Single responsibility

One class, one reason to change. Split a class that does three jobs, and see why a change stops spreading.

Pattern: SOLID20 min read
Quick recall3 questions
  1. 01In a class diagram, what does ──▷ mean?

    Inherits: the class at the tail extends the class at the head.

  2. 02What does encapsulation mean?

    An object keeps its data private. Other code reads or changes it only through the object's methods.

  3. 03What does YAGNI tell you?

    You aren't gonna need it: do not build for a need you do not have yet.

Your food delivery app sends a bill after every order. One class makes the bill, stores it and emails it.

That works fine, until three teams ask for three different changes in the same week. This lesson shows why that class starts to break, and how to split it.

The problem

Here is the class. It does three jobs:

  • total() adds the item prices and a Rs 30 delivery fee.
  • save() writes the invoice to the MySQL database.
  • sendEmail() mails the total to the customer.
record Invoice(String customer, int[] prices) {
    int total() {
        return 30 + Arrays.stream(prices).sum(); // 30 is the delivery fee
    }

    void save() {
        System.out.println("MySQL: saved invoice for " + customer);
    }

    void sendEmail() {
        System.out.println("Email to " + customer + ": total Rs " + total());
    }
}

In Java, a record is a short class: Java writes the constructor and the getters for you. Python's @dataclass does the same job.

For Asha's order of Rs 250 and Rs 120, save() and then sendEmail() print:

text
MySQL: saved invoice for Asha
Email to Asha: total Rs 400

Three requests, one class

The same week, three teams ask for changes:

  1. Finance: "Add 5% tax to every bill."
  2. Platform: "We move from MySQL to PostgreSQL."
  3. Marketing: "Send invoices on WhatsApp too."
One class, three teams who can ask it to change. Each arrow is a reason to change.

All three requests land in the same file. That hurts in three ways:

  • Merge conflicts. Three pull requests edit Invoice in the same week.
  • Side effects. A typo in the WhatsApp code can break total(), and finance sees wrong bills.
  • Slow tests. To test one sum, you load a class that also needs a database and a mail server.

The idea

A class should have only one reason to change.

A is a person or a team, not a method. Robert C. Martin, who named the principle, says it is about people: each class should answer to one group.

So you split Invoice by who asks for its changes:

After the split: each class has one team behind it. Checkout calls the other three.
  • Invoice holds the order and does the money math. Only finance rules change it.
  • InvoiceRepository stores invoices. Only a database change touches it.
  • EmailSender delivers the bill. Only messaging changes touch it.
  • Checkout runs the steps in order. It holds no rule of its own.

One reason to change does not mean one method. Invoice can have ten methods, as long as all of them are money math.

Find the reasons in three steps

Use this on any class that feels too big:

  1. List its methods. For Invoice: total, save, sendEmail.
  2. Next to each one, write who would ask to change it. Finance, platform, marketing.
  3. Group the methods by that name. Each group becomes one class. A small class such as Checkout then calls them in order.

When every method gets the same name, the class is fine as it is.

The fix

Keep the data and the math in Invoice. Move storage and sending into their own classes. Checkout calls them in order.

record Invoice(String customer, int[] prices) {
    int total() {
        return 30 + Arrays.stream(prices).sum(); // 30 is the delivery fee
    }
}

class InvoiceRepository {
    void save(Invoice invoice) {
        System.out.println("MySQL: saved invoice for " + invoice.customer());
    }
}

class EmailSender {
    void send(Invoice invoice) {
        System.out.println("Email to " + invoice.customer() + ": total Rs " + invoice.total());
    }
}

class Checkout {
    private final InvoiceRepository repository = new InvoiceRepository();
    private final EmailSender emailSender = new EmailSender();

    void finish(Invoice invoice) {
        repository.save(invoice);
        emailSender.send(invoice);
    }
}

Checkout().finish(...) with Asha's order prints the same two lines as before. The behaviour did not change; only the places where each rule lives did.

The three requests again

Marketing's WhatsApp request is now a new class next to EmailSender:

class WhatsAppSender {
    void send(Invoice invoice) {
        System.out.println("WhatsApp to " + invoice.customer() + ": total Rs " + invoice.total());
    }
}

It prints WhatsApp to Asha: total Rs 400. Here is where each request lands now:

RequestBefore: editsAfter: edits
Add 5% taxInvoiceInvoice only
Move to PostgreSQLInvoiceInvoiceRepository only
Send on WhatsAppInvoicenew WhatsAppSender, one line in Checkout

Each team now works in its own file. The PostgreSQL change cannot break the tax math, because the two no longer share a class.

How it runs

Checkoutfinish(invoice)
RepositoryInvoiceRepository
EmailSendermails the bill
Invoicethe money math
  1. Checkout to Repository:
    save(invoice)
  2. Repository to Checkout:
    saved
  3. Checkout to EmailSender:
    send(invoice)
  4. EmailSender to Invoice:
    total()
  5. Invoice to EmailSender:
    400
  6. EmailSender to Checkout:
    sent
Read it top to bottom: one order, three classes, each doing its own job.
  1. Checkout hands the invoice to the repository, which stores it.
  2. Checkout hands the same invoice to EmailSender.
  3. EmailSender asks Invoice for the total. It never computes money itself.

When to use it

Signals in the code

  • You need "and" to describe the class: "it computes totals and saves them and emails them".
  • One file imports unrelated worlds: a SQL driver and a mail client side by side.
  • Different teams edit the same file for reasons that have nothing in common.
  • A test needs a fake database to check a sum.

When it is too much

  • One class per method. If two methods always change together, for the same team, keep them together. Splitting them only adds files to jump between (KISS, from DRY, KISS, YAGNI and composition).
  • A small script that runs once. Nobody will ask it to change, so there is no reason to split it.

Look-alikes

RuleApplies toThe question it asks
Do one thinga functionDoes it do one step?
Single responsibilitya classDoes one team ask for its changes?
Separation of concernsa whole appAre screens, rules and storage apart?
Remember4 points
  1. 01

    A class should have one reason to change, and a reason is a person or team who can ask for that change.

  2. 02

    Split a class by who asks for its changes, not by its number of methods.

  3. 03

    After the split, the PostgreSQL move touches only InvoiceRepository, and WhatsApp is a new class.

  4. 04

    Do not split code that always changes together. One class per method is noise, not design.

Read more

Next: open for extension, closed for change. How to add a new rule as a new class, without editing a class that already works.