01In a class diagram, what does ──▷ mean?
Inherits: the class at the tail extends the class at the head.
02What does encapsulation mean?
An object keeps its data private. Other code reads or changes it only through the object's methods.
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:
MySQL: saved invoice for Asha
Email to Asha: total Rs 400Three requests, one class
The same week, three teams ask for changes:
- Finance: "Add 5% tax to every bill."
- Platform: "We move from MySQL to PostgreSQL."
- Marketing: "Send invoices on WhatsApp too."
┌────────────────────────────────────────────────────┐ │ │ │ ┌────────────────────────────┐ │ │ │ Invoice │ │ │ ├────────────────────────────┤ │ │ │ - customer: String │ │ │ │ - prices: int[] │ │ │ ├────────────────────────────┤ │ │ │ + total(): int │ ◀ finance │ │ │ + save(): void │ ◀ platform │ │ │ + sendEmail(): void │ ◀ marketing │ │ └────────────────────────────┘ │ │ + public - private ◀ who asks for changes │ │ │ └────────────────────────────────────────────────────┘
All three requests land in the same file. That hurts in three ways:
- Merge conflicts. Three pull requests edit
Invoicein 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:
┌───────────────────────────────────────────────────┐ │ │ │ ┌──────────────────────┐ │ │ │ Checkout │ │ │ ├──────────────────────┤ │ │ │ + finish(invoice) │ │ │ └──┬───────────────────┘ │ │ │ ┌──────────────────────┐ │ │ ├──▶ │ Invoice │ ◀ finance │ │ │ ├──────────────────────┤ │ │ │ │ + total(): int │ │ │ │ └──────────────────────┘ │ │ │ ┌──────────────────────┐ │ │ ├──▶ │ InvoiceRepository │ ◀ platform │ │ │ ├──────────────────────┤ │ │ │ │ + save(invoice) │ │ │ │ └──────────────────────┘ │ │ │ ┌──────────────────────┐ │ │ └──▶ │ EmailSender │ ◀ marketing │ │ ├──────────────────────┤ │ │ │ + send(invoice) │ │ │ └──────────────────────┘ │ │ ──▶ uses + public ◀ who asks for changes │ │ │ └───────────────────────────────────────────────────┘
Invoiceholds the order and does the money math. Only finance rules change it.InvoiceRepositorystores invoices. Only a database change touches it.EmailSenderdelivers the bill. Only messaging changes touch it.Checkoutruns 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:
- List its methods. For
Invoice:total,save,sendEmail. - Next to each one, write who would ask to change it. Finance, platform, marketing.
- Group the methods by that name. Each group becomes one class. A small class such as
Checkoutthen 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:
| Request | Before: edits | After: edits |
|---|---|---|
| Add 5% tax | Invoice | Invoice only |
| Move to PostgreSQL | Invoice | InvoiceRepository only |
| Send on WhatsApp | Invoice | new 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
- Checkout to Repository:save(invoice)
- Repository to Checkout:saved
- Checkout to EmailSender:send(invoice)
- EmailSender to Invoice:total()
- Invoice to EmailSender:400
- EmailSender to Checkout:sent
Checkouthands the invoice to the repository, which stores it.Checkouthands the same invoice toEmailSender.EmailSenderasksInvoicefor 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
| Rule | Applies to | The question it asks |
|---|---|---|
| Do one thing | a function | Does it do one step? |
| Single responsibility | a class | Does one team ask for its changes? |
| Separation of concerns | a whole app | Are screens, rules and storage apart? |
- 01
A class should have one reason to change, and a reason is a person or team who can ask for that change.
- 02
Split a class by who asks for its changes, not by its number of methods.
- 03
After the split, the PostgreSQL move touches only
InvoiceRepository, and WhatsApp is a new class. - 04
Do not split code that always changes together. One class per method is noise, not design.
Read more
- The Single Responsibility Principle by Robert C. Martin, on why the principle is about people
- Logging HOWTO in the Python docs: loggers, handlers, filters and formatters
- Single-responsibility principle on Wikipedia, with its history
Next: open for extension, closed for change. How to add a new rule as a new class, without editing a class that already works.