Cruxly

Cruxly › Low-level design › Principles & Patterns

Single Responsibility

one class, one reason to change

Open the interactive lesson

The principle: a class should have one, and only one, reason to change.

Not "one method" — one reason. A class can have ten methods and still satisfy SRP if all ten exist because of the same responsibility.

What Martin himself later said it means. That one-line version is the 1990s wording, and Robert Martin has called SRP the least well understood of the five, because "one reason" gets misread as "does one thing." He has restated it twice:

His own example is an Employee class with calculatePay(), reportHours() and save(). Three methods, three different requesters: accounting (under the CFO) owns pay rules, HR (under the COO) owns the hours report, and the DBAs (under the CTO) own persistence. Put them in one class and a change one group asked for can break another group's feature. SRP is about people, not code size. Every "reason to change" below is really a person or team.

The violation, concretely

class ParkingTicket {
    Vehicle vehicle;
    Spot spot;
    Instant entryTime, exitTime;

    Fee calculateFee() { ... }              // reason to change: pricing rules
    void printReceipt() { ... }             // reason to change: receipt formatting
    void saveToDatabase(Connection c) { ... } // reason to change: schema / persistence tech
    void sendEmailReceipt(String addr) { ... } // reason to change: email provider
}

Four reasons to change, one class. A pricing-team change to calculateFee and a DBA's schema migration to saveToDatabase now both require editing ParkingTicket, and a merge conflict between two unrelated teams becomes routine. Worse: a change to email formatting risks breaking fee calculation through nothing but proximity — nobody intended it, but both live in one file one person is editing under time pressure.

The fix — extract one class per reason

class ParkingTicket {                        // data + the one thing that is
    Vehicle vehicle;                          // genuinely intrinsic to a ticket
    Spot spot;
    Instant entryTime, exitTime;
}

interface FeeStrategy { Fee compute(ParkingTicket t); }   // reason: pricing rules
class ReceiptFormatter { String format(ParkingTicket t, Fee f); } // reason: formatting
class TicketRepository { void save(ParkingTicket t); }    // reason: persistence
class ReceiptMailer { void send(String addr, String receipt); }  // reason: email

class CheckoutService {                       // orchestrates; owns none of the above
    FeeStrategy fees; ReceiptFormatter fmt;
    TicketRepository repo; ReceiptMailer mailer;

    void checkout(ParkingTicket t, String email) {
        Fee fee = fees.compute(t);
        repo.save(t);
        mailer.send(email, fmt.format(t, fee));
    }
}

Four classes, four reasons to change, each isolated. CheckoutService looks like it "does everything" but it doesn't — it coordinates, owning no logic of its own. That distinction, between owning behaviour and merely sequencing calls to the classes that do, is what separates an orchestrator from a Manager god-object.

The dependency shape, before and after

BEFORE — four actors, one class pricing design DBA ops ParkingTicket 4 reasons to change every change is a merge conflict waiting to happen AFTER — one actor each pricing design DBA ops FeeStrategy ReceiptFormatter TicketRepository ReceiptMailer Checkout Service sequences only Exactly one class knows about all four — and it contains no logic of its own.
SRP is not about class size. It is about how many independent sources of change point at the same file.

Doing the refactor, step by step

The refactor above is shown as a finished result, which hides the order it has to happen in. Done in the wrong order it breaks the build for hours; done in this order every step compiles and the tests stay green.

@@ 1 | Name the actors out loud, before touching code

Write the list down: pricing team, design team, DBA, ops. Four actors means four target classes — you now know the shape of the end state before making a single edit, which is what stops a refactor from wandering.

@@ 2 | Extract the leaf that nothing depends on

ReceiptMailer first: it calls out, nothing calls back into it. Move the method, leave ParkingTicket.sendEmailReceipt() as a one-line delegation to it. The build stays green and every existing caller still works.

// temporary bridge — deleted in step 5
void sendEmailReceipt(String addr) { new ReceiptMailer().send(addr, ...); }

@@ 3 | Extract persistence next, because it has the most callers

TicketRepository is the risky one — lots of code calls save(). Same delegation trick. The point of doing it second rather than last is that its blast radius is known now, while the change set is still small.

@@ 4 | Extract the two that need an interface

FeeStrategy gets an interface because pricing was named as varying; ReceiptFormatter gets a concrete class because nothing said formatting varies. This asymmetry is deliberate and worth stating — an interface is not a reward for being extracted.

@@ 5 | Introduce the orchestrator, then delete the bridges

Now CheckoutService sequences the four. Migrate callers to it, then delete the delegating methods from ParkingTicket. It ends as pure data, which is correct for an entity whose only job is to be a ticket.

:::key Why the order matters Every intermediate state compiles and passes tests. A refactor that leaves the build broken across five steps is one interruption away from being abandoned half-done, and half-done is worse than not started — you now have both structures and neither is authoritative. :::

Where this already showed up, and where SRP goes too far

The parking-lot example in Running the LLD Interview made exactly this cut on sight — Gate/Display were excluded from the core model as I/O, and FeeStrategy was pulled out as an interface the moment pricing was named as variable. That was SRP and Open/Closed being applied before either had a name.

The test that generalises: name the actors who could request a change. "Marketing wants a new fee promotion," "the DBA is migrating to a new schema," "the ops team wants receipts in PDF" — three different people, three different classes, and if your design has all three landing on one class, that class has too many reasons to change.

Now the part most treatments skip: SRP taken literally produces a mess. "One reason to change" does not mean "one method per class." Split too aggressively and you get twelve classes that each do a third of a job, no single place that describes the workflow, and a reader who has to open eight files to understand checkout. That is a real and common failure, and it is harder to fix than the original god-class because the logic is now scattered.

The honest formulation: split when two responsibilities have demonstrably different rates or sources of change. Pricing changes monthly because marketing runs promotions; the database schema changes twice a year. Those belong apart. But validateEntry() and validateExit() both change when parking rules change, from the same actor — splitting those into two classes buys nothing and costs a file.

:::warn The interview version of this mistake A candidate who splits every class until each has one method is signalling that they memorised the letter rather than the reason. If asked to justify a split, the answer must name who changes each piece and how often — not "because single responsibility." :::

How to say it in the interview

"The test I actually run is: if I list the people or requirements that could force a change to this class, do they land in one bucket or several? Several buckets means the class is doing several jobs, and they will eventually need to change on different schedules."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons