Course contents

Single Responsibility Principle

Sign in to run this lab and save your progress

Learn

Before you start

Welcome to SOLID. Five design principles named by Robert Martin ("Uncle Bob"). They put words to the habits you'll lean on for the rest of your career.

S — Single Responsibility Principle (this lesson) O — Open/Closed Principle L — Liskov Substitution Principle I — Interface Segregation Principle D — Dependency Inversion Principle

Every principle has the same shape: spot the smell, apply the fix, feel the difference. Today: SRP.

The exact definition

Uncle Bob's version: "A class should have one, and only one, reason to change."

The important word is change. Not "does one thing" — that's vague. Reason to change is concrete: if two different teams would both want to edit this class for their own reasons, it has too many jobs.

The Invoice example, step by step

The god class in the objective has four reasons to change:

What triggers a change Which method changes
Add tax total()
HTML receipts format_text()
Move to S3 save_to_file()
Switch email provider email()

Four different teams, all editing the same file every quarter. Every quarter, one team's change accidentally breaks another team's tests. This is what SRP prevents.

After the fix:

What triggers a change Which class changes
Add tax Invoice
HTML receipts InvoiceFormatter
Move to S3 InvoiceRepository
Switch email provider InvoiceMailer

Four unrelated classes, four unrelated teams, no more conflicts.

Why this matters in design interviews

Every design problem you meet has a hidden god class temptation. The person who writes:

class ParkingLot:
    def park(self, ...): ...
    def unpark(self, ...): ...
    def calculate_fee(self, ...): ...
    def send_receipt(self, ...): ...
    def report_occupancy(self, ...): ...
    def log_to_audit(self, ...): ...

is missing SRP. The person who says "these are separate concerns — FeeCalculator, Receipt, OccupancyReport, AuditLog" is showing the design maturity interviewers reward.

About the "delegates to" tests

Two of the tests in this lesson check that Repository and Mailer actually USE the Formatter, instead of writing the format themselves. That's the SRP shape: one place to change the format, one place to change how we save, one place to change how we send. If any of them copies another's logic, you've re-created the god class in a slightly bigger package.

Your task

S in SOLID. The full rule from Robert Martin: A class should have one, and only one, reason to change.

Read that carefully. It's NOT "a class does one thing" — that's too vague. It's about change: if two different teams or requirements would both want to modify this class for their own reasons, it has too many jobs.

The "god class" problem

Here's an Invoice class that breaks this rule:

class Invoice:
    def __init__(self, items):
        self.items = items

    def total(self):
        return sum(i["price"] * i["qty"] for i in self.items)

    def format_text(self):
        lines = [f"{i['name']} x{i['qty']}: {i['price'] * i['qty']}"
                 for i in self.items]
        lines.append(f"TOTAL: {self.total()}")
        return "\n".join(lines)

    def save_to_file(self, path):
        with open(path, "w") as f:
            f.write(self.format_text())

    def email(self, to_address):
        print(f"emailing to {to_address}: {self.format_text()}")

What could make this class change?

  • Business adds tax → total() changes.
  • Marketing wants HTML receipts → format_text() changes.
  • Ops moves storage to S3 → save_to_file() changes.
  • Notification team switches email providers → email() changes.

Four different reasons, four different teams touching the same file every quarter. Merge conflicts, "who broke this?", and the file grows every year.

The fix: split by job

class Invoice:
    def __init__(self, items):
        self.items = items

    def total(self):
        return sum(i["price"] * i["qty"] for i in self.items)


class InvoiceFormatter:
    def format(self, invoice):
        ...


class InvoiceRepository:
    def save(self, invoice, path):
        ...


class InvoiceMailer:
    def email(self, invoice, to_address):
        ...

Now each class has ONE job. Formatter changes when the format changes. Repository changes when we change how we save. Mailer changes when we change how we send. Invoice itself only changes when the invoice DATA model changes.

Your task

You're given the god-class shape above. Split it into four classes in main.py:

  1. Invoice — holds items (list of dicts with name, price, qty). Exposes total() returning the numeric total.
  2. InvoiceFormatter — has a format(invoice) method returning a multi-line string. Format:
    <name> x<qty>: <line_total>
    ...
    TOTAL: <grand_total>
    
  3. InvoiceRepository — has a save(invoice, path) method that writes the formatted invoice to disk at path. It should USE InvoiceFormatter — no re-implementing the format.
  4. InvoiceMailer — has a send(invoice, to_address) method that returns a string: "emailing <to_address>: <formatted_invoice>". Also uses InvoiceFormatter.

Tests verify each responsibility in isolation AND that Repository/Mailer delegate to Formatter (extension test: changing the formatter must change the file and email content).

Click Submit to run the tests.

Starter files

Preview only — open the lab to edit and run.

files_dir
01-single-responsibility-starter