1

Why Design Principles?

When One Change Breaks Everything

Goal: Feel how a single private helper, shared across teams, makes simple change requests collide. (~20 min. Assumes Python classes, dicts, f-strings, json.dumps — see the Python tutorial if shaky.)

Four Key Words

SOLID is written in these four terms — every step below uses them.

  • Actor — a person or team who asks for changes. Finance, Registrar, Legal, Ops. Actors file tickets; they don’t read your code. Different actors want different things for different reasons.
  • Reason to change — a real-world force that makes you edit a file. Tax law shifts → Finance edits. HIPAA update → Legal edits. Different actors = different reasons to change.
  • Coupling — how much one chunk of code depends on the internals of another. Two classes are tightly coupled when touching one forces you to touch the other. Shared private helpers, reaching into another class’s state, and direct new Database() calls all create coupling.
  • Cohesion — how much the things inside one class belong together. A class is highly cohesive when its methods all serve the same actor for the same reason. Low cohesion = a Swiss-army-knife class where unrelated features are awkward roommates.

Good design = low coupling between modules, high cohesion within each module. SOLID is five specific tactics for hitting that target.

Inherited Code Is Normal. The Stress Is Normal.

You’re about to open a tangled class. That isn’t a contrived teaching example — it’s week 1 of almost every real engineering job. Every senior developer you’ll ever meet has been exactly where you are in the next 20 minutes. If the code feels overwhelming: you’re reading it right.

The Scenario

You just inherited a CourseManager class from a colleague who left the company. This thing handles everything: student enrollment, grade calculation, GPA formatting, HTML report generation, and email notifications. It works — for now.

Your manager drops two change requests on you:

  1. Finance Team: Switch the grading scale from letter grades (A/B/C/D/F) to a 4.0 numeric scale (4.0/3.0/2.0/1.0/0.0).
  2. Registrar’s Office: Change the report format from HTML to JSON.

Two small changes from two different teams. Should be easy, right? …Right?

Predict Before You Type

Before you touch the code, look at _format_grade() on line 105. It is a private helper. Predict which of these will happen when you change it:

  • (A) Only generate_report() is affected — _format_grade() is used there.
  • (B) Only calculate_gpa() is affected — _format_grade() is used there.
  • (C) Both generate_report() and calculate_gpa() change together, whether you want them to or not.
  • (D) Nothing else is affected — it’s a private method, so changes stay local.

Hold your prediction. You’ll check it in the quiz.

Your Task — Bounded Success Criteria

Open course_manager.py and make both changes.

Minimum success criteria (the tests check these):

  1. _grade_weights uses numeric keys 4.0, 3.0, 2.0, 1.0, 0.0 instead of "A", "B", "C", "D", "F", and add_grade() accepts those numeric grades.
  2. generate_report() returns a JSON string (built with json.dumps) instead of HTML. The JSON should at least contain the student’s name, their courses with grades, and the GPA.
  3. The demo script at the bottom still runs end-to-end without raising.

You are allowed to patch in place or add helpers — there is no “one right refactor” yet. SOLID tools arrive in the next ten steps. For now, just survive both tickets with a script that still runs.

The Real Lesson — Watch What Ripples

As you edit, keep one eye on _format_grade(). It is used by three other methods: get_grade_summary(), generate_report(), and send_grade_notification(). Any format change you make ripples into all three outputs simultaneously — even though each one has a different audience (academic display vs. registrar’s report vs. student email). The friction you feel is the point. SOLID is five ways to defuse exactly this kind of ripple.

Errors are the lesson. You will almost certainly break something on the first attempt. That’s normal — read the message, adjust, re-run. If you want a reference after your attempt, hit the “Solution” button in the top bar to compare. The point isn’t to guess the perfect patch; it’s to feel how the design resists you.

Reflect → Fill in the Ownership

Before moving on, take 60 seconds and complete this sentence by filling the five blanks with concrete names from the code (method names, helper names, actor names — not “SRP violation”):

CourseManager hurt to change because ____ was shared between ____ and ____, so a ticket from the ____ team rippled into an output owned by the ______ team.”

If you can fill all five blanks with specific names from the code, you’ve built the mental model the next ten steps stand on.

Starter files
course_manager.py
from __future__ import annotations


class CourseManager:
    """Monolithic class handling all course-related operations."""

    def __init__(self) -> None:
        self._students = {}  # {student_id: {"name": str, "courses": {course: grade}}}
        self._grade_weights = {"A": 4.0, "B": 3.0, "C": 2.0, "D": 1.0, "F": 0.0}
        self._email_log = []

    # ── Enrollment ──────────────────────────────────────────────
    def enroll_student(self, student_id: str, name: str) -> None:
        if student_id in self._students:
            raise ValueError(f"Student {student_id} is already enrolled")
        self._students[student_id] = {"name": name, "courses": {}}

    def add_grade(self, student_id: str, course: str, grade: str) -> None:
        if student_id not in self._students:
            raise ValueError(f"Student {student_id} not found")
        if grade not in self._grade_weights:
            raise ValueError(f"Invalid grade: {grade}. Must be one of {list(self._grade_weights.keys())}")
        self._students[student_id]["courses"][course] = grade

    def get_student(self, student_id: str) -> dict[str, object]:
        if student_id not in self._students:
            raise ValueError(f"Student {student_id} not found")
        return self._students[student_id]

    # ── Grade Calculation ──────────────────────────────────────
    def _format_grade(self, grade: str) -> str:
        """Shared helper: formats a grade for display.
        Used by BOTH calculate_gpa() and generate_report().
        WARNING: changing this affects multiple features!"""
        return f"{grade} ({self._grade_weights[grade]:.1f})"

    def calculate_gpa(self, student_id: str) -> float:
        student = self.get_student(student_id)
        courses = student["courses"]
        if not courses:
            return 0.0
        total = sum(self._grade_weights[g] for g in courses.values())
        gpa = total / len(courses)
        return gpa

    def get_grade_summary(self, student_id: str) -> str:
        student = self.get_student(student_id)
        lines = []
        for course, grade in student["courses"].items():
            formatted = self._format_grade(grade)
            lines.append(f"  {course}: {formatted}")
        gpa = self.calculate_gpa(student_id)
        lines.append(f"  GPA: {gpa:.2f}")
        return "\n".join(lines)

    # ── GPA Formatting ────────────────────────────────────────
    def format_gpa_for_transcript(self, student_id: str) -> str:
        gpa = self.calculate_gpa(student_id)
        if gpa >= 3.5:
            honors = " (Dean's List)"
        elif gpa >= 3.0:
            honors = " (Good Standing)"
        else:
            honors = ""
        return f"Cumulative GPA: {gpa:.2f}{honors}"

    # ── HTML Report Generation ─────────────────────────────────
    def generate_report(self, student_id: str) -> str:
        student = self.get_student(student_id)
        name = student["name"]
        html = f"<html><body><h1>Report for {name}</h1><table>"
        html += "<tr><th>Course</th><th>Grade</th></tr>"
        for course, grade in student["courses"].items():
            formatted = self._format_grade(grade)
            html += f"<tr><td>{course}</td><td>{formatted}</td></tr>"
        gpa = self.calculate_gpa(student_id)
        html += f"</table><p>GPA: {gpa:.2f}</p></body></html>"
        return html

    # ── Email Notification (simulated) ─────────────────────────
    def send_grade_notification(self, student_id: str, course: str) -> None:
        student = self.get_student(student_id)
        grade = student["courses"].get(course)
        if grade is None:
            raise ValueError(f"No grade for {course}")
        formatted = self._format_grade(grade)
        message = f"Dear {student['name']}, your grade for {course} is {formatted}."
        print(f"[EMAIL] To: {student_id} | {message}")
        self._email_log.append({"to": student_id, "message": message})

    def get_email_log(self) -> list[dict[str, str]]:
        return list(self._email_log)


# ── Demo usage ─────────────────────────────────────────────────
if __name__ == "__main__":
    mgr = CourseManager()
    mgr.enroll_student("S001", "Alice Johnson")
    mgr.add_grade("S001", "CS 101", "A")
    mgr.add_grade("S001", "MATH 201", "B")
    mgr.add_grade("S001", "ENG 101", "A")

    mgr.enroll_student("S002", "Bob Smith")
    mgr.add_grade("S002", "CS 101", "C")
    mgr.add_grade("S002", "MATH 201", "D")

    print("=== Grade Summary (Alice) ===")
    print(mgr.get_grade_summary("S001"))
    print()
    print("=== Transcript GPA (Alice) ===")
    print(mgr.format_gpa_for_transcript("S001"))
    print()
    print("=== HTML Report (Alice) ===")
    print(mgr.generate_report("S001"))
    print()
    print("=== Email Notification ===")
    mgr.send_grade_notification("S001", "CS 101")
2

SRP — Feel the Friction

The Single Responsibility Principle

Goal: Define SRP in terms of actors and spot SRP violations in existing code.

The Principle

“A class should have only one reason to change.” — Robert C. Martin

A “reason to change” comes from an actor — a person or team that asks for modifications (e.g., the actor in a user story). If a class serves multiple actors, one team’s change request can break another team’s stuff. That’s exactly the mess you just dealt with in Step 1.

The Scenario

Here’s an EmployeeManager class that three different teams depend on:

Actor What they care about
Finance Team Payroll calculation and tax brackets
HR Team Pay stub formatting and employee records
Compliance Team CSV export for audit reports

The Finance Team just messaged you: the 22% federal tax bracket needs to become 24% because of new tax legislation. Urgent, apparently.

Your Task

Open employee_manager.py and update the tax calculation so that the 22% bracket becomes 24%. Here are the brackets:

  • Income up to $50,000: 10%
  • Income from $50,001 to $100,000: 24% (was 22%)
  • Income above $100,000: 32%

After making the change, run the code. Watch what happens to the pay stub — does _format_currency() still work correctly for everyone who calls it?

What to Look For

Notice how _format_currency() is shared between calculate_payroll() and generate_pay_stub(). Now think: if HR later wants to change the currency format (say, adding thousands separators), would that accidentally mess up the payroll calculations too?

Code Smells in Play

The EmployeeManager class exhibits two named code smells that often travel together:

  • God Class (also called Large Class) — a single class that accumulates too many unrelated responsibilities. Easy to recognize: it keeps growing, and “where should I add this?” always ends up being this class.
  • Shotgun Surgery — a change that requires edits scattered across many places. One simple refactor in this class causes ripples to Finance’s, HR’s, and Compliance’s outputs simultaneously.

Knowing these names is useful: your IDE, static analysis tools (SonarQube, PyLint), and senior developers in code review will all reach for this vocabulary. (See the code smells reference in SEBook/development_practices/code_smells for more.)

Struggle is the lesson. This step is supposed to feel clunky. You’ll make a change, run the code, and realize something unrelated broke. That’s not your mistake — that’s the class’s design talking.

Reflect → Write the Incident Report

Before you run the code, imagine that in six months, the HR team files a bug: “The pay stub started showing the tax rate as 24% instead of 22%, but nobody on HR asked for that change.” Write a one-paragraph incident report (3–4 sentences) that would land on your team’s postmortem channel. Name the specific method that was edited, name the specific method that was affected, and name which team asked for the original change. Writing the bug report you’d actually send is a faster path to the schema than writing a textbook definition of SRP — and you’ll find yourself reading real incident reports just like this one in your first engineering job.

Starter files
employee_manager.py
from __future__ import annotations


class EmployeeManager:
    """Manages employees, payroll, pay stubs, and exports.
    Serves three actors: Finance, HR, and Compliance."""

    def __init__(self) -> None:
        self._employees = {}  # {emp_id: {"name": str, "salary": float, "department": str}}

    # ── Employee Storage ────────────────────────────────────────
    def add_employee(self, emp_id: str, name: str, salary: float, department: str) -> None:
        if emp_id in self._employees:
            raise ValueError(f"Employee {emp_id} already exists")
        self._employees[emp_id] = {
            "name": name,
            "salary": salary,
            "department": department,
        }

    def get_employee(self, emp_id: str) -> dict[str, object]:
        if emp_id not in self._employees:
            raise ValueError(f"Employee {emp_id} not found")
        return self._employees[emp_id]

    def validate_employee_data(self, emp_id: str, name: str, salary: float, department: str) -> list[str]:
        errors = []
        if not name or not name.strip():
            errors.append("Name cannot be empty")
        if salary < 0:
            errors.append("Salary cannot be negative")
        if not department or not department.strip():
            errors.append("Department cannot be empty")
        return errors

    # ── Shared Helper ─────────────────────────────────────────
    def _format_currency(self, amount: float) -> str:
        """Shared helper: formats a dollar amount for display.
        Used by BOTH calculate_payroll() and generate_pay_stub().
        WARNING: changing this affects multiple features!"""
        return f"${amount:,.2f}"

    # ── Payroll Calculation (Finance Team) ─────────────────────
    def calculate_payroll(self, emp_id: str) -> dict[str, object]:
        employee = self.get_employee(emp_id)
        salary = employee["salary"]

        # Federal tax brackets
        if salary <= 50000:
            tax_rate = 0.10
        elif salary <= 100000:
            tax_rate = 0.22   # TODO: Finance wants this changed to 0.24
        else:
            tax_rate = 0.32

        tax = salary * tax_rate
        net_pay = salary - tax

        return {
            "gross": salary,
            "tax_rate": tax_rate,
            "tax": tax,
            "net_pay": net_pay,
            "gross_formatted": self._format_currency(salary),
            "tax_formatted": self._format_currency(tax),
            "net_formatted": self._format_currency(net_pay),
        }

    # ── Pay Stub Formatting (HR Team) ──────────────────────────
    def generate_pay_stub(self, emp_id: str) -> str:
        employee = self.get_employee(emp_id)
        payroll = self.calculate_payroll(emp_id)
        stub = []
        stub.append("=" * 40)
        stub.append(f"  PAY STUB - {employee['name']}")
        stub.append(f"  Department: {employee['department']}")
        stub.append("=" * 40)
        stub.append(f"  Gross Pay:  {self._format_currency(payroll['gross'])}")
        stub.append(f"  Tax Rate:   {payroll['tax_rate']:.0%}")
        stub.append(f"  Tax:        {self._format_currency(payroll['tax'])}")
        stub.append(f"  Net Pay:    {self._format_currency(payroll['net_pay'])}")
        stub.append("=" * 40)
        return "\n".join(stub)

    # ── CSV Export (Compliance Team) ───────────────────────────
    def export_to_csv(self) -> str:
        lines = ["emp_id,name,salary,department,tax_rate,net_pay"]
        for emp_id, emp in self._employees.items():
            payroll = self.calculate_payroll(emp_id)
            lines.append(
                f"{emp_id},{emp['name']},{emp['salary']},"
                f"{emp['department']},{payroll['tax_rate']},{payroll['net_pay']}"
            )
        return "\n".join(lines)


# ── Demo usage ─────────────────────────────────────────────────
if __name__ == "__main__":
    mgr = EmployeeManager()
    mgr.add_employee("E001", "Alice Johnson", 75000, "Engineering")
    mgr.add_employee("E002", "Bob Smith", 45000, "Marketing")
    mgr.add_employee("E003", "Carol Davis", 120000, "Engineering")

    print("=== Payroll (Alice - $75k) ===")
    payroll = mgr.calculate_payroll("E001")
    print(f"  Gross: {payroll['gross_formatted']}")
    print(f"  Tax ({payroll['tax_rate']:.0%}): {payroll['tax_formatted']}")
    print(f"  Net: {payroll['net_formatted']}")
    print()

    print("=== Pay Stub (Bob - $45k) ===")
    print(mgr.generate_pay_stub("E002"))
    print()

    print("=== CSV Export ===")
    print(mgr.export_to_csv())
3

SRP — Refactor & Practice

Applying SRP: From One Class to Four

Goal: Refactor a monolithic class into multiple classes, each serving one actor.

Worked example (expand): how we'd split EmployeeManager from Step 2 into four focused classes. Open this if you want a fully-worked reference before you tackle the task below; skip it if you're ready to apply SRP directly.

Study each sub-goal label in the comments — they show the reasoning behind each separation.

# Sub-goal 1: Isolate data storage (serves all actors as a shared dependency)
class EmployeeRepository:
    """Stores and retrieves employee data. Shared by all actors."""
    def __init__(self) -> None:
        self._employees = {}

    def add(self, emp_id: str, name: str, salary: float, department: str) -> None:
        if emp_id in self._employees:
            raise ValueError(f"Employee {emp_id} already exists")
        self._employees[emp_id] = {
            "name": name, "salary": salary, "department": department
        }

    def get(self, emp_id: str) -> dict[str, object]:
        if emp_id not in self._employees:
            raise ValueError(f"Employee {emp_id} not found")
        return self._employees[emp_id]

    def all_employees(self) -> dict[str, dict[str, object]]:
        return dict(self._employees)


# Sub-goal 2: Isolate payroll logic (serves the Finance Team)
class PayrollCalculator:
    """Calculates payroll and taxes. Actor: Finance Team."""
    def calculate(self, salary: float) -> dict[str, float]:
        if salary <= 50000:
            tax_rate = 0.10
        elif salary <= 100000:
            tax_rate = 0.24
        else:
            tax_rate = 0.32
        tax = salary * tax_rate
        net_pay = salary - tax
        return {"gross": salary, "tax_rate": tax_rate, "tax": tax, "net_pay": net_pay}


# Sub-goal 3: Isolate formatting (serves the HR Team)
class PayStubFormatter:
    """Formats pay stubs for display. Actor: HR Team."""
    def format_currency(self, amount: float) -> str:
        return f"${amount:,.2f}"

    def generate(self, employee: dict[str, object], payroll: dict[str, float]) -> str:
        lines = []
        lines.append("=" * 40)
        lines.append(f"  PAY STUB - {employee['name']}")
        lines.append(f"  Department: {employee['department']}")
        lines.append("=" * 40)
        lines.append(f"  Gross Pay:  {self.format_currency(payroll['gross'])}")
        lines.append(f"  Tax Rate:   {payroll['tax_rate']:.0%}")
        lines.append(f"  Tax:        {self.format_currency(payroll['tax'])}")
        lines.append(f"  Net Pay:    {self.format_currency(payroll['net_pay'])}")
        lines.append("=" * 40)
        return "\n".join(lines)


# Sub-goal 4: Isolate export (serves the Compliance Team)
class EmployeeExporter:
    """Exports employee data to CSV. Actor: Compliance Team."""
    def export_csv(self, employees, calc) -> None:
        lines = ["emp_id,name,salary,department,tax_rate,net_pay"]
        for emp_id, emp in employees.items():
            payroll = calc.calculate(emp["salary"])
            lines.append(
                f"{emp_id},{emp['name']},{emp['salary']},"
                f"{emp['department']},{payroll['tax_rate']},{payroll['net_pay']}"
            )
        return "\n".join(lines)

Why this is better: Now Finance can change tax brackets in PayrollCalculator without touching pay stubs. HR can change currency display in PayStubFormatter without messing up tax calculations. Each class has one actor and one reason to change. No more surprise side effects.

Your Task: Refactor RideRequest

Welcome to NightOwl, a fictional rideshare startup. Your colleague just left “to tour with their band” and handed you ride_request.py. It’s a single RideRequest class doing everything: driver matching, fare calculation, rider/driver notifications, and fraud detection.

Four genuinely independent teams own pieces of this class — each with their own org chart, their own tickets, and their own reasons to ask for changes:

  1. Matching Ops — owns the driver-matching algorithm. Cares about ETA, distance, driver ratings.
  2. Pricing — owns fare calculation, surge rules, platform fee. Reports to the CFO’s office. Under audit scrutiny.
  3. Communications — owns SMS/push notification templates. Works with Legal on TCPA anti-spam compliance.
  4. Trust & Safety — owns fraud detection and route-anomaly flags. Reports to the Chief Risk Officer.

Why these really are separate actors (and not one person in four hats like the event-planner trope): each team reports up to a different executive, faces a different external regulator, and ships on its own release cadence. When Matching Ops tweaks the distance formula, that change should never touch Pricing’s surge logic — and yet in the current code, it does. That’s the SRP violation you’re about to fix.

The Shared-Helper Trap (this is the point)

Two private helpers are wired into methods belonging to different actors:

  • _distance(a, b) is called by Matching Ops (match_driver) and by Trust & Safety (flag_if_unsafe).
  • _platform_fee(subtotal) is called by Pricing (compute_fare) and by Trust & Safety (flag_if_unsafe — it flags abnormally high fees as potential fraud).

So when Pricing changes _platform_fee to a tiered model for a Black Friday promo, Trust & Safety’s fraud detector silently drifts. Nobody on Pricing reads T&S code. Nobody on T&S gets pinged about the promo. The bug ships on the biggest-revenue weekend of the year. This is what cross-actor coupling costs.

The Structural Move

Before (one class, four actors share private helpers):

RideRequest +match_driver() +compute_fare() +notify_rider() +flag_if_unsafe() -_distance() -_platform_fee()

After (one class per actor; each owns its own helpers):

RideData DriverMatcher +match() -_distance() FareCalculator +compute() -_platform_fee() RideNotifier +rider_arrival() +driver_heads_up() SafetyAuditor +audit() -_distance()

Notice what shifted: _distance() now appears in two classes (DriverMatcher and SafetyAuditor) — and that “duplication” is the point. Each actor owns its own copy and can change it without asking the other team.

Your Job

Split RideRequest into five focused classes, each owned by exactly one actor (plus a shared data record):

  • RideData — the data record (rider, pickup, dropoff, requested time). Shared as input; owned by nobody.
  • DriverMatcher — owns matching logic. Actor: Matching Ops.
  • FareCalculator — owns fare math. Actor: Pricing.
  • RideNotifier — owns notification templates. Actor: Communications.
  • SafetyAuditor — owns fraud + anomaly detection. Actor: Trust & Safety.

Each class gets its own copy of whatever helper it actually needs. DriverMatcher has its own _distance() for matching (could use euclidean or haversine — Matching Ops’ call). SafetyAuditor has its own _distance() tuned for anomaly detection (maybe haversine, maybe with a jitter tolerance — T&S’s call). They’re no longer forced to share.

We’ve filled in DriverMatcher and RideNotifier as worked examples. You complete FareCalculator, SafetyAuditor, and ensure RideData holds the right fields. Tests will verify that no cross-actor names leak into the wrong class.

Starter files
ride_request.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any


# ── Original monolithic class (for reference — do not edit) ────────
class RideRequest:
    """Monolithic class handling an entire NightOwl ride lifecycle.
    Violates SRP: four distinct actors (Matching Ops, Pricing,
    Communications, Trust & Safety) all share one class — and one
    another's helpers. Touching one actor's code routinely breaks
    another's without warning."""

    def __init__(self, rider_id: str, pickup: tuple[float, float],
                 dropoff: tuple[float, float]) -> None:
        self.rider_id = rider_id
        self.pickup = pickup
        self.dropoff = dropoff
        self.driver_id: str | None = None
        self.fare: float | None = None
        self.flags: list[str] = []

    # ── Matching Ops territory ────────────────────────────────────
    def match_driver(self, drivers: list[dict[str, Any]]) -> str:
        """Pick the nearest driver to the pickup point."""
        best = min(drivers, key=lambda d: self._distance(d["loc"], self.pickup))
        self.driver_id = best["id"]
        return best["id"]

    # ── Pricing territory ─────────────────────────────────────────
    def compute_fare(self, distance_km: float, surge: float = 1.0) -> float:
        """Base + per-km, times surge, plus platform fee."""
        base, per_km = 2.50, 1.20
        subtotal = (base + distance_km * per_km) * surge
        self.fare = round(subtotal + self._platform_fee(subtotal), 2)
        return self.fare

    # ── Communications territory ──────────────────────────────────
    def notify_rider(self, driver_name: str, eta_min: int) -> str:
        """Compose the rider-facing arrival message."""
        return (f"NightOwl: {driver_name} arrives in {eta_min} min. "
                f"Fare est: ${self.fare:.2f}")

    # ── Trust & Safety territory ──────────────────────────────────
    def flag_if_unsafe(self) -> None:
        """Fraud & anomaly detection.
        Uses _distance to detect absurdly-short rides (fake runs to
        farm promos) AND _platform_fee to detect high-fee outliers."""
        if self._distance(self.pickup, self.dropoff) < 0.2:
            self.flags.append("SUSPICIOUS_SHORT_RIDE")
        if self.fare is not None and self._platform_fee(self.fare) > 8.00:
            self.flags.append("HIGH_FEE_OUTLIER")

    # ── Private helpers — the trap ────────────────────────────────
    def _distance(self, a: tuple[float, float], b: tuple[float, float]) -> float:
        """Shared by match_driver (Matching Ops) AND flag_if_unsafe (T&S)."""
        return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5

    def _platform_fee(self, subtotal: float) -> float:
        """Shared by compute_fare (Pricing) AND flag_if_unsafe (T&S).
        If Pricing tweaks this for a promo, T&S's fraud detector
        silently drifts — and nobody notices until post-mortem."""
        return subtotal * 0.15


# ══════════════════════════════════════════════════════════════════
# REFACTORED DESIGN — each class owned by exactly one actor
# ══════════════════════════════════════════════════════════════════

@dataclass
class RideData:
    """Pure data record. Shared as *input* to actor classes;
    owned by nobody, so it has no actor's logic."""
    rider_id: str
    pickup: tuple[float, float]
    dropoff: tuple[float, float]
    driver_id: str | None = None
    fare: float | None = None
    flags: list[str] = field(default_factory=list)


# ── Worked example #1 — Matching Ops ──────────────────────────────
class DriverMatcher:
    """Picks the nearest driver for a ride. Actor: Matching Ops.
    Owns its own distance function — tuned for matching (fast,
    euclidean is fine for short distances)."""

    def match(self, ride: RideData, drivers: list[dict[str, Any]]) -> str:
        best = min(drivers, key=lambda d: self._distance(d["loc"], ride.pickup))
        ride.driver_id = best["id"]
        return best["id"]

    def _distance(self, a: tuple[float, float], b: tuple[float, float]) -> float:
        # Matching Ops: fast euclidean is fine for short city rides.
        return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5


# ── Worked example #2 — Communications ────────────────────────────
class RideNotifier:
    """Formats rider/driver messages. Actor: Communications.
    Owns template strings. Works with Legal on TCPA compliance."""

    BRAND = "NightOwl"

    def rider_arrival(self, ride: RideData, driver_name: str, eta_min: int) -> str:
        fare_str = f"${ride.fare:.2f}" if ride.fare is not None else "TBD"
        return (f"{self.BRAND}: {driver_name} arrives in {eta_min} min. "
                f"Fare est: {fare_str}")

    def driver_heads_up(self, ride: RideData, rider_name: str) -> str:
        return (f"{self.BRAND}: New ride for {rider_name}. "
                f"Pickup at {ride.pickup}.")


# ── Your turn #1 — Pricing ────────────────────────────────────────
class FareCalculator:
    """Computes ride fares. Actor: Pricing.
    Owns ITS OWN platform-fee logic — so T&S's fraud detector
    is no longer silently coupled to Pricing's experiments."""

    BASE = 2.50
    PER_KM = 1.20

    def compute(self, ride: RideData, distance_km: float, surge: float = 1.0) -> float:
        """Return the total fare and set ride.fare.
        Formula: (base + per_km * distance_km) * surge + platform_fee.
        Round to 2 decimals."""
        # TODO: compute subtotal using BASE, PER_KM, distance_km, surge
        # TODO: add self._platform_fee(subtotal)
        # TODO: round to 2 decimals, set ride.fare, and return it
        pass

    def _platform_fee(self, subtotal: float) -> float:
        """Pricing's OWN platform fee. When Pricing changes this for
        a promo, nothing else in the system silently shifts."""
        return subtotal * 0.15


# ── Your turn #2 — Trust & Safety ─────────────────────────────────
class SafetyAuditor:
    """Flags anomalies on a ride. Actor: Trust & Safety.
    Owns ITS OWN distance function AND ITS OWN fee threshold —
    so Matching Ops and Pricing can tune theirs freely."""

    SHORT_RIDE_THRESHOLD_KM = 0.2
    HIGH_FEE_ABSOLUTE_THRESHOLD = 8.00

    def audit(self, ride: RideData) -> list[str]:
        """Populate ride.flags based on anomaly rules, then return them."""
        # TODO: if self._distance(ride.pickup, ride.dropoff) is below
        #       SHORT_RIDE_THRESHOLD_KM, append "SUSPICIOUS_SHORT_RIDE".
        # TODO: if ride.fare is not None and is above
        #       HIGH_FEE_ABSOLUTE_THRESHOLD, append "HIGH_FEE_OUTLIER".
        # TODO: return ride.flags.
        pass

    def _distance(self, a: tuple[float, float], b: tuple[float, float]) -> float:
        """T&S's OWN distance function. T&S might swap to haversine
        later without asking Matching Ops for permission."""
        return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5


# ── Demo: each actor's class collaborates through RideData ─────────
if __name__ == "__main__":
    ride = RideData(
        rider_id="R-42",
        pickup=(0.0, 0.0),
        dropoff=(3.0, 4.0),  # 5km euclidean
    )

    drivers = [
        {"id": "D-1", "loc": (0.1, 0.1)},
        {"id": "D-2", "loc": (10.0, 10.0)},
    ]

    matcher = DriverMatcher()
    pricer = FareCalculator()
    notifier = RideNotifier()
    auditor = SafetyAuditor()

    matcher.match(ride, drivers)
    pricer.compute(ride, distance_km=5.0, surge=1.0)
    flags = auditor.audit(ride)

    print(f"Driver: {ride.driver_id}")
    print(f"Fare:   ${ride.fare:.2f}" if ride.fare else "Fare: (not set)")
    print(f"Flags:  {flags}")
    print(notifier.rider_arrival(ride, driver_name="Avery", eta_min=4))
4

OCP — Feel the Friction

The Open/Closed Principle — Part 1: Feel the Pain

Goal: Spot OCP violations and feel why if/elif chains turn every new feature into a regression risk.

The Scenario

Your team runs an e-commerce platform. The NotificationRouter class sends notifications to customers through different channels — email, SMS, and push. Each channel has its own formatting, rate limits, and delivery logic.

Open notification_router.py and read through the code. Notice how all three channels are stuffed into one method with an if/elif chain. It’s basically a giant switch statement pretending to be architecture.

Code Smell: Switch Statement Smell

The if/elif (or switch) chain on a string type has a name: the Switch Statement Smell. It’s the dominant symptom of an OCP violation. Every new variant forces you to reopen existing code, risk breaking working branches, and crowd the method with logic that belongs in its own class.

Predict First

Before you write a line of code, predict the answer to this question: if product asks you to add 10 more notification channels over the next six months, how long do you think send_notification() will end up? Write your guess (in lines) somewhere. We’ll check it against reality after the refactor in the next step.

Task 1: Add Slack Notifications

Product team just sent over a new request: “We need Slack notifications for enterprise customers.”

Slack notifications need:

  • Formatting: Wrap the message in Slack markdown — bold the first line as a header using *...* and prefix the body with a clipboard emoji
  • Rate limiting: Maximum 200 messages per hour
  • Delivery: Print "[SLACK] Delivering to {user}: {formatted_message}"

Add a "slack" branch to send_notification() that implements these requirements.

Task 2: Think Ahead

Now imagine product comes back and also wants Discord, Microsoft Teams, WhatsApp, Telegram, and Carrier Pigeon (okay, maybe not that last one). For each new channel you’d have to:

  1. Open notification_router.py
  2. Read through all existing branches to understand the pattern
  3. Slap another elif at the bottom
  4. Pray you didn’t accidentally break email or SMS while editing

This is the pain the Open/Closed Principle is trying to save you from. We’ll fix it properly in the next step.

Struggling is the lesson. OCP feels counter-intuitive at first — your instinct says “just add another elif, the code works, ship it.” The point of this step is to feel what happens when that instinct scales to 20 channels, so that polymorphism in Step 5 arrives as relief, not academic theory.

Reflect → Draft the PR-Review Comment

Close the code for a moment. Imagine a teammate opens a pull request that adds Discord support by pasting another elif channel == "discord": branch at the bottom of send_notification(). You’re the reviewer. Draft the one-sentence review comment you’d leave on that PR — not “OCP violation” (useless in review), but a concrete call-to-action: what should the PR actually do differently, and what specific line would you highlight as the danger zone? PR comments are where principles become habits; practicing the comment is practicing the habit.

Starter files
notification_router.py
from __future__ import annotations


class NotificationRouter:
    """Routes notifications to the appropriate channel.

    Supports email, SMS, and push notification channels,
    each with its own formatting, rate limiting, and delivery logic.
    """

    def __init__(self) -> None:
        self._send_counts = {
            "email": 0,
            "sms": 0,
            "push": 0,
        }
        self._rate_limits = {
            "email": 100,   # max 100 emails per hour
            "sms": 50,      # max 50 SMS per hour
            "push": -1,     # unlimited push notifications
        }
        self.last_sent_message = None  # for testing

    def send_notification(self, user: str, message: str, channel: str) -> str:
        """Format, rate-check, and deliver a notification.

        Args:
            user: Recipient username or identifier.
            message: The raw notification text.
            channel: One of "email", "sms", "push".

        Returns:
            str describing the result, or an error message.
        """
        if channel == "email":
            # --- Email formatting: wrap in HTML tags ---
            formatted = (
                f"<html><body>"
                f"<h1>Notification</h1>"
                f"<p>{message}</p>"
                f"</body></html>"
            )

            # --- Email rate limiting: 100/hr ---
            if self._rate_limits["email"] != -1:
                if self._send_counts["email"] >= self._rate_limits["email"]:
                    return f"Rate limit exceeded for email (max {self._rate_limits['email']}/hr)"
            self._send_counts["email"] += 1

            # --- Email delivery ---
            print(f"[EMAIL] Delivering to {user}: {formatted}")
            self.last_sent_message = formatted
            return f"Email sent to {user}"

        elif channel == "sms":
            # --- SMS formatting: truncate to 160 characters ---
            if len(message) > 160:
                formatted = message[:157] + "..."
            else:
                formatted = message

            # --- SMS rate limiting: 50/hr ---
            if self._rate_limits["sms"] != -1:
                if self._send_counts["sms"] >= self._rate_limits["sms"]:
                    return f"Rate limit exceeded for sms (max {self._rate_limits['sms']}/hr)"
            self._send_counts["sms"] += 1

            # --- SMS delivery ---
            print(f"[SMS] Delivering to {user}: {formatted}")
            self.last_sent_message = formatted
            return f"SMS sent to {user}"

        elif channel == "push":
            # --- Push formatting: add bell icon prefix ---
            formatted = f"\U0001f514 {message}"

            # --- Push rate limiting: unlimited ---
            # (no rate limit check needed)
            self._send_counts["push"] += 1

            # --- Push delivery ---
            print(f"[PUSH] Delivering to {user}: {formatted}")
            self.last_sent_message = formatted
            return f"Push notification sent to {user}"

        else:
            return f"Unknown channel: {channel}"


# --- Quick smoke test ---
if __name__ == "__main__":
    router = NotificationRouter()
    print(router.send_notification("alice", "Your order has shipped!", "email"))
    print(router.send_notification("bob", "Your package arrives today.", "sms"))
    print(router.send_notification("carol", "Flash sale — 50% off!", "push"))
    print()
    print("TODO: Add slack channel support and test it here")
5

OCP — Refactor & Practice

The Open/Closed Principle — Part 2: The Fix

Goal: Refactor an if/elif chain into a polymorphic ABC + concrete-class family — open for extension, closed for modification.

In Step 4 you felt the pain of that if/elif chain. Now let’s fix it properly using polymorphism and abstract base classes.

Check your prediction from Step 4. You predicted how long send_notification() would grow if 10 more channels were added to the if/elif design. After the refactor, NotificationRouter.send_notification() doesn’t grow at all — each channel is its own class.

The key insight: instead of one class that knows about every channel, we create a family of channel classes that each handle themselves. The router just iterates over whatever channels it has — it literally never needs to change when a new channel shows up. New channel? Just write a new class. Done.

Worked example (expand): the full refactored NotificationRouter. Open this for a complete reference with sub-goal labels; skip it if you're ready to apply the same pattern to report_exporter.py below.
from __future__ import annotations
from abc import ABC, abstractmethod

# --- Sub-goal 1: Define the contract (abstract base class) ---
# Every notification channel must implement these two methods.
# This is the "extension point" — new channels plug in here.
class NotificationChannel(ABC):
    """Abstract base class for all notification channels."""

    @property
    @abstractmethod
    def name(self) -> str:
        """Return the channel name (e.g., 'email', 'sms')."""
        ...

    @property
    @abstractmethod
    def rate_limit(self) -> int:
        """Max messages per hour. Return -1 for unlimited."""
        ...

    @abstractmethod
    def format_message(self, message: str) -> str:
        """Apply channel-specific formatting to the raw message."""
        ...

    @abstractmethod
    def deliver(self, user: str, formatted_message: str) -> None:
        """Deliver the formatted message to the user."""
        ...

# --- Sub-goal 2: Implement concrete channels ---
# Each channel is a self-contained class with its own logic.

class EmailChannel(NotificationChannel):
    @property
    def name(self) -> str:
        return "email"

    @property
    def rate_limit(self) -> int:
        return 100  # 100 emails per hour

    def format_message(self, message: str) -> str:
        return (
            f"<html><body>"
            f"<h1>Notification</h1>"
            f"<p>{message}</p>"
            f"</body></html>"
        )

    def deliver(self, user: str, formatted_message: str) -> None:
        print(f"[EMAIL] Delivering to {user}: {formatted_message}")

class SmsChannel(NotificationChannel):
    @property
    def name(self) -> str:
        return "sms"

    @property
    def rate_limit(self) -> int:
        return 50  # 50 SMS per hour

    def format_message(self, message: str) -> str:
        if len(message) > 160:
            return message[:157] + "..."
        return message

    def deliver(self, user: str, formatted_message: str) -> None:
        print(f"[SMS] Delivering to {user}: {formatted_message}")

class PushChannel(NotificationChannel):
    @property
    def name(self) -> str:
        return "push"

    @property
    def rate_limit(self) -> int:
        return -1  # unlimited

    def format_message(self, message: str) -> str:
        return f"\U0001f514 {message}"

    def deliver(self, user: str, formatted_message: str) -> None:
        print(f"[PUSH] Delivering to {user}: {formatted_message}")

# --- Sub-goal 3: Build the router that is CLOSED for modification ---
# This class never needs to change when new channels are added.
class NotificationRouter:
    def __init__(self, channels: list[NotificationChannel] | None = None) -> None:
        self._channels = {}
        self._send_counts = {}
        self.last_sent_message = None
        for ch in (channels or []):
            self.register_channel(ch)

    def register_channel(self, channel: NotificationChannel) -> None:
        self._channels[channel.name] = channel
        self._send_counts[channel.name] = 0

    def send_notification(self, user: str, message: str, channel_name: str) -> str:
        if channel_name not in self._channels:
            return f"Unknown channel: {channel_name}"

        channel = self._channels[channel_name]

        # Rate limiting — generic, works for any channel
        if channel.rate_limit != -1:
            if self._send_counts[channel_name] >= channel.rate_limit:
                return f"Rate limit exceeded for {channel_name} (max {channel.rate_limit}/hr)"
        self._send_counts[channel_name] += 1

        # Format and deliver — delegates to the channel
        formatted = channel.format_message(message)
        channel.deliver(user, formatted)
        self.last_sent_message = formatted
        return f"{channel_name.title()} notification sent to {user}"

# --- Sub-goal 4: To add Slack, write a NEW class — no existing code changes ---
class SlackChannel(NotificationChannel):
    @property
    def name(self) -> str:
        return "slack"

    @property
    def rate_limit(self) -> int:
        return 200

    def format_message(self, message: str) -> str:
        lines = message.split("\n", 1)
        header = f"*{lines[0]}*"
        body = lines[1] if len(lines) > 1 else lines[0]
        return f"{header}\n\U0001f4cb {body}"

    def deliver(self, user: str, formatted_message: str) -> None:
        print(f"[SLACK] Delivering to {user}: {formatted_message}")

# Usage — the router is CLOSED; we only write NEW code
router = NotificationRouter([
    EmailChannel(), SmsChannel(), PushChannel(), SlackChannel()
])

Notice: NotificationRouter.send_notification() has zero if/elif branches for specific channels. Want to add Discord, Teams, or WhatsApp? Write a new class and pass it to the router. The router code itself never changes. That’s the whole point.

The Structural Move

The if/elif chain doesn’t visualize as a class diagram — it’s just one bloated method. The fix does:

«abstract» ExportFormat +name +format_report(title, headers, rows) CsvFormat JsonFormat TextFormat ReportExporter -formats[] +register_format(fmt) +export(title, headers, rows, fmt_name)

ReportExporter no longer mentions any specific format. To add XmlFormat or MarkdownFormat, write a new class and register it — ReportExporter itself never reopens.

Your Turn

Open report_exporter.py — same problem, different domain. It exports reports in different formats (CSV, JSON, plain text) using a gnarly if/elif chain.

Refactor report_exporter.py to follow OCP:

  1. Create an ExportFormat abstract base class with:
    • A name abstract property (returns the format name like "csv")
    • A format_report(title, headers, rows) abstract method
  2. Create three concrete classes: CsvFormat, JsonFormat, TextFormat
  3. Refactor ReportExporter to accept a list of format objects and delegate to them — no if/elif chain
  4. Run the file to verify it works
Starter files
report_exporter.py
from __future__ import annotations


class ReportExporter:
    """Exports tabular report data in various formats.

    Currently supports CSV, JSON, and plain text export.
    Each format has its own serialization logic.
    """

    def __init__(self) -> None:
        self.last_export = None  # stores the last exported string

    def export(self, title: str, headers: list[str], rows: list[list[str]], fmt: str) -> str:
        """Export report data in the specified format.

        Args:
            title: Report title string.
            headers: List of column header strings.
            rows: List of lists, each inner list is one data row.
            fmt: One of "csv", "json", "txt".

        Returns:
            The formatted report as a string.
        """
        if fmt == "csv":
            # --- CSV: comma-separated with header row ---
            lines = [",".join(headers)]
            for row in rows:
                lines.append(",".join(str(cell) for cell in row))
            result = "\n".join(lines)
            self.last_export = result
            print(f"[CSV] Exported report: {title}")
            return result

        elif fmt == "json":
            # --- JSON: list of dictionaries ---
            import json
            records = []
            for row in rows:
                record = {}
                for i, header in enumerate(headers):
                    record[header] = row[i]
                records.append(record)
            data = {"title": title, "records": records}
            result = json.dumps(data, indent=2)
            self.last_export = result
            print(f"[JSON] Exported report: {title}")
            return result

        elif fmt == "txt":
            # --- Plain text: aligned columns with title ---
            col_widths = []
            for i, h in enumerate(headers):
                max_w = len(h)
                for row in rows:
                    max_w = max(max_w, len(str(row[i])))
                col_widths.append(max_w)

            lines = [f"=== {title} ==="]
            header_line = " | ".join(
                h.ljust(col_widths[i]) for i, h in enumerate(headers)
            )
            lines.append(header_line)
            lines.append("-" * len(header_line))
            for row in rows:
                lines.append(" | ".join(
                    str(cell).ljust(col_widths[i])
                    for i, cell in enumerate(row)
                ))
            result = "\n".join(lines)
            self.last_export = result
            print(f"[TXT] Exported report: {title}")
            return result

        else:
            return f"Unknown format: {fmt}"


# --- Quick demo ---
if __name__ == "__main__":
    exporter = ReportExporter()

    title = "Q4 Sales Report"
    headers = ["Region", "Revenue", "Units"]
    rows = [
        ["North", "$12,400", "150"],
        ["South", "$9,800", "98"],
        ["East", "$15,200", "210"],
    ]

    print(exporter.export(title, headers, rows, "csv"))
    print()
    print(exporter.export(title, headers, rows, "json"))
    print()
    print(exporter.export(title, headers, rows, "txt"))
6

LSP — The Contract You Didn't Know You Signed

The Liskov Substitution Principle — Part 1: Feel the Broken Promise

Goal: Spot LSP violations caused by strengthened preconditions or weakened postconditions — and write the contract as executable asserts.

The Principle

Barbara Liskov stated this rule in 1987, and it still hits:

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any desirable property of the program.

In plain English: inheritance means keeping your promises. A subclass must:

  • Accept AT LEAST what the parent accepts (preconditions may be equal or weaker)
  • Guarantee AT LEAST what the parent guarantees (postconditions may be equal or stronger)

Break either rule and code that trusts the parent’s contract will blow up the moment it meets the subclass.

Struggling is the lesson. LSP is the deepest SOLID principle — every senior developer has been burned by it at least once. If “strengthening vs. weakening” feels abstract, that’s normal. The exercises below make it mechanically observable.

The Happy / Sad Visual Mnemonic

Students routinely forget which of pre- and post-conditions can be weakened and which can be strengthened. Baniassad (ICSE-SEET 2018) developed a visual cue that sticks in memory: a substitutable method is happy — a smile, wider at the top and narrower at the bottom. A violating method is sad — a frown, narrow at the top and wide at the bottom.

Happy  ⌣   — substitutable (LSP-compliant)
   TOP  wide    → preconditions  WEAKER    (accept more inputs than parent)
   BOT  narrow  → postconditions STRONGER  (guarantee more than parent)

Sad    ⌢   — LSP violation
   TOP  narrow  → preconditions  STRONGER  (reject inputs parent accepts)
   BOT  wide    → postconditions WEAKER    (promise less than parent)

Keep this picture in your head. Every LSP judgment reduces to the same question: is the subclass’s contract shaped like a smile relative to the parent’s, or like a frown?

Code Smell: Refused Bequest

The most common LSP violation has a name: Refused Bequest. It looks like a subclass that inherits a method it cannot meaningfully implement, so it either throws NotImplementedError or returns a “success: false” sentinel. Both are violations — and the second is worse because it fails silently.

Sidebar (expand): LSP isn't just about classes — it bites REST APIs, message schemas, and CLI flags too.

LSP is about behavioral contracts, which show up everywhere a caller depends on a callee’s behavior — not only in class Subclass extends Parent. A few places LSP bites in the wild:

  • REST APIs. Service A’s /users/{id} returns {"name": "Ada"}. A “drop-in compatible” Service B returns {"full_name": "Ada"}. Every client breaks on deploy — same URL, same HTTP method, different behavioral contract.
  • Message schemas. A pub/sub topic promises events with a timestamp field as an ISO-8601 string. A new producer starts sending Unix epoch integers. Consumers that were “polymorphic over producers” suddenly crash or silently mis-sort events.
  • CLI flags. grep -l prints file names. A competing grep clone implements -l as “list all matches.” Pipelines written against the contract explode.

When you hear “LSP,” don’t just think inheritance. Think: “a caller trusted a contract; does the callee really honor it?”

The Payment Processing System

Open payments.py. You’ll find a working payment system:

  • PaymentProcessor (ABC) — defines two methods with documented contracts:
    • process_payment(amount)precondition: amount > 0; postcondition: returns {"success": True, "txn_id": <str>}
    • refund(txn_id)postcondition: returns {"success": True, "amount": <float>}
  • CreditCardProcessor — honors the full contract
  • DebitCardProcessor — honors the full contract
  • checkout(processors, amounts) — iterates processors polymorphically: charges all payments, stores transaction IDs, then refunds them all (a test/rollback scenario)

Run the tests now. They pass. Everything’s fine.

Predict Before You Code

Your job is to add a GiftCardProcessor that breaks the contract in two ways. Before writing a single line of code: predict — which phase of checkout() (process or refund) will fail first, and why? Write your prediction down. You’ll check it after running the code.

Task 1 — Add a Contract-Violating Subclass

Add a GiftCardProcessor class to payments.py that inherits from PaymentProcessor and:

  1. Strengthens the precondition: process_payment(amount) rejects amounts greater than the card’s balance (even though the base class accepts any positive amount). Raise a ValueError with the message "Insufficient gift card balance".
  2. Weakens the postcondition: refund(txn_id) returns {"success": False, "error": "Gift cards are non-refundable"} instead of actually refunding.

Give the constructor a balance parameter (e.g., GiftCardProcessor(balance=25.00)). Also implement process_payment() to return {"success": True, "txn_id": <some unique id>} when the amount is within balance, so that successful charges still look normal — this is exactly what makes the silent-failure path so dangerous in the real world.

Task 2 — Write the Contract as Executable Code

Open test_payments.py. You’ll find a helper function called refund_contract_holds(processor) marked as TODO. Implement it using plain assert statements so that each assertion encodes one piece of the refund() postcondition:

  1. The result must be a dict.
  2. result["success"] must be True.
  3. result["amount"] must be numeric (int or float).

Then — in the existing test test_refund_contract_for_all_processors() — call your helper with each of the three processors (after you’ve added GiftCardProcessor). The first two should pass. The gift card should fail — and because you wrote the contract as assert statements, the failure message will make the broken postcondition literally visible.

Task 3 — Break checkout()

In test_checkout_with_refund(), append GiftCardProcessor(balance=25.00) to the processors list and 50.00 to the amounts list. Run the tests and watch checkout() explode.

Was your prediction right?

Why Is {"success": False} Still a Violation?

You might be thinking: “But I’m returning a dict with the right keys — I’m just setting success to False.”

The postcondition of refund() isn’t just “return a dict.” It’s “actually refund the transaction and return the refunded amount.” Callers depend on that guarantee. A function that silently fails while wearing the same interface is more dangerous than one that crashes — because the caller has zero reason to suspect anything went wrong. Money has already moved. The customer is gone. You’ll find out next Tuesday during reconciliation.

The fix is structural — splitting the hierarchy so GiftCardProcessor never inherits a refund() method it can’t honor. That’s the next step.

Reflect → Pick a Side and Argue It

Your team is in a heated Slack debate. Senior-A argues: GiftCardProcessor.refund() returning {'success': False} is fine — it’s honest about what happened and avoids the scary exception.” Senior-B argues: “It’s worse than a crash, because crashes surface the problem. A success=False hidden in a dict gets silently ignored until Tuesday’s reconciliation report.” In one or two sentences, pick a side and cite the LSP contract language (pre/postcondition, Happy/Sad) to back your argument. Being able to argue LSP — not just recognize it — is what separates engineers who enforce contracts in review from ones who merely nod when violations slide by.

Starter files
payments.py
"""Payment processing system — LSP demonstration."""
from __future__ import annotations
from abc import ABC, abstractmethod
import uuid


class PaymentProcessor(ABC):
    """Abstract base class for all payment processors.

    Contract:
        process_payment(amount):
            Precondition:  amount > 0
            Postcondition: returns {"success": True, "txn_id": <str>}
        refund(txn_id):
            Precondition:  txn_id was returned by a prior process_payment call
            Postcondition: returns {"success": True, "amount": <float>}
    """

    @abstractmethod
    def process_payment(self, amount: float) -> dict[str, str | bool]:
        """Process a payment of the given amount.

        Args:
            amount: Any positive number.

        Returns:
            {"success": True, "txn_id": <str>}
        """
        ...

    @abstractmethod
    def refund(self, txn_id: str) -> dict[str, str | float | bool]:
        """Refund a previously processed transaction.

        Args:
            txn_id: A transaction ID from a prior process_payment call.

        Returns:
            {"success": True, "amount": <float>}
        """
        ...


class CreditCardProcessor(PaymentProcessor):
    """Processes credit card payments. Honors the full contract."""

    def __init__(self) -> None:
        self._transactions = {}

    def process_payment(self, amount: float) -> dict[str, str | bool]:
        if amount <= 0:
            raise ValueError("Amount must be positive")
        txn_id = str(uuid.uuid4())
        self._transactions[txn_id] = amount
        return {"success": True, "txn_id": txn_id}

    def refund(self, txn_id: str) -> dict[str, str | float | bool]:
        if txn_id not in self._transactions:
            raise ValueError(f"Unknown transaction: {txn_id}")
        amount = self._transactions.pop(txn_id)
        return {"success": True, "amount": amount}


class DebitCardProcessor(PaymentProcessor):
    """Processes debit card payments. Honors the full contract."""

    def __init__(self, account_balance: float = 1000.00) -> None:
        self._balance = account_balance
        self._transactions = {}

    def process_payment(self, amount: float) -> dict[str, str | bool]:
        if amount <= 0:
            raise ValueError("Amount must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds in debit account")
        self._balance -= amount
        txn_id = str(uuid.uuid4())
        self._transactions[txn_id] = amount
        return {"success": True, "txn_id": txn_id}

    def refund(self, txn_id: str) -> dict[str, str | float | bool]:
        if txn_id not in self._transactions:
            raise ValueError(f"Unknown transaction: {txn_id}")
        amount = self._transactions.pop(txn_id)
        self._balance += amount
        return {"success": True, "amount": amount}


# TODO: Add GiftCardProcessor here.
# Task 1 requirements:
#   1. Constructor takes `balance: float`.
#   2. process_payment(amount):
#        - If amount > balance, raise ValueError("Insufficient gift card balance").
#        - Otherwise, deduct from balance, store a txn_id, and return
#          {"success": True, "txn_id": <str>} — this is the part that looks normal.
#   3. refund(txn_id):
#        - Return {"success": False, "error": "Gift cards are non-refundable"}.


def checkout(processors: list[PaymentProcessor], amounts: list[float]) -> list[dict[str, str | float | bool]]:
    """Process payments and then refund them all (test/rollback scenario).

    This function depends on the PaymentProcessor contract:
    - Every processor accepts any positive amount
    - Every processor can refund a completed transaction

    Args:
        processors: list of PaymentProcessor instances
        amounts: list of amounts (same length as processors)

    Returns:
        list of refund results
    """
    if len(processors) != len(amounts):
        raise ValueError("processors and amounts must have same length")

    # Phase 1: Process all payments
    txn_ids = []
    for processor, amount in zip(processors, amounts):
        result = processor.process_payment(amount)
        assert result["success"], "process_payment must succeed"
        txn_ids.append((processor, result["txn_id"]))

    # Phase 2: Refund all payments
    refund_results = []
    for processor, txn_id in txn_ids:
        result = processor.refund(txn_id)
        assert result["success"], f"refund must succeed, got: {result}"
        refund_results.append(result)

    return refund_results
test_payments.py
"""Tests for the payment processing system."""
import pytest
from payments import (
    CreditCardProcessor,
    DebitCardProcessor,
    checkout,
)
# TODO: After you add GiftCardProcessor to payments.py, import it here:
# from payments import GiftCardProcessor


def test_credit_card_process_and_refund() -> None:
    """CreditCardProcessor honors the full contract."""
    cc = CreditCardProcessor()
    result = cc.process_payment(100.00)
    assert result["success"] is True
    assert "txn_id" in result

    refund_result = cc.refund(result["txn_id"])
    assert refund_result["success"] is True
    assert refund_result["amount"] == 100.00


def test_debit_card_process_and_refund() -> None:
    """DebitCardProcessor honors the full contract."""
    debit = DebitCardProcessor(account_balance=500.00)
    result = debit.process_payment(200.00)
    assert result["success"] is True

    refund_result = debit.refund(result["txn_id"])
    assert refund_result["success"] is True
    assert refund_result["amount"] == 200.00


# ── The Refund Contract (Task 2) ──────────────────────────────
#
# The refund() postcondition — written as executable assertions.
# Any processor that inherits PaymentProcessor must satisfy ALL
# three assertions. If not, it violates LSP.

def refund_contract_holds(processor) -> None:
    """TODO: Encode the refund() postcondition as assert statements.

    Call processor.process_payment(50.00), then call
    processor.refund(result["txn_id"]) and assert:
      1. the refund result is a dict
      2. refund_result["success"] is True
      3. refund_result["amount"] is an int or float
    """
    # TODO: Implement this helper.
    pass


def test_refund_contract_for_all_processors() -> None:
    """Task 2: verify the contract for every PaymentProcessor subclass.

    After adding GiftCardProcessor to payments.py, uncomment the
    third line below. The credit and debit cards should pass the
    contract. The gift card will FAIL — and the failure message
    from refund_contract_holds() will make the broken postcondition
    visible.
    """
    refund_contract_holds(CreditCardProcessor())
    refund_contract_holds(DebitCardProcessor(account_balance=500.00))
    # TODO: Uncomment the next line after adding GiftCardProcessor
    # refund_contract_holds(GiftCardProcessor(balance=100.00))


def test_checkout_with_refund() -> None:
    """Task 3: checkout() processes and refunds through all processors.

    After adding GiftCardProcessor, uncomment the extra lines below
    to add it to the list. Watch checkout() blow up in the refund
    phase — and compare the failure to your prediction.
    """
    processors = [
        CreditCardProcessor(),
        DebitCardProcessor(account_balance=500.00),
    ]
    amounts = [75.00, 150.00]

    # TODO: After adding GiftCardProcessor, uncomment:
    # processors.append(GiftCardProcessor(balance=25.00))
    # amounts.append(50.00)

    refund_results = checkout(processors, amounts)
    assert len(refund_results) == len(processors)
    for result in refund_results:
        assert result["success"] is True
7

LSP — Split the Hierarchy

The Liskov Substitution Principle — Part 2: The Structural Fix

Goal: Split a broken hierarchy so no subclass inherits a method it can’t honor.

In the previous step you watched GiftCardProcessor shred the refund() contract. Now the fix. The root cause isn’t that the gift card was coded poorly — it’s that the hierarchy is wrong. refund() doesn’t belong on every payment processor, only on those that can actually refund.

Worked example (expand): how we'd split PaymentProcessor by sub-goal. Open this if you want a fully-worked reference for the structural fix; skip it if you're ready to apply the same split to the media player below.
from __future__ import annotations
from abc import ABC, abstractmethod
import uuid

# --- Sub-goal 1: Define the minimum contract every processor honors ---
# Every payment processor must charge. That's it. No refund promise.
class PaymentProcessor(ABC):
    """Can process payments. No refund guarantee."""

    @property
    def max_amount(self) -> float | None:
        """Maximum accepted amount. None means no limit."""
        return None

    @abstractmethod
    def process_payment(self, amount: float) -> dict:
        ...

# --- Sub-goal 2: Extend the contract for processors that CAN refund ---
# Only processors that can honestly refund extend this class.
class RefundableProcessor(PaymentProcessor):
    """Can process payments AND refund them."""

    @abstractmethod
    def refund(self, txn_id: str) -> dict:
        ...

# --- Sub-goal 3: Cards extend RefundableProcessor — full contract ---
class CreditCardProcessor(RefundableProcessor):
    def __init__(self):
        self._transactions = {}

    def process_payment(self, amount):
        if amount <= 0:
            raise ValueError("Amount must be positive")
        txn_id = str(uuid.uuid4())
        self._transactions[txn_id] = amount
        return {"success": True, "txn_id": txn_id}

    def refund(self, txn_id):
        amount = self._transactions.pop(txn_id)
        return {"success": True, "amount": amount}

# --- Sub-goal 4: Gift cards extend ONLY PaymentProcessor — no lies ---
class GiftCardProcessor(PaymentProcessor):
    """No refund() method at all. Cannot violate what it doesn't inherit."""

    def __init__(self, balance):
        self._balance = balance
        self._transactions = {}

    @property
    def max_amount(self):
        return self._balance  # Explicit: the upper limit is visible.

    def process_payment(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient gift card balance")
        self._balance -= amount
        txn_id = str(uuid.uuid4())
        self._transactions[txn_id] = amount
        return {"success": True, "txn_id": txn_id}

# --- Sub-goal 5: Boundaries catch misuse at the signature level ---
def checkout_with_refund(
    processors: list[RefundableProcessor],  # <-- not PaymentProcessor!
    amounts: list[float],
) -> list[dict]:
    # Every processor here extends RefundableProcessor, so refund()
    # is guaranteed to exist and honor the contract.
    ...

The structural insight: GiftCardProcessor used to lie about refunds. After the split, it literally cannot be passed to a function that needs refunds — a type checker or a simple isinstance() check catches the misuse at the boundary, not deep inside business logic after money has already moved.

LSP ↔ ISP bridge: splitting one fat interface into two smaller ones is exactly the Interface Segregation Principle (ISP) — which you’ll see head-on in the next step.

The Structural Move

Before (one fat hierarchy; LiveStream lies about seek() and get_duration()):

«abstract» MediaPlayer +play() +stop() +seek(position) +get_duration() AudioFilePlayer VideoFilePlayer LiveStreamPlayer +seek() : raises NotImplementedError +get_duration() : raises NotImplementedError

After (split: LiveStream doesn’t inherit what it can’t honor):

«abstract» MediaPlayer +play() +stop() «abstract» SeekablePlayer +seek(position) +get_duration() AudioFilePlayer VideoFilePlayer LiveStreamPlayer

A function that needs seeking takes list[SeekablePlayer]. LiveStreamPlayer literally cannot be passed — the type system catches the mismatch at the call site, not at runtime after the player has already started.

Your Turn — The Media Player Hierarchy

Same structural move, new domain. Open media_player.py.

A MediaPlayer ABC declares play(), stop(), seek(position), and get_duration(). AudioFilePlayer and VideoFilePlayer work fine. But LiveStreamPlayer inherits all four methods and raises NotImplementedError on seek() and get_duration() — classic Refused Bequest.

The playlist_renderer(players) function iterates every player and renders a progress bar using get_duration() and seek(). It crashes on live streams.

Your tasks:

  1. Split MediaPlayer into two classes:
    • MediaPlayer (base) — abstract methods: play() and stop() only.
    • SeekablePlayer(MediaPlayer) — adds abstract seek(position) and get_duration().
  2. Move AudioFilePlayer and VideoFilePlayer to extend SeekablePlayer.
  3. Change LiveStreamPlayer to extend MediaPlayer only — and remove seek() and get_duration() entirely. Not even stubs that raise. The whole point is that a live stream cannot be asked for these.
  4. Update playlist_renderer(players) to use isinstance(player, SeekablePlayer) to decide whether a progress bar is appropriate. Live streams get an entry with has_progress_bar=False and duration=None.

The tests in test_media_player.py check each of these — including a test that confirms LiveStreamPlayer is not an instance of SeekablePlayer.

Starter files
media_player.py
"""Media player hierarchy — LSP exercise.

BUG: LiveStreamPlayer inherits seek() and get_duration() from
MediaPlayer but cannot implement them. The playlist_renderer()
function crashes when it encounters a live stream.

YOUR TASK: Split MediaPlayer into (MediaPlayer + SeekablePlayer)
so LiveStreamPlayer does not pretend to support seeking or duration.
The renderer should handle all player types safely.
"""
from abc import ABC, abstractmethod


class MediaPlayer(ABC):
    """Base class for all media players."""

    def __init__(self, title: str) -> None:
        self._title = title
        self._playing = False

    @property
    def title(self) -> str:
        return self._title

    @abstractmethod
    def play(self) -> None:
        ...

    @abstractmethod
    def stop(self) -> None:
        ...

    # TODO: Move these two methods to a new SeekablePlayer subclass.
    @abstractmethod
    def seek(self, position: int) -> None:
        """Seek to a position in seconds."""
        ...

    @abstractmethod
    def get_duration(self) -> int:
        """Return total duration in seconds."""
        ...


# TODO: Create a SeekablePlayer(MediaPlayer) class here that declares
# abstract seek() and get_duration() methods. AudioFilePlayer and
# VideoFilePlayer should then extend SeekablePlayer, not MediaPlayer.


class AudioFilePlayer(MediaPlayer):
    def __init__(self, title: str, duration: int) -> None:
        super().__init__(title)
        self._duration = duration

    def play(self) -> None:
        self._playing = True

    def stop(self) -> None:
        self._playing = False

    def seek(self, position: int) -> None:
        if position < 0 or position > self._duration:
            raise ValueError("Position out of range")

    def get_duration(self) -> int:
        return self._duration


class VideoFilePlayer(MediaPlayer):
    def __init__(self, title: str, duration: int, resolution: str = "1080p") -> None:
        super().__init__(title)
        self._duration = duration
        self._resolution = resolution

    def play(self) -> None:
        self._playing = True

    def stop(self) -> None:
        self._playing = False

    def seek(self, position: int) -> None:
        if position < 0 or position > self._duration:
            raise ValueError("Position out of range")

    def get_duration(self) -> int:
        return self._duration


class LiveStreamPlayer(MediaPlayer):
    """Live streams have no duration and cannot be seeked."""

    def __init__(self, title: str, stream_url: str = "https://live.example.com") -> None:
        super().__init__(title)
        self._stream_url = stream_url

    def play(self) -> None:
        self._playing = True

    def stop(self) -> None:
        self._playing = False

    # TODO: REMOVE these two methods entirely (not even a stub that raises).
    # LiveStreamPlayer should only extend MediaPlayer (after the split),
    # so it will not inherit seek() or get_duration() at all.
    def seek(self, position: int) -> None:
        raise NotImplementedError("Cannot seek a live stream")

    def get_duration(self) -> int:
        raise NotImplementedError("Live streams have no duration")


def playlist_renderer(players) -> list[dict]:
    """Render a playlist with progress bars for seekable players.

    TODO: Use isinstance(player, SeekablePlayer) to decide whether
    a progress bar is appropriate. Non-seekable players should get
    an entry with has_progress_bar=False and duration=None.
    """
    entries: list[dict] = []
    for player in players:
        duration = player.get_duration()   # <-- crashes on live streams
        midpoint = duration / 2
        player.seek(midpoint)
        entries.append({
            "title": player.title,
            "duration": duration,
            "seeked_to": midpoint,
            "has_progress_bar": True,
        })
    return entries
test_media_player.py
"""Tests for the media player hierarchy — post-fix."""
import pytest
from media_player import (
    AudioFilePlayer,
    VideoFilePlayer,
    LiveStreamPlayer,
    MediaPlayer,
    playlist_renderer,
)
# TODO: After you add SeekablePlayer, import it here:
# from media_player import SeekablePlayer


def test_audio_player_supports_seek() -> None:
    player = AudioFilePlayer("Song.mp3", duration=240)
    player.play()
    player.seek(120)
    assert player.get_duration() == 240
    player.stop()


def test_video_player_supports_seek() -> None:
    player = VideoFilePlayer("Movie.mp4", duration=7200)
    player.play()
    player.seek(3600)
    assert player.get_duration() == 7200
    player.stop()


def test_live_stream_plays_and_stops() -> None:
    """Live streams only need play/stop."""
    player = LiveStreamPlayer("CNN Live")
    assert isinstance(player, MediaPlayer)
    player.play()
    player.stop()


def test_live_stream_is_not_seekable() -> None:
    """After the fix, LiveStreamPlayer must NOT be a SeekablePlayer."""
    # TODO: Uncomment after adding SeekablePlayer:
    # player = LiveStreamPlayer("CNN Live")
    # assert not isinstance(player, SeekablePlayer), (
    #     "LiveStreamPlayer must not inherit from SeekablePlayer — "
    #     "it can't honor the seek contract."
    # )
    pass


def test_playlist_renders_all_types() -> None:
    """Renderer handles seekable and non-seekable players safely."""
    players = [
        AudioFilePlayer("Song.mp3", duration=240),
        VideoFilePlayer("Movie.mp4", duration=7200),
        LiveStreamPlayer("CNN Live"),
    ]
    entries = playlist_renderer(players)
    assert len(entries) == 3

    # Seekable players get progress bars
    assert entries[0]["has_progress_bar"] is True
    assert entries[0]["duration"] == 240

    assert entries[1]["has_progress_bar"] is True
    assert entries[1]["duration"] == 7200

    # Live stream: no progress bar, no duration
    assert entries[2]["has_progress_bar"] is False
    assert entries[2]["duration"] is None
8

ISP — Lean Interfaces

The Interface Segregation Principle

Goal: Split a fat ABC into role-based ABCs so each client only depends on what it actually calls.

The Principle

“Clients should not be forced to depend on methods they do not use.” — Robert C. Martin

When an interface gets too bloated, implementers that only need a few of its methods are forced to write stub implementations — methods that do nothing or raise errors. This wastes effort, confuses anyone reading the code, and creates latent bugs: code that looks fine but crashes at runtime.

Code Smell: Fat Interface

A bloated interface with too many unrelated methods is called a Fat Interface. Close cousin: the Refused Bequest smell you met in the LSP step — when a fat interface forces a subclass to stub methods it cannot honor, you get LSP violations for free. ISP and LSP work together: segregating interfaces eliminates whole categories of LSP bugs before they can happen.

Bridge from LSP

Remember LSP from the previous step? ISP prevents LSP violations at the structural level. If FreeStorage never inherits a sync() method, it can never break the sync contract. Problem eliminated before it starts.

SRP vs. ISP — Don’t Conflate Them

Students routinely confuse SRP and ISP. They’re related but ask different questions:

  • SRP asks: “Who requests changes to this module?” — optimize for actor-aligned cohesion. A class’s internal methods should all serve the same actor.
  • ISP asks: “Who depends on this module, and does each dependent use all of it?” — optimize for client-minimal interfaces. An exposed interface shouldn’t force a client to carry methods it never calls.

A class can obey SRP (one actor owns all its methods) while still exposing a fat interface that violates ISP (its many clients each use different subsets). Both principles apply; they operate on different dimensions.

Sidebar (expand): does ISP matter in Python?

In Java or C++, an ISP violation causes recompilation cascades: change a method signature in a fat interface and every unrelated client recompiles and re-deploys. In Python, the recompile pain disappears — there’s no compile step to cascade.

But the architectural pain remains: when your module depends on a fat interface, you transitively import every dependency that interface touches. A heavy SDK used by admin_only_method() is now a mandatory dependency for your lightweight read-only client. If that SDK has a bug, your read-only client catches it. ISP still matters in Python — the symptoms just shift from “recompile time” to “import surface and transitive risk.”

The Scenario — Fat-Interface Cloud Storage

You’re working on a cloud storage service with two tiers: Premium and Free. The current CloudStorage abstract base class has six capabilities:

Method Premium Free
upload(file) Yes Yes
download(file_id) Yes Yes
sync(device) Yes No
share(file_id, user) Yes No
encrypt(file_id) Yes No
get_version_history(file_id) Yes No

PremiumStorage implements all six. FreeStorage can only honestly implement two — but because CloudStorage is a fat ABC, FreeStorage is forced to provide stubs for sync(), share(), encrypt(), and get_version_history() just so Python will let it instantiate. Those stubs raise NotImplementedError("Upgrade to Premium").

Task 1 (Observation, not Writing) — Watch the Fat Interface Crash

Open cloud_storage.py. The FreeStorage class is already written — including the four NotImplementedError stubs. Do not rewrite them. Writing stubs like this is exactly the anti-pattern ISP exists to eliminate, and practicing the anti-pattern is the last thing we want.

Instead, do three things and then answer a question in the quiz:

  1. Run cloud_storage.py. Watch the demo section. The last thing it prints is a line where backup_service(free) calls sync() on a FreeStorage and crashes with a NotImplementedError.
  2. Read the backup_service() function at the bottom. Its type signature is storage: CloudStorage. Python’s type system says this is a valid call. The runtime disagrees. The gap between those two is an ISP violation made visible.
  3. Diagnose — in your own words — what went wrong. Not “ISP violation” — the concrete mechanism: a function expected method X on an abstraction, but the abstraction promised more than one of its implementations could honor.

Task 2 (Active Refactor) — Segregate the Smart Home

Open smart_home.py. This file has the same shape as cloud_storage.py: a fat SmartDevice ABC with four abstract methods (play_music, set_temperature, lock_door, dim_lights) and a SmartLightBad that is forced to stub three of them.

Your job is to eliminate the stubs by splitting the one fat interface into four role-based ones:

  1. Define Dimmable(ABC), Playable(ABC), TemperatureControllable(ABC), Lockable(ABC) — each with exactly one @abstractmethod and a __init__(self, name: str).
  2. Implement SmartLight(Dimmable), SmartSpeaker(Playable), SmartThermostat(TemperatureControllable), SmartLock(Lockable) — each class extends exactly one role interface and implements only its one method.
  3. Do not retain SmartDevice, SmartLightBad, or any raise NotImplementedError stubs in your final smart_home.py besides the pre-existing reference copies of SmartDevice/SmartLightBad at the top.

Tests confirm two things: (a) each device implements its own role interface (isinstance(light, Dimmable) is True) and (b) each device does not implement the role interfaces it doesn’t need (isinstance(light, Playable) is False). The second half is what prevents the fat-interface disease from coming back.

What ISP Would Look Like

Compare the fat interface approach with a segregated design:

Fat interface (current — problematic). FreeStorage is forced to implement methods it cannot support:

CloudStorage +upload() +download() +sync() +share() +encrypt() +get_version_history() FreeStorage +upload() +download() PremiumStorage +upload() +download() +sync() +share() +encrypt() +get_version_history()

Segregated interfaces (ISP-compliant). Each capability is its own interface; classes only implement what they can actually support:

«interface» Uploadable +upload() «interface» Downloadable +download() «interface» Syncable +sync() «interface» Shareable +share() «interface» Encryptable +encrypt() «interface» Versionable +get_version_history() FreeStorage PremiumStorage

With segregated interfaces, FreeStorage never inherits sync(), so backup_service() would require Syncable — and the type system (or a simple isinstance check) would catch the mismatch before runtime.

Key Insight: ISP is NOT SRP

The #1 ISP misconception is conflating it with SRP. They look similar, but they target different problems:

  • SRP = what a class does internally (one reason to change, one actor).
  • ISP = what a class exposes to its clients (no client should depend on methods it does not use).
Discrimination drill (expand): which principle does each sketch violate? Try this if you want extra practice telling SRP and ISP apart before moving on.

Study these two code sketches. Both look “wrong” at first glance, but they violate different principles. Think about each one before reading the diagnoses.

Class A:

class CustomerRecord:
    # Used by Sales, Finance, and Support teams
    def update_loyalty_points(self, points: int): ...        # Sales
    def calculate_lifetime_value(self): ...                   # Finance
    def generate_billing_invoice(self): ...                   # Finance
    def record_support_ticket(self, ticket: dict): ...        # Support
    def get_support_history(self): ...                        # Support

Every method here is actually used — nobody is forced to implement stubs. But every team’s concerns live in the same class, so a change requested by Support (e.g., new ticket fields) touches the same class that holds Finance’s billing logic.

Class B:

class MediaDevice(ABC):
    @abstractmethod
    def play_audio(self, track: str): ...
    @abstractmethod
    def play_video(self, file: str): ...
    @abstractmethod
    def record_audio(self, duration: int): ...
    @abstractmethod
    def record_video(self, duration: int): ...

class MicrophoneSpeaker(MediaDevice):
    def play_audio(self, track): ...       # uses it
    def record_audio(self, duration): ...  # uses it
    def play_video(self, file):
        raise NotImplementedError
    def record_video(self, duration):
        raise NotImplementedError

This class serves a single actor (the audio-device-using feature), but it’s forced to implement video methods it can’t honor. Stubs. Latent crashes.

Diagnoses:

  • Class A violates SRP — multiple actors (Sales, Finance, Support) drive changes to the same class. No ISP violation — every client uses what it depends on. The fix is to split by actor into LoyaltyTracker, BillingCalculator, and SupportTicketLog.
  • Class B violates ISP — a single client is forced to depend on methods it can’t implement. The interface is too fat. The fix is to split the interface into AudioPlayable, AudioRecordable, VideoPlayable, VideoRecordable, and have each device implement only what it can honor.

The rule of thumb:

  • If the class has multiple reasons to change (multiple actors) → SRP violation.
  • If an implementer is forced to stub or NotImplementedError a method it doesn’t need → ISP violation.
  • A single class can violate both. Fixing ISP often exposes an SRP violation underneath, and vice versa.
Starter files
cloud_storage.py
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Dict, List, Optional


# ── Fat interface — all storage capabilities in one ABC ──────────
class CloudStorage(ABC):
    """Abstract base class for cloud storage services.
    Defines ALL storage capabilities in a single interface."""

    @abstractmethod
    def upload(self, file: str) -> bool:
        """Upload a file to cloud storage. Returns True on success."""
        ...

    @abstractmethod
    def download(self, file_id: str) -> str:
        """Download a file by ID. Returns the file name."""
        ...

    @abstractmethod
    def sync(self, device: str) -> bool:
        """Sync files to a device. Returns True on success."""
        ...

    @abstractmethod
    def share(self, file_id: str, user: str) -> str:
        """Share a file with another user. Returns a share link."""
        ...

    @abstractmethod
    def encrypt(self, file_id: str) -> bool:
        """Encrypt a file. Returns True on success."""
        ...

    @abstractmethod
    def get_version_history(self, file_id: str) -> List[str]:
        """Get version history for a file. Returns list of version IDs."""
        ...


# ── Premium tier — implements everything ─────────────────────────
class PremiumStorage(CloudStorage):
    """Premium cloud storage with all features."""

    def __init__(self) -> None:
        self._files: Dict[str, str] = {}
        self._next_id: int = 1
        self._shared: Dict[str, str] = {}
        self._synced_devices: List[str] = []
        self._encrypted: List[str] = []
        self._versions: Dict[str, List[str]] = {}

    def upload(self, file: str) -> bool:
        file_id: str = f"file_{self._next_id}"
        self._next_id += 1
        self._files[file_id] = file
        self._versions[file_id] = ["v1"]
        print(f"[PREMIUM] Uploaded '{file}' as {file_id}")
        return True

    def download(self, file_id: str) -> str:
        if file_id not in self._files:
            raise ValueError(f"File {file_id} not found")
        print(f"[PREMIUM] Downloaded {file_id}")
        return self._files[file_id]

    def sync(self, device: str) -> bool:
        self._synced_devices.append(device)
        print(f"[PREMIUM] Synced to {device}")
        return True

    def share(self, file_id: str, user: str) -> str:
        if file_id not in self._files:
            raise ValueError(f"File {file_id} not found")
        link: str = f"https://cloud.example.com/share/{file_id}?user={user}"
        self._shared[file_id] = user
        print(f"[PREMIUM] Shared {file_id} with {user}: {link}")
        return link

    def encrypt(self, file_id: str) -> bool:
        if file_id not in self._files:
            raise ValueError(f"File {file_id} not found")
        self._encrypted.append(file_id)
        print(f"[PREMIUM] Encrypted {file_id}")
        return True

    def get_version_history(self, file_id: str) -> List[str]:
        if file_id not in self._versions:
            raise ValueError(f"File {file_id} not found")
        return list(self._versions[file_id])


# ── Free tier — PRE-WRITTEN (anti-pattern shown, not asked for) ──
#
# READ THIS class — DO NOT modify it. Four of these methods are
# stubs that raise NotImplementedError. That is exactly the ISP
# violation this step exists to make visible. Writing stubs like
# this is how you install the anti-pattern into your muscle memory.
# Your task is to OBSERVE the crash in the demo below, then do
# the ACTIVE refactor over in smart_home.py.
class FreeStorage(CloudStorage):
    """Free cloud storage — upload and download only.
    sync/share/encrypt/version are STUBS because the fat ABC
    forces every subclass to implement every method."""

    def __init__(self) -> None:
        self._files: Dict[str, str] = {}
        self._next_id: int = 1

    def upload(self, file: str) -> bool:
        file_id: str = f"file_{self._next_id}"
        self._next_id += 1
        self._files[file_id] = file
        print(f"[FREE] Uploaded '{file}' as {file_id}")
        return True

    def download(self, file_id: str) -> str:
        if file_id not in self._files:
            raise ValueError(f"File {file_id} not found")
        print(f"[FREE] Downloaded {file_id}")
        return self._files[file_id]

    # Forced stubs — the fat ABC demanded these. Each one is a
    # latent crash waiting for any client that trusts the ABC.
    def sync(self, device: str) -> bool:
        raise NotImplementedError("Upgrade to Premium")

    def share(self, file_id: str, user: str) -> str:
        raise NotImplementedError("Upgrade to Premium")

    def encrypt(self, file_id: str) -> bool:
        raise NotImplementedError("Upgrade to Premium")

    def get_version_history(self, file_id: str) -> List[str]:
        raise NotImplementedError("Upgrade to Premium")


# ── A function that depends on the fat interface ──────────────────
def backup_service(storage: CloudStorage) -> bool:
    """Backs up files by syncing to a backup device.
    Expects ANY CloudStorage — but crashes for FreeStorage!"""
    print("[BACKUP] Starting backup...")
    storage.upload("backup_manifest.txt")
    result: bool = storage.sync("backup-server-01")
    print(f"[BACKUP] Sync result: {result}")
    return result


# ── Demo ──────────────────────────────────────────────────────────
if __name__ == "__main__":
    print("=== Premium Storage ===")
    premium: PremiumStorage = PremiumStorage()
    premium.upload("report.pdf")
    premium.sync("laptop")
    premium.share("file_1", "colleague@example.com")
    premium.encrypt("file_1")
    print(f"Version history: {premium.get_version_history('file_1')}")
    print()

    print("=== Free Storage ===")
    free: FreeStorage = FreeStorage()
    free.upload("notes.txt")
    free.download("file_1")
    print()

    print("=== Backup Service (Premium — works) ===")
    backup_service(premium)
    print()

    print("=== Backup Service (Free — crashes!) ===")
    try:
        backup_service(free)
    except NotImplementedError as e:
        print(f"CRASH: {e}")
smart_home.py
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional


# ── Fat interface — all smart home capabilities in one ABC ──────
class SmartDevice(ABC):
    """Abstract base class for ALL smart home devices.
    Every device must implement every method — even if it makes no sense."""

    def __init__(self, name: str) -> None:
        self.name: str = name

    @abstractmethod
    def play_music(self, song: str) -> str:
        """Play a song on the device."""
        ...

    @abstractmethod
    def set_temperature(self, degrees: float) -> float:
        """Set the thermostat temperature. Returns the new setting."""
        ...

    @abstractmethod
    def lock_door(self) -> bool:
        """Lock the door. Returns True if locked."""
        ...

    @abstractmethod
    def dim_lights(self, brightness: int) -> int:
        """Set light brightness (0-100). Returns the new level."""
        ...


# ── A device that is forced to implement everything ─────────────
class SmartLightBad(SmartDevice):
    """A smart light bulb — but it is forced to 'play music',
    'set temperature', and 'lock doors' because of the fat interface."""

    def __init__(self, name: str) -> None:
        super().__init__(name)
        self._brightness: int = 100

    def play_music(self, song: str) -> str:
        raise NotImplementedError("A light bulb cannot play music!")

    def set_temperature(self, degrees: float) -> float:
        raise NotImplementedError("A light bulb has no thermostat!")

    def lock_door(self) -> bool:
        raise NotImplementedError("A light bulb cannot lock doors!")

    def dim_lights(self, brightness: int) -> int:
        self._brightness = max(0, min(100, brightness))
        print(f"[{self.name}] Brightness set to {self._brightness}%")
        return self._brightness


# ── TODO: Segregate into role interfaces ─────────────────────────
#
# Replace the fat SmartDevice ABC with four lean role interfaces:
#
# 1. Dimmable(ABC)              — abstractmethod: dim_lights()
# 2. Playable(ABC)              — abstractmethod: play_music()
# 3. TemperatureControllable(ABC) — abstractmethod: set_temperature()
# 4. Lockable(ABC)              — abstractmethod: lock_door()
#
# Then implement:
#   SmartLight(Dimmable)         — only lighting
#   SmartSpeaker(Playable)       — only music
#   SmartThermostat(TemperatureControllable) — only temperature
#   SmartLock(Lockable)          — only locking
#
# Each device should accept a name: str in __init__.


# ── Demo ────────────────────────────────────────────────────────
if __name__ == "__main__":
    print("=== SmartLightBad (fat interface) ===")
    bulb_bad: SmartLightBad = SmartLightBad("Living Room Light")
    bulb_bad.dim_lights(50)
    try:
        bulb_bad.play_music("Bohemian Rhapsody")
    except NotImplementedError as e:
        print(f"ERROR: {e}")
9

DIP — Own Your Abstractions

The Dependency Inversion Principle

Goal: Tell DIP apart from Dependency Injection, design abstractions owned by the caller (not the library), and unit-test without hitting external services.

The Principle

“High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.” — Robert C. Martin

DIP is NOT Dependency Injection

These get mixed up constantly, and the confusion is the #1 DIP misconception:

  • DIP (Dependency Inversion Principle) = an architectural decision to depend on abstractions instead of concrete implementations. The high-level module owns the interface — it describes what it needs, not what the low-level module happens to provide.
  • DI (Dependency Injection) = a mechanism for delivering dependencies (via constructor, setter, or container). DI is one way to implement DIP.

You can have DI without DIP (inject a concrete PostgresDatabase) and you can have DIP without DI (use a factory or service locator). Passing a concrete class through a constructor is not DIP — it’s just late binding.

Who Owns the Abstraction?

This is the part that trips people up. “Depend on abstractions” doesn’t mean “wrap every class in a generic interface.” It means the high-level policy defines the abstraction in terms of its own needs, and the low-level detail bends to fit.

Example: OrderService needs to save orders. It does not need to know whether the store is SQL, NoSQL, or an in-memory dict. So the abstraction OrderRepository — owned by the order service’s module — has a method like save_order(order_id, items, total), shaped by what an order needs, not by what Postgres provides. Then PostgresOrderRepository implements that interface. If the interface started looking like execute_sql(query), you’d know the low-level details were leaking up.

“Who Owns the Abstraction?” — A 60-Second Check

Before you write any code, read these three candidate WeatherProvider interfaces. Only one is actually inverted — the other two smuggle low-level details up into the high-level module under the disguise of an interface.

# Candidate A — mirrors the OpenWeatherMap HTTP endpoints
class WeatherProvider(ABC):
    def http_get_current(self, city: str, api_key: str) -> dict: ...
    def http_get_forecast(self, city: str, api_key: str, units: str) -> dict: ...

# Candidate B — mirrors a generic REST client
class WeatherProvider(ABC):
    def request(self, method: str, path: str, params: dict) -> dict: ...

# Candidate C — shaped by what the dashboard actually needs
class WeatherProvider(ABC):
    def current_temp_celsius(self, city: str) -> float: ...
    def forecast_celsius(self, city: str, days: int) -> list[float]: ...

Only Candidate C is inverted. A and B are indirection without inversion — the interface speaks HTTP, not weather. Every time OpenWeatherMap changes its URL structure, A and B change with it, and every caller breaks. C doesn’t care what provider you swap in; the dashboard only asked about temperatures. This is what “the high-level module owns the abstraction” means.

Apply DIP to Volatile Dependencies, Not Everything

Not every concrete type needs an abstraction. int, str, datetime, list are stable — they don’t change often, and depending on them is completely fine. DIP targets volatile dependencies — the ones that:

  • Reach the network (APIs, databases, message queues, file systems)
  • Change frequently (3rd-party libraries on active releases, business rules you own)
  • Block testing (slow, flaky, expensive, or stateful)

Wrapping every class in an ABC is the DIP equivalent of OCP’s “speculative extensibility” trap — it adds indirection without buying you anything. The right question is always: “If this collaborator had to be swapped tomorrow — for a test, a migration, or a cost cut — would I want it behind an abstraction?” If yes, apply DIP. If no (datetime.now(), math.sqrt), don’t.

Advanced (expand): the object-creation problem and the Abstract Factory pattern. Open this when you start building larger DIP-compliant systems and need to know where the concrete instantiation should live.

There’s one stubborn corner of DIP: creating objects inherently requires mentioning a concrete class somewhere. If OrderService never writes PostgresDatabase(), someone has to. The standard fix is the Abstract Factory pattern — push the concrete instantiation to a single, isolated module (often the app’s main or a wiring.py), and let the rest of the codebase receive dependencies through abstractions only.

Concretely: your WeatherDashboard never writes OpenWeatherMapClient(). A main() function (or a test fixture) builds the concrete provider and hands it in. The dashboard speaks only in abstractions; the factory is the one place that knows which concrete class exists today. If you later switch from OpenWeatherMap to a weather API from Google, only the factory changes. The rest of the system is already inverted.

Two DIP maxims that drop out of this:

  1. The place that creates concrete objects should be the outermost shell of your program, not its core.
  2. DIP violations cannot be eliminated entirely — but they can be herded into one small concrete corner, which is where factories, dependency-injection containers, and composition roots earn their keep.

Struggling is the lesson. DIP is the principle that most unlocks testing — and the one most commonly confused with Dependency Injection. If “who owns the abstraction” still feels slippery, that’s normal. The Task 2 refactor will crystallize it in code.

The Scenario — Feel the Friction

Open order_service.py. It has an OrderService class that directly instantiates three external dependencies in its constructor:

  • PostgresDatabase() — stores order data (simulated with print())
  • StripePaymentGateway() — charges credit cards (simulated)
  • SendGridEmailer() — sends confirmation emails (simulated)

Task 1 — Try to Unit-Test It

At the bottom of order_service.py there’s a stub function test_order_total_calculation(). Implement it so it:

  1. Creates an OrderService instance.
  2. Calls place_order() with two items (prices $29.99 and $49.99).
  3. Asserts the returned total equals $79.98.

Run the file. The assertion passes — but look at all the [POSTGRES], [STRIPE], [SENDGRID] print spam. Your “unit test” just pretended to charge a credit card, wrote to a database, and sent an email. That’s not a unit test — that’s an integration test wearing a disguise. In production, this test would hit real services, rack up real API costs, and be slow and flaky.

The root cause: OrderService is hardwired to concrete classes. You cannot swap them out. That’s the DIP violation.

Code Smell: Hardcoded Dependency

This specific violation has a name: Hardcoded Dependency (sometimes called New-keyword coupling in Java, or tight coupling). The tell: a high-level class’s __init__ contains self.db = PostgresDatabase() or similar direct instantiation of concrete collaborators. When you see this pattern, ask: “Can I unit-test this class without the real database?” If no, you’ve found a DIP violation.

The Structural Move — Watch the Arrows Reverse

Before (high-level points down to concrete; low-level libraries are baked in):

OrderService PostgresDatabase StripePaymentGateway SendGridEmailer

After (high-level depends on abstractions; low-level implements them — arrows now meet in the middle):

OrderService «abstract» OrderRepository «abstract» PaymentGateway «abstract» OrderNotifier PostgresDatabase StripePaymentGateway SendGridEmailer FakeRepository FakeGateway FakeNotifier

The inversion lives in the arrows. Before the refactor, every dependency arrow points from OrderService down to a concrete class. After the refactor, OrderService arrows point down to abstractions, and concrete classes arrow up to those same abstractions. The two halves of the system depend on a shared contract in the middle, not on each other directly. That’s why fakes drop in for free and unit tests stop hitting Stripe.

Task 2 — Design the Abstractions Yourself

Now the real work. You will apply DIP to weather_dashboard.py. Open it.

You’ll find only the concrete low-level classes (OpenWeatherMapClient, MongoDbCache) and the current high-level WeatherDashboard that instantiates them directly.

You need to design the abstractions. No ABCs are provided. No fakes are provided. Your job is to decide — from the perspective of WeatherDashboard — what interfaces it needs.

Start by asking: “What does WeatherDashboard actually need from its dependencies?” Not “what does OpenWeatherMapClient offer?” The direction matters.

Concretely, do the following in order:

  1. Design WeatherProvider (ABC) — an abstraction owned by the dashboard module. Think about what the dashboard needs to ask about weather. Look at the concrete calls inside WeatherDashboard.get_weather() and get_forecast() — the abstraction should have exactly the methods those calls need, no more.
  2. Design WeatherCache (ABC) — an abstraction for caching. Again, shaped by the dashboard’s needs: get a key, set a key with a TTL. Don’t copy MongoDB’s internal API; describe what the dashboard needs.
  3. Make OpenWeatherMapClient extend WeatherProvider and MongoDbCache extend WeatherCache. They already have the right methods if you designed the ABCs based on the dashboard’s calls.
  4. Create WeatherDashboardDIP — a class that accepts WeatherProvider and WeatherCache via its constructor (DI), and implements the same caching logic as WeatherDashboard but only through the abstractions.
  5. Write FakeWeatherProvider and FakeWeatherCache — in-memory implementations for testing. FakeWeatherProvider should count how many times get_current() was called.
  6. Write test_dashboard_uses_cache() — using the fakes, verify that calling dashboard.get_weather("Seattle") twice hits the provider only once (cache hit on the second call). No network, no database.

Why This Is the Real Lesson

A common mistake: students extract WeatherProvider with the same methods as OpenWeatherMapClient (matching the concrete class’s API exactly). That’s indirection without inversion — the “abstraction” is just a copy of the library’s interface. If OpenWeatherMap changes its API, your abstraction changes with it, and every caller breaks.

The right move: design WeatherProvider from the caller’s perspective. What does the dashboard need to ask? That’s the interface. If a future provider has extra features, great — the dashboard doesn’t care. If a future provider is missing features the dashboard needed, the dashboard’s abstraction is the spec, and that provider has to adapt.

This is what it means for the high-level module to own the abstraction.

Starter files
order_service.py
from __future__ import annotations
from typing import Dict, List, Optional


# ── Low-level modules (external services) ────────────────────────
class PostgresDatabase:
    """Simulates a PostgreSQL database connection."""

    def __init__(self) -> None:
        print("[POSTGRES] Connecting to database...")
        self._orders: Dict[str, Dict] = {}

    def save_order(self, order_id: str, items: List[str], total: float) -> None:
        print(f"[POSTGRES] INSERT INTO orders VALUES ('{order_id}', {len(items)} items, ${total:.2f})")
        self._orders[order_id] = {"items": items, "total": total}

    def get_order(self, order_id: str) -> Optional[Dict]:
        print(f"[POSTGRES] SELECT * FROM orders WHERE id = '{order_id}'")
        return self._orders.get(order_id)


class StripePaymentGateway:
    """Simulates the Stripe payment API."""

    def __init__(self) -> None:
        print("[STRIPE] Initializing payment gateway...")

    def charge(self, amount: float, token: str) -> bool:
        print(f"[STRIPE] Charging ${amount:.2f} to card {token}")
        print(f"[STRIPE] Payment approved")
        return True


class SendGridEmailer:
    """Simulates the SendGrid email API."""

    def __init__(self) -> None:
        print("[SENDGRID] Initializing email service...")

    def send_confirmation(self, order_id: str, email: str) -> None:
        print(f"[SENDGRID] Sending order confirmation for {order_id} to {email}")
        print(f"[SENDGRID] Email delivered successfully")


# ── High-level module — directly depends on low-level modules ────
class OrderService:
    """Processes e-commerce orders.
    Problem: directly instantiates all external dependencies — cannot be unit-tested."""

    def __init__(self) -> None:
        # Direct instantiation — tight coupling to concrete classes!
        self.db: PostgresDatabase = PostgresDatabase()
        self.payment: StripePaymentGateway = StripePaymentGateway()
        self.emailer: SendGridEmailer = SendGridEmailer()
        self._next_order_id: int = 1

    def place_order(
        self,
        items: List[Dict[str, float]],
        payment_token: str,
        customer_email: str,
    ) -> Dict:
        """Place an order: calculate total, charge payment, save, notify."""
        total: float = sum(item["price"] for item in items)
        order_id: str = f"ORD-{self._next_order_id:04d}"
        self._next_order_id += 1

        item_names: List[str] = [item["name"] for item in items]
        self.payment.charge(total, payment_token)
        self.db.save_order(order_id, item_names, total)
        self.emailer.send_confirmation(order_id, customer_email)

        return {
            "order_id": order_id,
            "items": item_names,
            "total": total,
            "status": "confirmed",
        }


# ── Task 1: Write a unit test ─────────────────────────────────────
def test_order_total_calculation() -> None:
    """Test that place_order() calculates the total correctly.

    TODO:
      1. Create an OrderService.
      2. Call place_order() with two items (prices $29.99 and $49.99).
      3. Assert the total returned equals 79.98.

    After implementing, run this file. Notice how much the test prints:
    your 'unit test' triggers real-looking database writes, Stripe charges,
    and email sends. That's the DIP violation showing up as untestability.
    """
    # TODO: Implement this test.
    pass


if __name__ == "__main__":
    print("=== Placing an Order ===")
    service: OrderService = OrderService()
    result: Dict = service.place_order(
        items=[
            {"name": "Wireless Mouse", "price": 29.99},
            {"name": "USB-C Hub", "price": 49.99},
        ],
        payment_token="tok_visa_4242",
        customer_email="customer@example.com",
    )
    print(f"\nOrder result: {result}")
    print()
    print("=== Running 'Unit' Test ===")
    test_order_total_calculation()
    print("Test passed. (But look at all that side-effect noise — that's the problem.)")
weather_dashboard.py
"""Weather dashboard — DIP exercise.

ONLY concrete low-level classes are provided. You will DESIGN the
abstractions (WeatherProvider, WeatherCache) based on what
WeatherDashboard actually needs — the high-level module OWNS the
abstraction. Then you will write fakes and a unit test.
"""
from __future__ import annotations
from typing import Dict, List, Optional


# ── Low-level modules (external services) ────────────────────────
class OpenWeatherMapClient:
    """Simulates the OpenWeatherMap API client."""

    def __init__(self) -> None:
        print("[OPENWEATHER] Connecting to API...")

    def get_current(self, city: str) -> Dict[str, float]:
        print(f"[OPENWEATHER] GET /weather?q={city}")
        return {"temp": 72.5, "humidity": 45.0, "wind_speed": 8.3}

    def get_forecast(self, city: str, days: int) -> List[Dict[str, float]]:
        print(f"[OPENWEATHER] GET /forecast?q={city}&cnt={days}")
        return [{"temp": 70.0 + i, "humidity": 40.0 + i} for i in range(days)]


class MongoDbCache:
    """Simulates a MongoDB caching layer."""

    def __init__(self) -> None:
        print("[MONGODB] Connecting to cache...")
        self._cache: Dict[str, Dict] = {}

    def get(self, key: str) -> Optional[Dict]:
        print(f"[MONGODB] GET {key}")
        return self._cache.get(key)

    def set(self, key: str, value: Dict, ttl_seconds: int) -> None:
        print(f"[MONGODB] SET {key} (TTL: {ttl_seconds}s)")
        self._cache[key] = value


# ── High-level module — directly depends on low-level modules ────
class WeatherDashboard:
    """Displays weather data with caching.
    Problem: directly instantiates external dependencies, so it cannot
    be unit-tested without connecting to OpenWeatherMap and MongoDB.
    """

    def __init__(self) -> None:
        self.api: OpenWeatherMapClient = OpenWeatherMapClient()
        self.cache: MongoDbCache = MongoDbCache()

    def get_weather(self, city: str) -> Dict[str, float]:
        """Get current weather, using cache if available."""
        cache_key: str = f"weather:{city}"
        cached: Optional[Dict] = self.cache.get(cache_key)
        if cached is not None:
            print(f"[DASHBOARD] Cache hit for {city}")
            return cached

        print(f"[DASHBOARD] Cache miss for {city} — fetching from API")
        data: Dict[str, float] = self.api.get_current(city)
        self.cache.set(cache_key, data, ttl_seconds=300)
        return data

    def get_forecast(self, city: str, days: int = 5) -> List[Dict[str, float]]:
        """Get weather forecast for the next N days."""
        cache_key: str = f"forecast:{city}:{days}"
        cached: Optional[Dict] = self.cache.get(cache_key)
        if cached is not None:
            return cached["data"]

        forecast: List[Dict[str, float]] = self.api.get_forecast(city, days)
        self.cache.set(cache_key, {"data": forecast}, ttl_seconds=600)
        return forecast


# ══════════════════════════════════════════════════════════════════
# TASK 2 — You write everything below this line.
# ══════════════════════════════════════════════════════════════════
#
# Step 1: Design the WeatherProvider abstraction.
#   - Look at the calls WeatherDashboard makes to self.api.
#   - Define an ABC called WeatherProvider with exactly those methods.
#   - The abstraction is owned by the dashboard module — shape it by
#     what the dashboard needs, not by what OpenWeatherMap offers.
#
# Step 2: Design the WeatherCache abstraction.
#   - Same approach: look at the calls to self.cache.
#   - Define an ABC called WeatherCache with get(key) and
#     set(key, value, ttl_seconds).
#
# Step 3: Make the existing concrete classes implement the ABCs.
#   - class OpenWeatherMapClient(WeatherProvider): ...
#   - class MongoDbCache(WeatherCache): ...
#   (They already have the right methods if you designed the ABCs
#   around the dashboard's calls.)
#
# Step 4: Write WeatherDashboardDIP.
#   - Constructor: __init__(self, provider: WeatherProvider, cache: WeatherCache)
#   - Methods: get_weather() and get_forecast() — same logic as
#     WeatherDashboard, but using self.provider and self.cache.
#
# Step 5: Write FakeWeatherProvider(WeatherProvider).
#   - In-memory, no network.
#   - Track how many times get_current was called via self.call_count.
#
# Step 6: Write FakeWeatherCache(WeatherCache).
#   - In-memory dict, no database.
#
# Step 7: Write test_dashboard_uses_cache().
#   - Create a FakeWeatherProvider and FakeWeatherCache.
#   - Create a WeatherDashboardDIP(provider, cache).
#   - Call dashboard.get_weather("Seattle") twice.
#   - Assert provider.call_count == 1 (the second call should hit
#     the cache, not the provider).


if __name__ == "__main__":
    print("=== Original (tightly coupled) ===")
    dash: WeatherDashboard = WeatherDashboard()
    print(dash.get_weather("Seattle"))
    print()
    print(dash.get_weather("Seattle"))  # Cached
10

Integration — Which Principle?

Can You Spot the Violation?

Goal: Diagnose which SOLID principle (if any) a code snippet violates — and justify it in one sentence.

Why This Matters

In real code reviews, violations don’t come with labels. You have to look at a class and recognize the smell from its structure alone. This exercise mixes all five principles so you practice discrimination — telling similar-looking problems apart.

Your Task

Open violations_gallery.py. It has six numbered classes. For each one:

  1. Read the class carefully.
  2. Decide which SOLID principle it violates — or whether it’s actually well-designed.
  3. Add a comment at the top of each class with your diagnosis:
# DIAGNOSIS: <principle> violation — <one-sentence reason>
# Example: "SRP violation — class handles both formatting and persistence"

If a class follows all SOLID principles, write:

# DIAGNOSIS: No violation — <brief justification>

Hint: Exactly one of the six classes has no violation. Don’t assume everything is broken.

The Diagnostic Rubric — Use This in Order

For each snippet, walk through these five yes/no questions in order. The first “yes” is your diagnosis. If all five are “no,” the class is fine.

# Question If yes
1 Does any single team fail to own every change this class could need? (i.e. would changes come from two or more different actors?) SRP violation
2 Would adding a new variant of the behavior here force me to modify this class (not extend it)? OCP violation
3 Could a subclass of this class realistically break a caller that was written against the parent’s contract? LSP violation
4 Is there any client of this class/interface that is forced to depend on methods it doesn’t call? ISP violation
5 Does a high-level policy class directly instantiate, import, or mention a volatile concrete low-level class (DB, HTTP client, file I/O, 3rd-party SDK)? DIP violation

Order matters. Walk through 1 → 5 and stop at the first yes. Some snippets trip multiple principles; the rubric gives you the most architecturally significant one to focus on. If you reach question 5 and everything is “no,” the class is genuinely clean — write that and move on.

Starter files
violations_gallery.py
from abc import ABC, abstractmethod
from typing import Optional


# ── Snippet 1 ─────────────────────────────────────────────────────
class Logger:
    """Handles application logging."""

    def __init__(self, log_path: str) -> None:
        self.log_path: str = log_path

    def log(self, message: str) -> None:
        formatted: str = self._format_message(message)
        self._write_to_file(formatted)

    def _format_message(self, message: str) -> str:
        import datetime
        timestamp: str = datetime.datetime.now().isoformat()
        return f"[{timestamp}] {message.upper()}"

    def _write_to_file(self, formatted_message: str) -> None:
        with open(self.log_path, "a") as f:
            f.write(formatted_message + "\n")


# ── Snippet 2 ─────────────────────────────────────────────────────
class DiscountEngine:
    """Calculates discounts for an e-commerce store."""

    def calculate_discount(self, order_total: float, discount_type: str) -> float:
        if discount_type == "seasonal":
            return order_total * 0.10
        elif discount_type == "clearance":
            return order_total * 0.25
        elif discount_type == "employee":
            return order_total * 0.30
        elif discount_type == "loyalty":
            return order_total * 0.15
        else:
            raise ValueError(f"Unknown discount type: {discount_type}")


# ── Snippet 3 ─────────────────────────────────────────────────────
class User:
    """Base class for application users."""

    def __init__(self, username: str, email: str) -> None:
        self.username: str = username
        self.email: str = email

    def login(self) -> str:
        return f"{self.username} logged in"

    def free_trial_signup(self) -> str:
        return f"{self.username} signed up for free trial"


class PremiumUser(User):
    """A user with a paid subscription."""

    def __init__(self, username: str, email: str, plan: str) -> None:
        super().__init__(username, email)
        self.plan: str = plan

    def free_trial_signup(self) -> str:
        raise RuntimeError(
            f"{self.username} is already on the {self.plan} plan. "
            "Premium users cannot sign up for a free trial."
        )


# ── Snippet 4 ─────────────────────────────────────────────────────
class DatabaseClient(ABC):
    """Abstract interface for all database clients."""

    @abstractmethod
    def connect(self, connection_string: str) -> None: ...

    @abstractmethod
    def query(self, sql: str) -> list[dict[str, object]]: ...

    @abstractmethod
    def disconnect(self) -> None: ...

    @abstractmethod
    def cache_query(self, sql: str, ttl_seconds: int) -> list[dict[str, object]]: ...

    @abstractmethod
    def replicate(self, target_host: str) -> None: ...

    @abstractmethod
    def shard(self, shard_key: str, num_shards: int) -> None: ...


# ── Snippet 5 ─────────────────────────────────────────────────────
class PdfRenderer:
    """Renders content to PDF format."""

    def render(self, content: str) -> bytes:
        return f"<PDF>{content}</PDF>".encode()


class SqlConnection:
    """Connects to a SQL database."""

    def fetch(self, query: str) -> list[dict[str, str]]:
        return [{"id": "1", "data": "sample"}]


class ReportGenerator:
    """Generates reports from database data."""

    def __init__(self) -> None:
        self.renderer: PdfRenderer = PdfRenderer()
        self.db: SqlConnection = SqlConnection()

    def generate(self, report_name: str) -> bytes:
        data: list[dict[str, str]] = self.db.fetch(
            f"SELECT * FROM {report_name}"
        )
        content: str = "\n".join(
            str(row) for row in data
        )
        return self.renderer.render(content)


# ── Snippet 6 ─────────────────────────────────────────────────────
class ValidationRule(ABC):
    """Interface for a single validation rule."""

    @abstractmethod
    def validate(self, value: str) -> Optional[str]:
        """Return an error message if invalid, None if valid."""
        ...


class NotEmptyRule(ValidationRule):
    def validate(self, value: str) -> Optional[str]:
        if not value.strip():
            return "Value must not be empty"
        return None


class MaxLengthRule(ValidationRule):
    def __init__(self, max_length: int) -> None:
        self.max_length: int = max_length

    def validate(self, value: str) -> Optional[str]:
        if len(value) > self.max_length:
            return f"Value must be at most {self.max_length} characters"
        return None


class UserValidator:
    """Validates user input against a configurable set of rules."""

    def __init__(self, rules: list[ValidationRule]) -> None:
        self.rules: list[ValidationRule] = rules

    def validate(self, value: str) -> list[str]:
        errors: list[str] = []
        for rule in self.rules:
            error: Optional[str] = rule.validate(value)
            if error is not None:
                errors.append(error)
        return errors
11

Capstone — The Real World

Final Boss: Library Management System

Goal: Refactor a system with simultaneous SOLID violations in three disciplined phases — and recognize when SOLID is being over-applied.

The Challenge

Here’s library_system.py — a monolithic library management system with multiple SOLID violations baked in. Your mission has two halves:

  1. Annotate — label every violation you find with a comment starting # VIOLATION: <principle> — <reason>. (There are at least four: one each of SRP, OCP, LSP, and DIP.)
  2. Refactor — in three ordered phases, described below. Each phase has its own success criteria and tests. Do not attempt all three at once — that’s the mistake every first-timer makes with a capstone this size.

Why phases? Refactoring a 120-line monolith into 8+ SOLID-compliant classes in one pass will exceed your working memory and your patience. Engineers don’t do that either — real refactors land in small, reviewable commits, each of which leaves the system working. You’re going to practice the same discipline.

Step-Zero: Annotate the Violations

Before you change any code, open library_system.py and read it top to bottom. As you read, add a comment at the top of each offending method or line:

# VIOLATION: DIP — __init__ directly instantiates FileLogger() and CsvReportWriter()
# VIOLATION: OCP — checkout() uses if/elif on book.format; adding a format requires modifying this method
# VIOLATION: LSP — reference books raise NotImplementedError from checkout()
# VIOLATION: SRP — LibrarySystem handles books, members, notifications, and reports — four actors in one class

This annotation pass is fast (5–10 min) and it’s what senior engineers do before any refactor — understand the shape of the problem before touching the code. Your tests in this step won’t check your comments verbatim (they check the final design), but writing them costs very little and pays off when you get stuck mid-refactor and need to remember why you were refactoring.

Phase 1 — DIP: Inject the Side-Effect Dependencies

Goal: Break LibrarySystem’s hard-coded FileLogger() and CsvReportWriter() so you can test it without writing to real files.

What to do:

  1. Define two ABCs at the top of library_system.py: Logger (with abstract log(message)) and ReportWriter (with abstract write(title, rows)).
  2. Make the existing concrete classes extend them: class FileLogger(Logger): ... and class CsvReportWriter(ReportWriter): ... — bodies unchanged.
  3. Change LibrarySystem.__init__() to accept logger: Logger and report_writer: ReportWriter as constructor parameters, storing them. Do not instantiate them inside LibrarySystem.
  4. Update the if __name__ == "__main__": demo to build a FileLogger and CsvReportWriter explicitly and pass them in.

Done when: the demo still runs, and LibrarySystem’s source no longer contains the strings FileLogger() or CsvReportWriter() — a test confirms this.

Why this phase is first: it’s the smallest mechanical change, and it lets you test each subsequent phase without hitting a real file system.

Phase 2 — LSP + OCP: Polymorphic Books

Goal: Eliminate the if/elif chain on book.format by making checkout behavior live on each book subtype.

What to do:

  1. Convert the single Book dataclass into an ABC with abstract methods checkout(self) -> str and is_available(self) -> bool.
  2. Create three concrete subclasses: PhysicalBook(Book), EBook(Book), AudioBook(Book). Each one carries its format-specific fields (copies, download_link, drm_license) and implements its own checkout() — move the respective arm of the old if/elif into the appropriate class.
  3. Introduce a BookRepository class that stores Book instances by ISBN (replaces the self.books dict inside LibrarySystem). Give it add_book, get_book, and get_all_books.
  4. Replace LibrarySystem.checkout()’s if/elif with a single line: result = book.checkout(). No format branches.

Done when: LibrarySystem.checkout() contains no if/elif on book.format. A test confirms this by AST inspection. The demo still runs for physical, ebook, and audiobook.

Principle link: This is where LSP enables OCP. Because every Book subtype fulfills the same checkout() contract, adding a PodcastBook later would require a new class but zero changes to LibrarySystem.

Phase 3 — LSP Structural Fix: ReferenceBook Is Not a Book

Goal: Stop ReferenceBook from lying about the checkout contract by removing it from the Book hierarchy entirely.

What to do:

  1. Create a separate ReferenceBook class that does not inherit from Book. It has a browse() method instead of checkout().
  2. Add add_reference_book and get_reference_book methods to BookRepository (reference books live in a parallel dict).
  3. Verify: CheckoutService (or LibrarySystem.checkout) cannot be accidentally called with a ReferenceBook — it simply isn’t a Book. The static type now prevents the runtime error entirely.

Done when: ReferenceBook has no checkout() method (a test confirms this). Reference books can be browse()d but not checked out. A test tries both paths and verifies neither raises NotImplementedError.

Principle link: This is the LSP structural fix from Step 7. A subclass that cannot honor the contract should not be a subclass.

Phase 4 (Optional Stretch, expand): SRP — split the services. The core design lesson is already complete after Phase 3 — tests fall back if you skip this. Open if you want to push the SRP split further.

Split LibrarySystem into focused services:

  • MemberRegistry (member add/lookup — was register_member + get_member)
  • NotificationService (send overdue notices — was send_overdue_notice)
  • CheckoutService (orchestrate checkouts using the above)
  • InventoryReporter (generate inventory reports — was generate_inventory_report)

Each new class has one actor and takes its dependencies through the constructor. The solution file at the end shows one reasonable split.

How the Principles Reinforce Each Other — Your Capstone Insight

Look at the three phases as a cascade. Each one enabled the next:

  • Phase 1 (DIP) let you test phases 2 and 3 without hitting the file system.
  • Phase 2 (LSP + OCP) let you kill the if/elif — which was only possible because every book type honored the same contract.
  • Phase 3 (LSP structural fix) eliminated the one subclass that couldn’t honor the contract — which in turn made Phase 2’s polymorphism honest.

This is what “the principles work together” actually means in code: not five separate rules you satisfy in parallel, but a chain where each principle removes the obstacle that made the next one impractical. Senior developers don’t remember the acronym; they remember the chain.

Don’t Over-Engineer

SOLID is a tool, not a religion. Apply it when the complexity of the domain justifies it. Don’t be that person who architects a to-do app like it’s a distributed system.

Cautionary example (expand): four interfaces, a factory, and a result wrapper — all for 2 + 3.

This is what happens when SOLID is applied without judgment — when you treat principles as rules to maximize instead of tools to apply thoughtfully:

# over_engineered.py — DO NOT DO THIS
from abc import ABC, abstractmethod
from typing import Protocol

class IAddable(Protocol):
    def add(self, a: float, b: float) -> float: ...

class ISubtractable(Protocol):
    def subtract(self, a: float, b: float) -> float: ...

class IMultipliable(Protocol):
    def multiply(self, a: float, b: float) -> float: ...

class IDivisible(Protocol):
    def divide(self, a: float, b: float) -> float: ...

class CalculationResult:
    def __init__(self, value: float, operation: str) -> None:
        self.value = value
        self.operation = operation
    def unwrap(self) -> float:
        return self.value

class CalculationStrategyFactory:
    @staticmethod
    def create(op: str) -> "MathEngine":
        return MathEngine()

class MathEngine(IAddable, ISubtractable, IMultipliable, IDivisible):
    def add(self, a: float, b: float) -> float:
        return CalculationResult(a + b, "add").unwrap()
    def subtract(self, a: float, b: float) -> float:
        return CalculationResult(a - b, "subtract").unwrap()
    def multiply(self, a: float, b: float) -> float:
        return CalculationResult(a * b, "multiply").unwrap()
    def divide(self, a: float, b: float) -> float:
        if b == 0:
            raise ZeroDivisionError("Cannot divide by zero")
        return CalculationResult(a / b, "divide").unwrap()

# Usage — compare to: result = 2 + 3
engine = CalculationStrategyFactory.create("add")
result = engine.add(2, 3)

The original 2 + 3 was already perfectly clear. The “principled” version added four interfaces and a factory for zero benefit.

When NOT to Apply SOLID

  • Simple scripts and one-off tasks: If the code will never change, don’t invest in extension points.
  • Prototypes and spikes: Favor speed over structure — you’re gonna throw it away anyway.
  • When there’s only one variant: Don’t create an ABC with a single implementation. Wait until you actually have two variants, then extract the abstraction.
  • When it obscures intent: If a 5-line function becomes 50 lines across 4 classes, the cure is worse than the disease.

SOLID is the grammar. Architecture is the sentences. You have the grammar now. Go write things that last.

Optional (expand): SOLID scales up to architecture. A short forward-pointer to where you'll meet these principles again at the package and service level.

You just learned SOLID at the level of classes inside one module. The same reasoning scales up to components (packages, services, deployable units) — and when you meet it later in your career, it will sound suspiciously familiar:

Class-level (what you learned) Component/architecture level (next course) Same underlying idea
SRP — one actor per class Common Closure Principle — classes that change together live in the same package Group by reason to change
ISP — don’t depend on methods you don’t use Common Reuse Principle — don’t depend on packages whose classes you don’t use Don’t depend on what you don’t need
DIP — depend on abstractions, not concretions Dependency Rule — source-code dependencies point toward stable, high-level policy Point dependencies toward what changes least

The fancy names (Common Closure, Common Reuse, Dependency Rule) aren’t new tricks — they’re the principles you just practiced, played at a bigger scale. When you take a software architecture course and meet clean architecture, hexagonal architecture, or onion architecture, the centerpiece is always the Dependency Rule — a direct descendant of the DIP exercise you just did with OrderService and WeatherDashboard.

Starter files
library_system.py
from dataclasses import dataclass, field
from typing import Optional


# ── Data Models ──────────────────────────────────────────────────
@dataclass
class Book:
    title: str
    author: str
    isbn: str
    format: str  # "physical", "ebook", "audiobook", "reference"
    copies: int = 1
    download_link: str = ""
    drm_license: str = ""


@dataclass
class Member:
    member_id: str
    name: str
    email: str
    borrowed_isbns: list[str] = field(default_factory=list)


# ── Concrete low-level classes (instantiated directly) ──────────
class FileLogger:
    """Logs messages to a file."""

    def log(self, message: str) -> None:
        print(f"[LOG] {message}")


class CsvReportWriter:
    """Writes reports in CSV format."""

    def write(self, title: str, rows: list[dict[str, str]]) -> str:
        if not rows:
            return f"{title}\n(empty)"
        headers: str = ",".join(rows[0].keys())
        lines: list[str] = [f"# {title}", headers]
        for row in rows:
            lines.append(",".join(str(v) for v in row.values()))
        return "\n".join(lines)


# ── The Monolithic System ──────────────────────────────────────
class LibrarySystem:
    """Manages books, members, checkouts, notifications, and reports.

    This class has multiple SOLID violations. Your task:
    1. Identify each violation (SRP, OCP, LSP, DIP).
    2. Refactor into well-designed classes.
    """

    def __init__(self) -> None:
        self.books: dict[str, Book] = {}
        self.members: dict[str, Member] = {}
        # DIP violation: directly instantiates concrete classes
        self.logger: FileLogger = FileLogger()
        self.report_writer: CsvReportWriter = CsvReportWriter()

    # ── Book Management ────────────────────────────────────────
    def add_book(self, book: Book) -> None:
        if book.isbn in self.books:
            existing: Book = self.books[book.isbn]
            existing.copies += book.copies
        else:
            self.books[book.isbn] = book
        self.logger.log(f"Added book: {book.title} ({book.isbn})")

    def get_book(self, isbn: str) -> Book:
        if isbn not in self.books:
            raise ValueError(f"Book with ISBN {isbn} not found")
        return self.books[isbn]

    # ── Member Management ─────────────────────────────────────
    def register_member(self, member: Member) -> None:
        if member.member_id in self.members:
            raise ValueError(
                f"Member {member.member_id} already registered"
            )
        self.members[member.member_id] = member
        self.logger.log(
            f"Registered member: {member.name} ({member.member_id})"
        )

    def get_member(self, member_id: str) -> Member:
        if member_id not in self.members:
            raise ValueError(f"Member {member_id} not found")
        return self.members[member_id]

    # ── Checkout (OCP violation: if/elif on format) ───────────
    def checkout(self, member_id: str, isbn: str) -> str:
        member: Member = self.get_member(member_id)
        book: Book = self.get_book(isbn)

        # LSP violation: reference books raise instead of
        # working polymorphically
        if book.format == "reference":
            raise NotImplementedError(
                f"'{book.title}' is a reference book and "
                "cannot be checked out."
            )

        # OCP violation: adding a new format means editing
        # this if/elif chain
        if book.format == "physical":
            if book.copies <= 0:
                raise ValueError(
                    f"No copies of '{book.title}' available"
                )
            book.copies -= 1
            result: str = (
                f"Checked out physical copy of '{book.title}'"
            )
        elif book.format == "ebook":
            link: str = (
                book.download_link or
                f"https://library.example.com/ebook/{book.isbn}"
            )
            result = (
                f"Download '{book.title}' at: {link}"
            )
        elif book.format == "audiobook":
            if not book.drm_license:
                raise ValueError(
                    f"No DRM license for '{book.title}'"
                )
            result = (
                f"Stream '{book.title}' with license: "
                f"{book.drm_license}"
            )
        else:
            raise ValueError(f"Unknown format: {book.format}")

        member.borrowed_isbns.append(isbn)
        self.logger.log(
            f"{member.name} checked out '{book.title}' "
            f"({book.format})"
        )
        return result

    # ── Notifications (SRP violation: mixed into system) ──────
    def send_overdue_notice(
        self, member_id: str, isbn: str
    ) -> str:
        member: Member = self.get_member(member_id)
        book: Book = self.get_book(isbn)
        notice: str = (
            f"Dear {member.name}, '{book.title}' is overdue. "
            f"Please return it to the library."
        )
        print(f"[NOTICE] {notice}")
        self.logger.log(
            f"Sent overdue notice to {member.name} "
            f"for '{book.title}'"
        )
        return notice

    # ── Reports (SRP violation: mixed into system) ───────────
    def generate_inventory_report(self) -> str:
        rows: list[dict[str, str]] = []
        for isbn, book in self.books.items():
            rows.append({
                "isbn": isbn,
                "title": book.title,
                "author": book.author,
                "format": book.format,
                "copies": str(book.copies),
            })
        return self.report_writer.write("Inventory Report", rows)


# ── Demo ──────────────────────────────────────────────────────
if __name__ == "__main__":
    lib = LibrarySystem()

    lib.add_book(Book("The Pragmatic Programmer", "Hunt & Thomas",
                      "978-0135957059", "physical", copies=3))
    lib.add_book(Book("Clean Code", "Robert C. Martin",
                      "978-0132350884", "ebook",
                      download_link="https://example.com/clean"))
    lib.add_book(Book("Design Patterns", "GoF",
                      "978-0201633610", "audiobook",
                      drm_license="DRM-ABC-123"))
    lib.add_book(Book("Encyclopedia Britannica", "Various",
                      "978-1593392925", "reference"))

    lib.register_member(Member("M001", "Alice", "alice@mail.com"))
    lib.register_member(Member("M002", "Bob", "bob@mail.com"))

    print(lib.checkout("M001", "978-0135957059"))
    print(lib.checkout("M001", "978-0132350884"))
    print(lib.checkout("M002", "978-0201633610"))
    print(lib.send_overdue_notice("M001", "978-0135957059"))
    print()
    print(lib.generate_inventory_report())
12

Consolidation — What Survives

Consolidation: What Survives in a Year

Goal: Re-derive the five principles from memory and name three signs of over-engineering. Best done at least 24 hours after the capstone — the spacing is the point.

Why One More Step?

Research on human memory is blunt about this: material you read decays within days, but material you actively retrieved from your own head survives for months. You just finished the hardest step of the tutorial. This one is short on purpose, and it is the step that will decide whether your SOLID knowledge is still alive in six months.

You will do three things:

  1. Re-derive all five principles without re-reading earlier steps. Write each one in your own words.
  2. Discriminate under interleaving: the quiz below mixes all five principles in random order so you can’t coast on step-by-step recency.
  3. Reflect on when not to apply SOLID — because the second most common SOLID failure (after violating it) is over-applying it.

Task — Fill the PRINCIPLES Dictionary From Memory

Open principle_match.py. You’ll see a Python dict PRINCIPLES with five empty-string values — one per principle. Your task: without scrolling back to earlier steps, write a one-sentence definition of each principle in your own words.

Guidance:

  • Aim for 20–200 characters per definition.
  • Use the vocabulary from Step 1: actor, reason to change, coupling, cohesion, abstraction, contract.
  • Avoid empty rewordings like “SRP means single responsibility.” Say what single responsibility actually meansone actor, one reason to change, one audience for the class’s output.
  • The tests are forgiving about exact wording but check that each principle’s definition touches its unique anchor concept (e.g., SRP must mention actors/reasons/responsibility, OCP must mention extension/modification, etc.).

If a principle escapes you: write what you remember, then scroll back if you must — but write first. The attempt to retrieve is what strengthens memory, even when it fails. Reading the definition again before you’ve tried feels comfortable and measurably hurts long-term retention.

Task — List Three Reasons NOT to Apply SOLID

In the same file, you’ll see a list REASONS_TO_SKIP_SOLID = []. Populate it with at least three strings — each one a concrete situation where applying SOLID would be over-engineering. Step 11’s cautionary example is a starting point; good answers include signs like “only one variant today and no concrete plan for a second,” “prototype code with a horizon of under a week,” “a five-line function that would become fifty across four classes.”

Recognizing when not to apply a principle is what separates engineers who use SOLID as a tool from ones who use it as a cudgel. Put the guardrails in words you’ll remember.

Optional but powerful (expand): Teach it back. A 3-minute exercise that exploits the protégé effect — preparing to teach produces deeper encoding than preparing to be tested.

Before you take the quiz, pick one of the five principles and, in 3–5 sentences, write a teach-back for an imaginary junior engineer on your team. Use a concrete code smell from the tutorial (e.g., God Class, Switch Statement Smell, Refused Bequest, Fat Interface, Hardcoded Dependency) as your hook. This step has no test — it’s for you. Research on the protégé effect shows that preparing to teach material produces deeper encoding than preparing to be tested on it.

Starter files
principle_match.py
"""Consolidation exercise: re-derive the five SOLID principles
from memory, then list three situations where SOLID should NOT
be applied.

RULES:
  1. Fill in PRINCIPLES with your own-words definitions
     (20-200 chars each). Use the Step 1 vocabulary — actor,
     reason to change, coupling, cohesion, abstraction.
  2. Fill in REASONS_TO_SKIP_SOLID with at least 3 concrete
     situations where applying SOLID is over-engineering.
  3. Do NOT scroll back to earlier steps until you've written
     at least one attempt at each definition. The point is to
     retrieve, not to transcribe.
"""

PRINCIPLES: dict[str, str] = {
    # Single Responsibility Principle
    "SRP": "",

    # Open/Closed Principle
    "OCP": "",

    # Liskov Substitution Principle
    "LSP": "",

    # Interface Segregation Principle
    "ISP": "",

    # Dependency Inversion Principle
    "DIP": "",
}


REASONS_TO_SKIP_SOLID: list[str] = [
    # TODO: Add at least 3 concrete signs that applying SOLID
    # would be over-engineering. Examples to inspire you (use
    # your own wording — don't just copy):
    #   - "Only one variant of the behavior exists today, and
    #      there is no concrete plan for a second one."
    #   - "Prototype / spike code with a horizon of under a week."
    #   - "A 5-line function would become 50 lines across 4
    #      classes — the cure is worse than the disease."
]


# ── Self-check (run the file to see your summary) ───────────────
if __name__ == "__main__":
    print("Your SOLID summary:\n")
    for key, defn in PRINCIPLES.items():
        status = "OK" if 20 <= len(defn) <= 200 else "NEEDS WORK"
        print(f"  [{status:<10}] {key}: {defn or '(empty)'}")
    print(f"\nReasons to skip SOLID ({len(REASONS_TO_SKIP_SOLID)}):")
    for i, reason in enumerate(REASONS_TO_SKIP_SOLID, 1):
        print(f"  {i}. {reason}")