Single responsibility
One reason to change per class.
01The intuition
The problem it solves
A school library program starts as one class that does everything a librarian does: it stores the books, decides who may borrow, works out fines, prints receipts and saves to a file.
class Library:
def __init__(self): self.books = {}; self.loans = []
def lend(self, book_id, member):
if member.fines > 50: raise ValueError("pay fines first")
if len([l for l in self.loans if l.member == member]) >= 3:
raise ValueError("limit reached")
self.loans.append(Loan(book_id, member))
print(f"Receipt: {member.name} borrowed {book_id}")
with open("loans.txt", "a") as f:
f.write(f"{book_id},{member.name}\n")
def fine_for(self, loan): return max(0, (loan.days_late()) * 2)
The first time the fine rule changes, you edit this class. When the receipt moves from the console to a printer, you edit it. When storage moves from a text file to a database, you edit it again. Three unrelated people, the head librarian, the designer of the receipt and the IT administrator, all have reasons to change the same file, and every change risks breaking the others. Testing lend writes a real file.
The single responsibility principle says: a class should have one reason to change. "Reason" means one kind of person or one kind of decision, not one method.
Build it one piece at a time
Step 1: list the reasons the class changes. Written down, they are: lending rules, fine arithmetic, receipt format, and how loans are stored. Four reasons, so the class wants to become four.
Step 2: pull out the one that is pure arithmetic. The fine policy has no need to know about files or receipts.
class FinePolicy:
def __init__(self, per_day: int = 2, max_unpaid: int = 50):
self.per_day = per_day
self.max_unpaid = max_unpaid
def fine_for(self, days_late: int) -> int:
return max(0, days_late * self.per_day)
def may_borrow(self, unpaid: int) -> bool:
return unpaid <= self.max_unpaid
Step 3: pull out the things that talk to the outside world. Storage and receipts each become an object with one job, behind a small interface so a test can swap in a fake.
class LoanStore:
def add(self, loan) -> None:
with open("loans.txt", "a") as f:
f.write(f"{loan.book_id},{loan.member.name}\n")
def count_for(self, member) -> int:
return sum(1 for l in self.all() if l.member == member)
class ReceiptPrinter:
def print_loan(self, loan) -> None:
print(f"Receipt: {loan.member.name} borrowed {loan.book_id}")
Step 4: leave the coordinator with only the lending decision. Library now reads like the rule it enforces, and each collaborator is handed in from outside.
class Library:
def __init__(self, policy: FinePolicy, store: LoanStore, printer: ReceiptPrinter):
self.policy, self.store, self.printer = policy, store, printer
def lend(self, book_id: str, member) -> None:
if not self.policy.may_borrow(member.unpaid_fines):
raise ValueError("pay fines first")
if self.store.count_for(member) >= 3:
raise ValueError("limit reached")
loan = Loan(book_id, member)
self.store.add(loan)
self.printer.print_loan(loan)
| Step | Classes | Who edits Library when fines change | Testing lend touches the disk |
|---|---|---|---|
| Start | 1 | yes | yes |
| 2 | 2 | no | yes |
| 3 and 4 | 4 | no | no, with a fake store |
Why it works
Each class now changes for exactly one reason, so a change is small, local and reviewable by the person who understands that reason. The invariant you can state is "everything in this file is about X": fines, or storage, or receipts. A reader finds the fine rule by opening FinePolicy, not by scanning a 400-line class. Tests shrink too, because a class with one job has a short list of things to prove.
Where it breaks: splitting too far. A Loan class with a separate LoanIdAssigner, LoanValidator and LoanDateCalculator makes a simple concept hard to follow, which is the opposite of the goal. The test is the question "who would ask for this change". If every split has a different answer, the split is right.
The trade-off you must say out loud
More classes means more names, more files and more wiring in the constructor. In an interview, name the responsibilities first, then say which ones you are splitting now and which you would split only when a second reason to change appears. The follow-up is usually "is this over-engineered for a 45-minute interview", and the honest answer is that the policy and the storage are worth separating because you need a fake for tests, and the receipt can stay inline until it grows.
Where it shows up in the 100 Google problems
- Design a library management system: catalogue, loans, fines and notifications each get an owner, and the fine rule is a policy object.
- Design a parking lot: the lot allocates spots, a fee calculator prices tickets, a gate issues them; nobody edits the lot to change prices.
- Design an online shopping system: cart arithmetic, discount rules, inventory checks and payment each live apart, which is what lets discounts change weekly.
- Design a logging framework: formatting, filtering and writing to a sink are three classes, so a new output format never touches the file writer.
- Design a restaurant management system: table seating, the kitchen queue and billing are different people's jobs in the restaurant and different classes in the code.