Single responsibility in depth
Module 03 gave single responsibility one line. This module makes it measurable: a responsibility is a group of people who can ask for changes. You'll see where to cut a class, how a bad cut shows up in the change history, and why splitting alone doesn't remove coupling.
1. The idea
"A class should do one thing" sounds clear until you try to measure "one thing". Is saving an expense one thing? Is checking that the shares add up part of it? Robert C. Martin's sharper version is: a class should have one reason to change, and a reason to change is a person or team who can ask for one. If two different groups can each send you change requests for the same class, it has two responsibilities, however tidy it looks.
| Inside one ExpenseService | Who asks for changes | A request they might send |
|---|---|---|
| Working out each person's share | Product team | "Add percentage splits", "round in the payer's favour" |
| Saving the expense | Platform team | "Move expenses to the new database" |
| Telling people what they owe | Messaging team | "Send a push, not an email", "translate into Spanish" |
| The order of the steps | Whoever owns the feature | "Check the group's limit before saving" |
Cohesion
How much the parts inside one class belong together. A class whose methods all use the same few fields and change for the same reason is highly cohesive. Splitting by who asks usually raises cohesion.
Coupling
How much one class needs to know about another. Splitting creates new connections; keep them thin (an interface, a few values passed in) or the pieces still move together.
Separation of concerns
The same idea at every size: functions, classes, modules, services. SRP is how it looks for one class. Architecture Module 07 applies it to components.
2. How it works
Follow one change request through two designs. The code does the same thing in both; what differs is who has to touch what.
3. Try it yourself
Read the change history
Six real change requests arrive over a few months. Pick a design and see which classes each request edits. Good splits put each team's requests in its own class; bad splits show up as classes that always change together.
4. In code
An expense service split by who asks for changes: the product team's split rules, the platform team's storage, the messaging team's notifications, and a coordinator that runs the steps. The second expense uses a split type that was added without touching any other class.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class Expense:
id: int
paid_by: str
amount: int # cents
shares: dict[str, int] # person -> cents they owe for this expense
def money(cents: int) -> str:
return f"{cents // 100}.{cents % 100:02d}"
# Asked for by the product team: how a bill is divided.
class SplitCalculator:
def equal(self, amount: int, people: list[str]) -> dict[str, int]:
base, extra = divmod(amount, len(people))
return {p: base + (1 if i < extra else 0) for i, p in enumerate(people)}
def by_percent(self, amount: int, percents: dict[str, int]) -> dict[str, int]:
shares = {p: amount * pct // 100 for p, pct in percents.items()}
first = next(iter(shares))
shares[first] += amount - sum(shares.values()) # the first person absorbs rounding
return shares
# Asked for by the platform team: where expenses are kept.
class ExpenseRepository(ABC):
@abstractmethod
def save(self, expense: Expense) -> None: ...
@abstractmethod
def count(self) -> int: ...
class InMemoryExpenses(ExpenseRepository):
def __init__(self) -> None:
self.rows: list[Expense] = []
def save(self, expense: Expense) -> None:
self.rows.append(expense)
def count(self) -> int:
return len(self.rows)
# Asked for by the messaging team: what people are told, and how.
class ExpenseNotifier:
def notify(self, expense: Expense) -> None:
for person, owed in expense.shares.items():
if person != expense.paid_by:
print(f"#{expense.id} {person} owes {expense.paid_by} {money(owed)}")
# The workflow itself: changes only if the steps of adding an expense change.
class ExpenseService:
def __init__(self, repo: ExpenseRepository, notifier: ExpenseNotifier) -> None:
self.repo, self.notifier = repo, notifier
def add(self, paid_by: str, amount: int, shares: dict[str, int]) -> Expense:
if sum(shares.values()) != amount:
raise ValueError("shares must add up to the amount")
expense = Expense(self.repo.count() + 1, paid_by, amount, shares)
self.repo.save(expense)
self.notifier.notify(expense)
return expense
if __name__ == "__main__":
split, repo = SplitCalculator(), InMemoryExpenses()
service = ExpenseService(repo, ExpenseNotifier())
service.add("Ben", 10000, split.equal(10000, ["Ana", "Ben", "Chen"]))
# The product team adds percentage splits: only SplitCalculator changed.
service.add("Ana", 6000, split.by_percent(6000, {"Ana": 50, "Ben": 30, "Chen": 20}))
print(f"stored: {repo.count()} expenses")
interface Expense { id: number; paidBy: string; amount: number; shares: Map<string, number>; } // cents
const money = (c: number): string => `${Math.floor(c / 100)}.${String(c % 100).padStart(2, "0")}`;
// Asked for by the product team: how a bill is divided.
class SplitCalculator {
equal(amount: number, people: string[]): Map<string, number> {
const base = Math.floor(amount / people.length), extra = amount % people.length;
return new Map(people.map((p, i): [string, number] => [p, base + (i < extra ? 1 : 0)]));
}
byPercent(amount: number, percents: Map<string, number>): Map<string, number> {
const shares = new Map<string, number>();
for (const [p, pct] of percents) shares.set(p, Math.floor((amount * pct) / 100));
const first = [...shares.keys()][0];
const total = [...shares.values()].reduce((s, v) => s + v, 0);
shares.set(first, (shares.get(first) ?? 0) + amount - total); // the first person absorbs rounding
return shares;
}
}
// Asked for by the platform team: where expenses are kept.
interface ExpenseRepository { save(expense: Expense): void; count(): number; }
class InMemoryExpenses implements ExpenseRepository {
private rows: Expense[] = [];
save(expense: Expense): void { this.rows.push(expense); }
count(): number { return this.rows.length; }
}
// Asked for by the messaging team: what people are told, and how.
class ExpenseNotifier {
notify(e: Expense): void {
for (const [person, owed] of e.shares) if (person !== e.paidBy) console.log(`#${e.id} ${person} owes ${e.paidBy} ${money(owed)}`);
}
}
// The workflow itself: changes only if the steps of adding an expense change.
class ExpenseService {
constructor(private repo: ExpenseRepository, private notifier: ExpenseNotifier) {}
add(paidBy: string, amount: number, shares: Map<string, number>): Expense {
if ([...shares.values()].reduce((s, v) => s + v, 0) !== amount) throw new Error("shares must add up to the amount");
const expense: Expense = { id: this.repo.count() + 1, paidBy, amount, shares };
this.repo.save(expense);
this.notifier.notify(expense);
return expense;
}
}
const split = new SplitCalculator(), repo = new InMemoryExpenses();
const service = new ExpenseService(repo, new ExpenseNotifier());
service.add("Ben", 10000, split.equal(10000, ["Ana", "Ben", "Chen"]));
// The product team adds percentage splits: only SplitCalculator changed.
service.add("Ana", 6000, split.byPercent(6000, new Map([["Ana", 50], ["Ben", 30], ["Chen", 20]])));
console.log(`stored: ${repo.count()} expenses`);import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
record Expense(int id, String paidBy, int amount, Map<String, Integer> shares) {} // cents
// Asked for by the product team: how a bill is divided.
class SplitCalculator {
Map<String, Integer> equal(int amount, List<String> people) {
int base = amount / people.size(), extra = amount % people.size();
Map<String, Integer> shares = new LinkedHashMap<>();
for (int i = 0; i < people.size(); i++) shares.put(people.get(i), base + (i < extra ? 1 : 0));
return shares;
}
Map<String, Integer> byPercent(int amount, Map<String, Integer> percents) {
Map<String, Integer> shares = new LinkedHashMap<>();
percents.forEach((p, pct) -> shares.put(p, amount * pct / 100));
String first = shares.keySet().iterator().next();
int total = shares.values().stream().mapToInt(Integer::intValue).sum();
shares.put(first, shares.get(first) + amount - total); // the first person absorbs rounding
return shares;
}
}
// Asked for by the platform team: where expenses are kept.
interface ExpenseRepository {
void save(Expense expense);
int count();
}
class InMemoryExpenses implements ExpenseRepository {
private final List<Expense> rows = new ArrayList<>();
public void save(Expense expense) { rows.add(expense); }
public int count() { return rows.size(); }
}
// Asked for by the messaging team: what people are told, and how.
class ExpenseNotifier {
static String money(int c) { return (c / 100) + "." + String.format("%02d", c % 100); }
void notify(Expense e) {
e.shares().forEach((person, owed) -> {
if (!person.equals(e.paidBy())) System.out.println("#" + e.id() + " " + person + " owes " + e.paidBy() + " " + money(owed));
});
}
}
// The workflow itself: changes only if the steps of adding an expense change.
class ExpenseService {
private final ExpenseRepository repo;
private final ExpenseNotifier notifier;
ExpenseService(ExpenseRepository repo, ExpenseNotifier notifier) { this.repo = repo; this.notifier = notifier; }
Expense add(String paidBy, int amount, Map<String, Integer> shares) {
if (shares.values().stream().mapToInt(Integer::intValue).sum() != amount) throw new IllegalArgumentException("shares must add up to the amount");
Expense expense = new Expense(repo.count() + 1, paidBy, amount, shares);
repo.save(expense);
notifier.notify(expense);
return expense;
}
}
public class Main {
public static void main(String[] args) {
SplitCalculator split = new SplitCalculator();
InMemoryExpenses repo = new InMemoryExpenses();
ExpenseService service = new ExpenseService(repo, new ExpenseNotifier());
service.add("Ben", 10000, split.equal(10000, List.of("Ana", "Ben", "Chen")));
// The product team adds percentage splits: only SplitCalculator changed.
Map<String, Integer> pct = new LinkedHashMap<>();
pct.put("Ana", 50); pct.put("Ben", 30); pct.put("Chen", 20);
service.add("Ana", 6000, split.byPercent(6000, pct));
System.out.println("stored: " + repo.count() + " expenses");
}
}Output (the same in all three languages)
#1 Ana owes Ben 33.34 #1 Chen owes Ben 33.33 #2 Ben owes Ana 18.00 #2 Chen owes Ana 12.00 stored: 2 expenses
5. In the interview
- Say who asks. "Split rules come from product, storage from the platform team, messages from the messaging team, so they're three classes" is a stronger reason than "it was getting big".
- Test a split before you commit to it. Can you describe each new class in one sentence without "and"? Could you test it without building the others? Would the two halves usually change in the same commit? If they would, it's one responsibility cut in half.
- Leave a coordinator. Something still runs the steps in order (here, ExpenseService). Its one reason to change is the workflow itself.
- Don't split what nobody will change. In a 45-minute interview, split where the prompt hints at change ("we may add more split types", "notifications might go by SMS later").
6. Remember it
How this hook works: what “One class, one team asking” means
What the slogan means, phrase by phrase
- “One class”
- The unit we're judging: a class (or a module).
- “one team asking”
- Count the people or teams who can send change requests; that's the number of responsibilities.
Why this picture fits: A restaurant menu: the chef edits dishes, the owner edits prices, the designer edits the print. One file each.
The story → the concept
| The restaurant | The code |
|---|---|
| The chef's dishes | SplitCalculator (product team) |
| The owner's prices | ExpenseRepository (platform team) |
| The designer's print | ExpenseNotifier (messaging team) |
| The menu itself | ExpenseService, the workflow |
Watch it on an example
- Product adds percentage splits: only SplitCalculator changes.
- Messaging switches to push: only ExpenseNotifier changes.
- A new currency field touches everything, and that's fine: it's a change to the data itself.
The trap it saves you from
Splitting by step or by method (Validator, Calculator, Saver…), so pieces that always change together live in different files.
It still works when…
Even in tiny programs: you don't have to split, but you should be able to say who would ask for each change.
7. Common mistakes
8. Practice exercise (25 minutes)
- List the askers (5 min). Take the ParkingLot class from your last design. For each method, write which team would ask to change it.
- Split once (10 min). Move one team's methods into its own class with a name that stands alone. What does the original class still do?
- Test the split (5 min). Write one test for the new class without creating the old one. Did you need fakes?
- Find a bad split (5 min). In code you know, find two classes that always change in the same commits. Should they be one?
9. Self-check quiz
What is the clearest test that a class has two responsibilities?
ExpenseValidator and ExpenseCalculator appear together in nearly every commit. What does that suggest?
After splitting, what still runs the steps in order?
Which change is fine to touch every class?
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Module 03b: Open/closed in depth | Next | Once responsibilities are separate, new behaviour can arrive as new classes. |
| Modularity, cohesion and coupling (Software architecture) | 30 min | Cohesion and coupling measured for components. |
| P11: Expense sharing | 45 min | The full problem this module's example comes from. |
| The Single Responsibility Principle (Robert C. Martin) | 10 min | The author's own explanation of "reason to change" as people. |