Single Responsibility Principle (SRP)
The Problem It Solves
OrderService validates orders, persists them, sends confirmation emails, and generates invoices. Three different teams touch it: checkout owns validation rules, platform owns persistence, growth owns email copy. Every change from any team redeploys — and risks — all responsibilities. Merge conflicts, surprise regressions (“email change broke validation?”), and review paralysis follow mechanically.
SRP (Robert C. Martin): a class should have exactly one reason to change — one actor whose requirements it serves.
Seeing Responsibilities
ONE CLASS, THREE ACTORS SPLIT BY REASON-TO-CHANGE
┌──────────────────────┐ ┌──────────────┐
│ OrderService │ │ OrderValidator│ ← checkout team
│ validate() │ each arrow = ├──────────────┤
│ save() │ a different │ OrderRepository│← platform team
│ sendConfirmation() │ actor with ├──────────────┤
│ generateInvoice() │ independent │ Notifier │ ← growth team
└──────────────────────┘ change reasons└──────────────┘
Left: four reasons to change inside one file. Right: each reason isolated; email copy changes can never break invoice generation because they no longer share a compilation unit. The split follows actors, not method counts — a class with five methods serving one actor is SRP-compliant.
Mechanics
// before: three actors' logic entangled
class OrderService {
void place(Order o) {
if (o.items().isEmpty()) throw new InvalidOrder(); // checkout's rule
db.insert(o); // platform's concern
smtp.send(o.userEmail(), template); // growth's copy
}
}
// after: orchestration only
class CheckoutService {
private final OrderValidator validator;
private final OrderRepository repo;
private final OrderNotifier notifier;
void place(Order o) {
validator.check(o);
repo.save(o);
notifier.confirmation(o);
}
}
The orchestrator remains small and stable; each extracted collaborator changes on its own schedule behind its own interface.
What SRP Is Not
| Misreading | Correction |
|---|---|
| ”One method per class” | It is about reasons to change (actors), not granularity |
| ”Split everything” | Over-splitting creates shotgun-surgery across ten files for one feature |
| ”Only for big classes” | A 40-line class serving two actors violates it too |
Detection Signals
- Class name contains
Manager,Service,Helper,Processorplus multiple domain verbs. - Imports span unrelated domains (SMTP + SQL + PDF in one file).
- Different teams repeatedly editing the same file.
- Unit tests needing database + mail server mocks together to test one method.
Interview Application
- Naming the actor when justifying a split (“this belongs to billing’s rate of change”) scores over mechanical splitting.
- Follow-ups probe boundaries: “should validation live on Order itself?” — answer via cohesion: rules guarding Order’s invariants belong to Order; cross-entity workflow stays in a service.
Failure Modes
- Over-extraction into anemic data bags — moving methods off entities without preserving behavior ownership recreates the anti-pattern this principle prevents.
The Situation
A 3,000-line ReportManager: reads requests, parses CSV, computes metrics, formats HTML, emails results, logs to DB, retries failures. Every feature request touches it; every test needs the world mocked; nobody can describe what it is in one sentence. Refactoring it wrong (big-bang rewrite) is how regressions ship — the value of this page is a safe, incremental decomposition path.
The Procedure
STEP 1 STEP 2 STEP 3 STEP 4
Map duties → Cluster into → Extract class → Delete old
by responsibility paths when callers
migrate
┌─────────────────────┐
│ parse() ◄─┐ │ Parsing ReportParser
│ csvLoad() ◄─┤ same│ ┌───────────┐ (owns parsing rules)
│ metrics() ◄─┼─ grp│ │ extraction│
│ htmlFmt() ◄─┘ │ ▼ order ReportRenderer
│ email() alone │ metrics → fmt (owns presentation)
│ retryLog() alone │ email/retry → DeliveryService
└─────────────────────┘
Step 1 — inventory: list every public/private method and field. Step 2 — cluster: group methods that change together and share fields (fields are the strongest cohesion signal — methods reading the same three fields belong together). Step 3 — extract one cluster at a time, tests green between each. Step 4 — retire: once all callers use new classes, delete forwarding shims.
Keeping It Safe
- Tests first: pin current behavior with characterization tests before moving anything — they become the safety net.
- One extraction per commit: reviewable units, bisectable regressions.
- Delegation facade during migration: keep the old class temporarily delegating to new parts so existing callers never break mid-refactor:
// transitional shim — delete after caller migration
class ReportManager {
private final ReportParser parser = new ReportParser();
private final DeliveryService delivery = new DeliveryService();
Report handle(Request r) { return delivery.send(parser.parse(r)); }
}
- Behavior-preserving rule: no feature changes ride along. “While I’m here” edits destroy bisectability.
Common Pitfalls
| Pitfall | Consequence |
|---|---|
| Extracting data without behavior | Anemic DTOs + a coordinator that still knows everything |
| Clustering by layer (all parsers together) | New god classes at layer scale |
| Extracting before writing tests | No way to prove behavior survived |
| Big-bang split in one PR | Unreviewable, un-bisectable |
Detection Signals a Split Is Due
- One sentence cannot describe the class without “and”.
- Field sets used by disjoint method groups.
- Import list spans unrelated packages (mail + SQL + templating).
- Test setup mocks more than two external worlds for one unit.
Interview Framing
- Asked to “fix this design,” walking the four steps with the actor justification (“these methods change for different teams”) scores higher than redrawing everything from scratch.
- Mentioning the delegation-facade migration trick signals real-world refactoring experience.
Premium Content
Unlock Single Responsibility Principle and all premium lessons with a subscription.
From ₹199.99/year — See plans