Concepts / SOLID and design principles / Single responsibility

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)
StepClassesWho edits Library when fines changeTesting lend touches the disk
Start1yesyes
22noyes
3 and 44nono, 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

02Used in these problems