SOLID Design Principles: Stop Writing Code That Falls Apart
Learn the SOLID principles by suffering through rigid code first, then leveling up with refactoring. Each principle hits you with a realistic scenario where one tiny change request breaks everything — and then shows you how to fix it so your code actually survives contact with reality.
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:
- 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).
- 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()andcalculate_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):
_grade_weightsuses numeric keys4.0, 3.0, 2.0, 1.0, 0.0instead of"A", "B", "C", "D", "F", andadd_grade()accepts those numeric grades.generate_report()returns a JSON string (built withjson.dumps) instead of HTML. The JSON should at least contain the student’s name, their courses with grades, and the GPA.- 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”):
“
CourseManagerhurt 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.
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")
Why Design Principles? — Knowledge Check
Min. score: 60%1. You tried to change the grading scale and the report format. What made this so painful?
The core problem is coupling: _format_grade() does double duty for both grade calculation display and report rendering. You can’t change it for one without breaking the other. It’s like having one remote control for your TV and your neighbor’s TV — change the channel and you change theirs too.
2. Coupling in software design means:
Coupling measures how tangled your components are with each other’s internals. High coupling = change one thing, break five others. The CourseManager is a coupling disaster — its methods share helpers and data in ways that make independent changes basically impossible.
3. How many distinct actors (stakeholders with different reasons to request changes) does the CourseManager class serve?
At least three different groups want different things from this class: (1) administration cares about enrollment, (2) the academic department cares about grade calculation, and (3) the registrar cares about reports and transcripts. When one class tries to make everyone happy, changes from one group break things for another. This is exactly why the Single Responsibility Principle exists — you’ll learn it next.
4. Prediction check. Before the task, you were asked to predict what would happen when you changed _format_grade(). Which option actually turned out to be true as you worked through the code?
Answer (C). _format_grade() is called from get_grade_summary(), generate_report(), AND send_grade_notification() — three outputs owned by three different audiences (academics, registrar, students). “Private” only means “not part of the public class API.” It says nothing about internal coupling. Any ripple you have to hold in your head to be safe is coupling; coupling is what SOLID is designed to dissolve.
5. Evaluate. Your manager looks at the patched CourseManager and says: “It works. Let’s ship it and move on.” You disagree. Which of the following are the strongest reasons to push back before the next ticket arrives? Select all that apply.
(select all that apply)
The strongest pushback against shipping is architectural — future-change risk, multi-audience coupling, and cross-team coordination cost. These are exactly what you’ll hear senior engineers say in code review. SOLID is about actors and reasons to change, not about file size: a 400-line class serving one actor can be fine, and a 40-line class serving three actors is a bomb.
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.
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())
SRP — Feel the Friction — Knowledge Check
Min. score: 60%1. In the Single Responsibility Principle, a “reason to change” comes from:
Robert C. Martin defines “reason to change” in terms of actors — the people or teams who ask for changes. A class serving multiple actors gets pulled in different directions, and that’s how you end up with the breakage you just experienced.
2. A class has 10 methods. Does this automatically mean it violates the Single Responsibility Principle?
SRP is not about counting methods. A class with 10 methods can be totally fine if they all serve the same actor. Meanwhile, a class with just 3 methods can be an SRP disaster if those 3 methods serve 3 different teams. It’s about who wants changes, not how many methods you have.
3. In the EmployeeManager class, which actors are served by which responsibilities?
(select all that apply)
Each team wants different things: Finance changes tax rules, HR changes display formats, Compliance changes export structure. When one class tries to serve all three, a change for one team (like that tax bracket update) can ripple into the others’ outputs. Splitting these into separate classes is exactly what SRP is about.
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:
- Matching Ops — owns the driver-matching algorithm. Cares about ETA, distance, driver ratings.
- Pricing — owns fare calculation, surge rules, platform fee. Reports to the CFO’s office. Under audit scrutiny.
- Communications — owns SMS/push notification templates. Works with Legal on TCPA anti-spam compliance.
- 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):
After (one class per actor; each owns its own helpers):
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.
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))
SRP — Refactor & Practice — Knowledge Check
Min. score: 60%1. After the refactor, Pricing wants to add a tiered platform-fee promo: 15% under $20, 10% above. Which class do they modify, and what is the worst thing that could happen to Trust & Safety?
This is the whole point of the refactor. In the monolithic class, _platform_fee was shared between Pricing (for fares) and T&S (for high-fee anomaly detection). A promo tweak silently changed T&S’s detector — the exact coupling that ships fraud bugs on Black Friday. After the split, FareCalculator and SafetyAuditor each own their own helpers. Pricing can experiment freely; T&S stays stable.
2. Your code reviewer complains: “Both DriverMatcher and SafetyAuditor have their own _distance() method — that’s duplicated code! Extract a shared Geometry module.” How do you respond?
This is the subtlest SRP lesson: DRY (“Don’t Repeat Yourself”) and SRP sometimes pull in opposite directions. DRY says “share identical code.” SRP says “let actors change independently.” When the duplicated code genuinely serves two different actors, SRP wins — because the day Matching Ops switches to haversine for better accuracy, T&S would NOT want to inherit that change. Duplication across actor lines is architectural insurance.
3. A junior engineer says: “SafetyAuditor and DriverMatcher both use _distance. I’ll just give SafetyAuditor a reference to a DriverMatcher instance so we can reuse the method.” What SRP issue does this create?
Sharing private helpers through indirection is still sharing. The whole point of actor-ownership is that changes by one team cannot surprise another team. Threading DriverMatcher into SafetyAuditor just to reuse _distance smuggles the old coupling back in under a new name. The right answer is: let T&S own its own distance formula, even if it’s identical today.
4. Which are genuine benefits of the SRP refactor you just performed? (Select all that apply.) (select all that apply)
SRP often increases total line count slightly (more class scaffolding, sometimes duplicated helpers) — and that’s fine. The payoff is independent testability, independent release cadence across teams, and reusable focused classes. The only “cost” is a small amount of duplication; the “benefit” is being able to change software without terror. That’s the right trade in any system that ships for years.
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:
- Open
notification_router.py - Read through all existing branches to understand the pattern
- Slap another
elifat the bottom - 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.
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")
OCP — Feel the Friction
Min. score: 66%
1. Imagine the NotificationRouter grows to support 20 channels, all in one if/elif chain. A junior developer needs to add channel #21. Which of the following problems are they likely to face?
(select all that apply)
The first three are real consequences of the if/elif design — they all trace to the same root cause: all channel logic is stuffed into one method. Reading hundreds of lines to add a feature is painful. Editing shared code risks breaking stuff. And you can’t test one channel without loading all the others. Option 4 confuses design problems with performance problems — adding an elif adds essentially no runtime cost at typical scale, so a junior dev shouldn’t reach for it as their reason to refactor. OCP is about maintainability and safety, not raw speed.
2. The Open/Closed Principle says a module should be “open for extension, closed for modification.” What does “closed for modification” mean in practice?
“Closed for modification” doesn’t mean the file is literally locked. It means the design lets you add new features (like a new notification channel) without touching the existing, tested code. You write new classes that plug into the system instead of editing the core logic. Bug fixes are still fine — this principle is about feature additions.
3. You just added Slack support by modifying NotificationRouter.send_notification(). While editing, you accidentally changed the SMS truncation from 160 to 60 characters. The SMS tests still pass because they use a short message.
This scenario illustrates a key risk of violating OCP. Which risk?
Classic regression risk. When all channels share one method, editing any part puts every channel at risk. The SMS bug went undetected because test messages were short enough to pass with the wrong limit. With an OCP-compliant design, adding Slack wouldn’t touch SMS code at all — this entire category of bug becomes impossible.
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:
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:
- Create an
ExportFormatabstract base class with:- A
nameabstract property (returns the format name like"csv") - A
format_report(title, headers, rows)abstract method
- A
- Create three concrete classes:
CsvFormat,JsonFormat,TextFormat - Refactor
ReportExporterto accept a list of format objects and delegate to them — no if/elif chain - Run the file to verify it works
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"))
OCP — Refactor & Practice
Min. score: 66%1. Arrange the code blocks to construct a polymorphic notification system following OCP. Drag the blocks into the correct order. (arrange in order)
from abc import ABC, abstractmethodclass NotificationChannel(ABC):@abstractmethoddef deliver(self, user, message):...class EmailChannel(NotificationChannel):def deliver(self, user, message):print(f"[EMAIL] {user}: {message}")class NotificationRouter:def __init__(self, channels):self._channels = {ch.name: ch for ch in channels}def send(self, user, message, channel_name):self._channels[channel_name].deliver(user, message)
if channel_name == "email":elif channel_name == "sms":self._channels.append(channel_name)
The correct order builds: (1) ABC import, (2) abstract NotificationChannel with deliver, (3) concrete EmailChannel implementing it, (4) a NotificationRouter that delegates via dictionary lookup. The distractors use if/elif chains and list appends — the old broken patterns. Polymorphism replaces all of that with dictionary dispatch and method delegation.
2. In the refactored ReportExporter, you created separate classes for CsvFormat, JsonFormat, and TextFormat. Thinking back to SRP: does each format class have one reason to change?
Each format class has exactly one reason to change: when its specific serialization rules change. CsvFormat only changes for CSV reasons; JsonFormat only for JSON reasons. That’s SRP working as intended. Sharing an abstract base class doesn’t violate SRP — the ABC is a contract, and SRP applies regardless of class size.
3. A teammate wants to add a MarkdownFormat to the refactored ReportExporter. What do they need to modify?
This is the whole payoff of OCP. Adding Markdown means writing a new MarkdownFormat(ExportFormat) class and registering it. ReportExporter, ExportFormat, and all existing format classes stay untouched. Compare that to Step 4, where adding Slack meant surgery on the NotificationRouter itself.
4. Which of the following are benefits of the refactored polymorphic design over the original if/elif chain? (select all that apply)
The first three are real benefits. Each format is independently testable, new formats can’t break existing ones, and the exporter is cleaner because it just dispatches. The fourth is a myth — polymorphism doesn’t inherently save memory, and in Python the difference is negligible. OCP is about maintainability and safety, not performance.
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
timestampfield 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 -lprints file names. A competinggrepclone implements-las “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 contractDebitCardProcessor— honors the full contractcheckout(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:
- Strengthens the precondition:
process_payment(amount)rejects amounts greater than the card’s balance (even though the base class accepts any positive amount). Raise aValueErrorwith the message"Insufficient gift card balance". - 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:
- The result must be a
dict. result["success"]must beTrue.result["amount"]must be numeric (intorfloat).
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.
"""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
"""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
LSP Part 1 — Knowledge Check
Min. score: 75%1. The Liskov Substitution Principle says a subclass must: (select all that apply)
LSP is about behavioral compatibility. A subclass can’t reject inputs the parent accepts (no strengthening preconditions — option 3 is the opposite of what LSP requires; the smile-vs-frown picture says preconditions get weaker in subtypes, not stronger) and can’t surprise callers with exceptions the parent never declared (option 4 is the classic Refused-Bequest dressed up as documentation). Both options 3 and 4 are misconceptions students arrive with from intuition — “specific subtypes should be stricter” and “documenting an exception makes it OK.” Neither survives contact with the contract: callers wrote against the parent’s promises, not the subclass’s footnotes.
2. A PaymentProcessor base class defines refund(txn_id) with the postcondition “returns {"success": True, "amount": <float>}.” A GiftCardProcessor subclass returns {"success": False, "error": "non-refundable"} instead. A teammate argues: “It still returns a dict with the right keys, so it follows the contract.”
Why is this still an LSP violation?
The postcondition isn’t just “return a dict” — it’s “actually refund and return the amount.” Returning success=False silently breaks that promise. Callers that trust the contract will keep going as if the refund worked. That’s actually worse than crashing — at least a crash tells you something went wrong.
3. Consider this code:
class Bird(ABC):
@abstractmethod
def fly(self) -> None:
...
class Penguin(Bird):
def fly(self) -> None:
raise NotImplementedError("Penguins can't fly")
If Bird promises fly() works, then Penguin breaking that promise will crash any code that loops over birds and calls fly(). This has a name: Refused Bequest. The fix is the same pattern you’ll use in the next step: split into Bird (base) and FlyingBird (adds fly()). Seeing NotImplementedError in a concrete subclass is basically a flashing neon sign saying “LSP violation here.”
4. A base class method calculate_shipping(weight) accepts any positive weight. A subclass overrides it to reject weights above 50 kg. This is an example of:
The base class accepts any positive weight. The subclass rejects weights above 50 kg — it accepts a smaller set of inputs. That’s strengthening the precondition, which violates LSP. Code that passes 75 kg expecting it to work will blow up when it gets the subclass. Fix: declare the weight limit explicitly (e.g., a max_weight property) instead of silently restricting inputs.
5. Why are assert statements a useful way to encode a method’s postcondition?
(select all that apply)
The first three capture the value of Design-by-Contract: executable, documenting, and diagnostic. The fourth is wrong — assertions complement unit tests, they don’t replace them. Unit tests verify specific inputs; contract assertions verify every call. Using both gives you deep coverage.
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()):
After (split: LiveStream doesn’t inherit what it can’t honor):
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:
- Split
MediaPlayerinto two classes:MediaPlayer(base) — abstract methods:play()andstop()only.SeekablePlayer(MediaPlayer)— adds abstractseek(position)andget_duration().
- Move
AudioFilePlayerandVideoFilePlayerto extendSeekablePlayer. - Change
LiveStreamPlayerto extendMediaPlayeronly — and removeseek()andget_duration()entirely. Not even stubs that raise. The whole point is that a live stream cannot be asked for these. - Update
playlist_renderer(players)to useisinstance(player, SeekablePlayer)to decide whether a progress bar is appropriate. Live streams get an entry withhas_progress_bar=Falseandduration=None.
The tests in test_media_player.py check each of these — including a test that confirms LiveStreamPlayer is not an instance of SeekablePlayer.
"""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
"""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
LSP Part 2 — Knowledge Check
Min. score: 75%
1. After splitting PaymentProcessor into PaymentProcessor (base) and RefundableProcessor (extends base with refund), checkout_with_refund() is typed to accept list[RefundableProcessor]. What does this give us that the old design did not?
(select all that apply)
The first three are the core wins. GiftCardProcessor can still be used for payments (option 4 is wrong) — it just cannot be used anywhere refund() is required, because it no longer claims to support it. The type signature of the function makes this explicit at the boundary.
2. In the media player refactor, LiveStreamPlayer ended up with no seek() or get_duration() methods — not even stubs that raise. Why is removing them entirely better than keeping stubs that raise NotImplementedError?
The whole point of LSP is honesty about capabilities. A seek() that raises is a lie dressed as a method. Removing it moves the check from runtime (where it crashes after some work has already started) to load time (where isinstance(player, SeekablePlayer) returns False and the code goes down the non-seekable branch). Static type checkers like mypy can enforce this at CI time.
3. Which of the following subclass behaviors would violate LSP? Select all that apply. (select all that apply)
- New exceptions (option 1): violates LSP — callers don’t expect them.
- Weaker preconditions (option 2): LSP-compliant — accepting more is always safe.
- Weaker postconditions (option 3): violates LSP — you promised more than you’re delivering.
- Faster (option 4): LSP-compliant — performance improvements don’t change the contract.
4. You see this structure in a legacy codebase:
class Bird(ABC):
@abstractmethod
def fly(self): ...
@abstractmethod
def swim(self): ...
class Penguin(Bird):
def fly(self): raise NotImplementedError()
def swim(self): return "Penguin swimming"
class Eagle(Bird):
def fly(self): return "Eagle flying"
def swim(self): raise NotImplementedError()
Two Refused Bequests mean two extension interfaces are needed. Bird is the minimal common contract, FlyingBird adds fly(), SwimmingBird adds swim(). This mirrors the payments split (PaymentProcessor / RefundableProcessor). Any function that needs a flyer takes a FlyingBird; any function that needs a swimmer takes a SwimmingBird.
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:
- Run
cloud_storage.py. Watch the demo section. The last thing it prints is a line wherebackup_service(free)callssync()on aFreeStorageand crashes with aNotImplementedError. - Read the
backup_service()function at the bottom. Its type signature isstorage: 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. - 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:
- Define
Dimmable(ABC),Playable(ABC),TemperatureControllable(ABC),Lockable(ABC)— each with exactly one@abstractmethodand a__init__(self, name: str). - Implement
SmartLight(Dimmable),SmartSpeaker(Playable),SmartThermostat(TemperatureControllable),SmartLock(Lockable)— each class extends exactly one role interface and implements only its one method. - Do not retain
SmartDevice,SmartLightBad, or anyraise NotImplementedErrorstubs in your finalsmart_home.pybesides the pre-existing reference copies ofSmartDevice/SmartLightBadat 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:
Segregated interfaces (ISP-compliant). Each capability is its own interface; classes only implement what they can actually support:
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, andSupportTicketLog. - 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
NotImplementedErrora method it doesn’t need → ISP violation. - A single class can violate both. Fixing ISP often exposes an SRP violation underneath, and vice versa.
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}")
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}")
ISP — Lean Interfaces — Knowledge Check
Min. score: 60%
1. Observation check (Task 1). In cloud_storage.py, backup_service(free) crashes on a FreeStorage instance. Its type signature is storage: CloudStorage. Where does the violation originate?
Both implementations honestly tried to satisfy the ABC. FreeStorage raised NotImplementedError because it couldn’t really sync; backup_service trusted the CloudStorage contract that said every subclass could sync. The interface itself is the bug: it promised capabilities that not every implementer can honestly provide. ISP prescribes the structural fix — split the fat ABC so only implementers that can sync are allowed to claim they can, via a separate Syncable interface that FreeStorage never extends.
2. What is the difference between the Interface Segregation Principle (ISP) and the Single Responsibility Principle (SRP)?
SRP and ISP tackle different things. SRP = a class should serve one actor (internal cohesion). ISP = an interface shouldn’t force clients to depend on methods they don’t use (external contract). You can satisfy SRP while violating ISP, and vice versa. They’re complementary, not synonymous.
3. Consider these two code sketches: Class A:
class OrderProcessor:
# All methods are used; no stubs.
def calculate_tax(self, order): ... # Finance
def format_receipt(self, order): ... # UX
def send_shipping_update(self, order): ... # Ops
class DocumentEditor(ABC):
@abstractmethod
def edit_text(self): ...
@abstractmethod
def edit_images(self): ...
@abstractmethod
def edit_video(self): ...
class TextEditor(DocumentEditor):
def edit_text(self): ...
def edit_images(self): raise NotImplementedError
def edit_video(self): raise NotImplementedError
Class A serves three actors (Finance, UX, Ops) that each drive change to the same class — SRP violation. Every method is used, so there’s no ISP problem.
Class B has a single actor (text editing) but is forced to implement video and image methods with raise NotImplementedError — classic ISP violation. Fix by splitting into TextEditable, ImageEditable, VideoEditable interfaces. No SRP problem: the class has one cohesive responsibility (edit text), it just depends on too-wide an interface.
The discrimination rule: multiple actors → SRP; forced-to-stub methods → ISP.
4. How does ISP help prevent LSP violations?
ISP prevents LSP violations by design. In the exercise, FreeStorage was forced to inherit sync() and could only raise NotImplementedError — textbook LSP violation. With segregated interfaces, FreeStorage wouldn’t inherit Syncable at all, making it impossible to break the sync contract. Problem solved at the design level, not patched at runtime.
5. A colleague proposes splitting a 6-method interface into 6 single-method interfaces — one per method. Is this always a good idea?
ISP is about role-based grouping, not splitting everything into atoms. One-method-per-interface creates an explosion of tiny interfaces that are a nightmare to discover and compose. The goal is to group methods that change together and are used together by the same clients. For example, Uploadable can reasonably include both upload() and get_upload_status() if every client that uploads also checks status.
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:
- The place that creates concrete objects should be the outermost shell of your program, not its core.
- 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 withprint())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:
- Creates an
OrderServiceinstance. - Calls
place_order()with two items (prices $29.99 and $49.99). - 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):
After (high-level depends on abstractions; low-level implements them — arrows now meet in the middle):
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:
- 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 insideWeatherDashboard.get_weather()andget_forecast()— the abstraction should have exactly the methods those calls need, no more. - 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. - Make
OpenWeatherMapClientextendWeatherProviderandMongoDbCacheextendWeatherCache. They already have the right methods if you designed the ABCs based on the dashboard’s calls. - Create
WeatherDashboardDIP— a class that acceptsWeatherProviderandWeatherCachevia its constructor (DI), and implements the same caching logic asWeatherDashboardbut only through the abstractions. - Write
FakeWeatherProviderandFakeWeatherCache— in-memory implementations for testing.FakeWeatherProvidershould count how many timesget_current()was called. - Write
test_dashboard_uses_cache()— using the fakes, verify that callingdashboard.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.
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 — 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
DIP — Knowledge Check
Min. score: 75%1. What is the difference between the Dependency Inversion Principle (DIP) and Dependency Injection (DI)?
DIP is what: depend on abstractions, not concretions. DI is how: deliver dependencies via constructor, setter, or container. DI is one way — the most common way — to implement DIP. But you can do DI without DIP (injecting a concrete class) and DIP without DI (using a factory or service locator). Mixing them up is the single biggest source of confusion around this principle.
2. In the WeatherDashboard refactor, who should own the WeatherProvider abstraction — the dashboard module or the OpenWeatherMap module?
The high-level module owns the abstraction. The interface is shaped by what the dashboard needs to ask, not by what OpenWeatherMap happens to offer. If OpenWeatherMap had an interface file, the dashboard could be trapped by its choices — it could never switch to a different provider without touching the interface. When the dashboard owns the abstraction, any provider must conform to its spec.
3. A teammate says: “I added Dependency Inversion by making my class accept PostgresDatabase via its constructor instead of creating it inside the method.”
Is this actually DIP?
Classic misconception. Passing PostgresDatabase via constructor is Dependency Injection (delivering the dependency externally), but not Dependency Inversion (depending on an abstraction). To satisfy DIP, the parameter type should be an abstract interface (OrderRepository), not a concrete class. Your class should not know or care whether it’s talking to Postgres, MySQL, or an in-memory fake.
4. Which of the following are benefits of applying DIP to the WeatherDashboard?
(select all that apply)
DIP gives you testability (use fakes instead of real services), flexibility (swap implementations without changing the high-level module), and extensibility (new providers or caches just implement the interface). It does not make code faster — abstractions add a tiny layer of indirection. The real win is being able to test properly and catch bugs early.
5. A student “applies DIP” by extracting a WeatherProvider ABC that mirrors OpenWeatherMap’s full API — identical method names and parameters, including obscure endpoints the dashboard never calls. Why is this still a DIP violation (in spirit)?
This is the “indirection without inversion” trap. The shape of a true DIP abstraction comes from the high-level module’s needs. If you start by mirroring the concrete class, you’re still tied to its design decisions — swap the provider and you’ll either carry dead interface methods or break something. The tell: if you ask “what does my high-level module actually call?” and the answer is a subset of the ABC you defined, the ABC is too wide.
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:
- Read the class carefully.
- Decide which SOLID principle it violates — or whether it’s actually well-designed.
- 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.
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
Integration — Which Principle? — Knowledge Check
Min. score: 60%1. Snippet 1 — Logger: This class formats messages (adding timestamps, uppercasing) and writes them to a file. Which principle does it violate?
Logger serves two actors: whoever decides the formatting policy (timestamps, uppercasing) and whoever decides the persistence policy (file path, writing strategy). Changing the format (e.g., switching to JSON) means touching the same class as changing the output (e.g., switching to a network socket). Textbook SRP violation.
2. Snippet 2 — DiscountEngine: This class uses an if/elif chain to select discount logic based on a string type. Which principle does it violate?
Every time the business adds a new discount type (e.g., “holiday”, “referral”), someone has to open DiscountEngine and slap on another elif. The class is not closed for modification. An OCP-compliant design would use a strategy pattern or discount registry so new types plug in without touching existing code.
3. Snippet 3 — PremiumUser(User): PremiumUser.free_trial_signup() raises RuntimeError instead of returning a string. Which principle does it violate?
Code that takes a User and calls free_trial_signup() expects a string back. PremiumUser throws an exception instead. It cannot be substituted for User without breaking callers — textbook LSP violation. Fix: either restructure the hierarchy so free_trial_signup() isn’t in the shared interface, or have PremiumUser return something meaningful instead of exploding.
4. Snippet 4 — DatabaseClient ABC: Simple clients must implement cache_query(), replicate(), and shard() even if they only need connect(), query(), and disconnect(). Which principle does it violate?
DatabaseClient bundles six methods into one interface, but most clients only need three (connect, query, disconnect). Forcing simple clients to implement cache_query(), replicate(), and shard() fills their code with useless stubs or raise NotImplementedError. Fix: split into a base DatabaseClient and separate Cacheable, Replicable, Shardable interfaces.
5. Snippet 5 — ReportGenerator: This class directly instantiates PdfRenderer() and SqlConnection() in its constructor. Which principle does it violate?
ReportGenerator is high-level policy (orchestrating report creation), but it hard-codes PdfRenderer and SqlConnection — concrete low-level details. Want HTML rendering or a NoSQL database? You’d have to modify ReportGenerator itself. Fix: define Renderer and DataSource abstractions and inject them via constructor. Then ReportGenerator depends only on abstractions.
6. Snippet 6 — UserValidator: This class accepts a list of ValidationRule objects (an ABC) and runs each one. Does it violate any SOLID principle?
UserValidator is well-designed:
- SRP: One responsibility — orchestrating validation.
- OCP: New rules are added by creating new
ValidationRulesubclasses, not by modifyingUserValidator. - LSP: Any
ValidationRulesubclass can be substituted safely. - ISP:
ValidationRulehas exactly one method — a minimal interface. - DIP:
UserValidatordepends on theValidationRuleabstraction, not onNotEmptyRuleorMaxLengthRuledirectly.
Recognizing well-designed code is just as important as spotting violations — otherwise you’ll “refactor” things that are already correct, which is just creating problems where there were none.
7. Spaced retrieval — back to Step 1. Across all six snippets in this step, the violations had different surface symptoms (an if/elif chain, a raised exception, a fat ABC, a direct new keyword call). But the same two underlying failures from Step 1’s vocabulary show up every time. Which two?
Every SOLID violation you’ve studied reduces to the same two forces from Step 1: things that shouldn’t be connected got tangled together (coupling too high), or things that belong together got scattered across files (cohesion too low). The five principles are five specific diagnoses for one underlying disease. That’s why the capstone insight matters — they’re a system, not a checklist.
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:
- 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.) - 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:
- Define two ABCs at the top of
library_system.py:Logger(with abstractlog(message)) andReportWriter(with abstractwrite(title, rows)). - Make the existing concrete classes extend them:
class FileLogger(Logger): ...andclass CsvReportWriter(ReportWriter): ...— bodies unchanged. - Change
LibrarySystem.__init__()to acceptlogger: Loggerandreport_writer: ReportWriteras constructor parameters, storing them. Do not instantiate them insideLibrarySystem. - Update the
if __name__ == "__main__":demo to build aFileLoggerandCsvReportWriterexplicitly 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:
- Convert the single
Bookdataclass into an ABC with abstract methodscheckout(self) -> strandis_available(self) -> bool. - 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 owncheckout()— move the respective arm of the oldif/elifinto the appropriate class. - Introduce a
BookRepositoryclass that storesBookinstances by ISBN (replaces theself.booksdict insideLibrarySystem). Give itadd_book,get_book, andget_all_books. - Replace
LibrarySystem.checkout()’sif/elifwith 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:
- Create a separate
ReferenceBookclass that does not inherit fromBook. It has abrowse()method instead ofcheckout(). - Add
add_reference_bookandget_reference_bookmethods toBookRepository(reference books live in a parallel dict). - Verify:
CheckoutService(orLibrarySystem.checkout) cannot be accidentally called with aReferenceBook— it simply isn’t aBook. 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 — wasregister_member+get_member)NotificationService(send overdue notices — wassend_overdue_notice)CheckoutService(orchestrate checkouts using the above)InventoryReporter(generate inventory reports — wasgenerate_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.
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())
Capstone — The Real World — Knowledge Check
Min. score: 60%
1. The over-engineered calculator uses IAddable, ISubtractable, IMultipliable, IDivisible interfaces, a CalculationStrategyFactory, and a CalculationResult wrapper — all for 2 + 3. What is the core problem?
SOLID solves real problems: code that changes for multiple reasons, types that break substitutability, interfaces that force unnecessary dependencies. A basic calculator has none of these problems — it’s stable, simple, and never going to change. The four interfaces, factory, and result wrapper add cognitive overhead for zero benefit. SOLID is a tool for managing complexity, not a checklist to apply to everything.
2. Which of the following are signs that you are over-engineering with SOLID? (select all that apply)
These are classic over-engineering red flags: an ABC with one implementation is premature abstraction (wait for the second variant); exploding a simple function across classes is obscured intent; a Factory for single-site construction is unnecessary indirection. Splitting a class with 8 methods serving 3 different actors into 3 focused classes, by contrast, is a valid SRP refactoring.
3. In the refactored library system, CheckoutService calls book.checkout() without checking the book’s type. This works because of a specific SOLID principle. Adding a new book format (e.g., PodcastBook) would require no changes to CheckoutService. Which principle relationship makes this possible?
This is the principles working together: LSP guarantees every Book subtype behaves consistently (no surprises from checkout()), which lets CheckoutService be closed for modification (OCP) when new book types arrive. Without LSP (e.g., if ReferenceBook exploded in checkout()), CheckoutService would need type-checking if/elif to guard against broken subtypes — defeating OCP. The principles reinforce each other.
4. In the original LibrarySystem, the constructor directly created FileLogger() and CsvReportWriter(). After refactoring, these are injected as Logger and ReportWriter abstractions. What does this enable?
DIP enables OCP: By depending on Logger (abstraction) instead of FileLogger (concretion), CheckoutService is open for extension — inject a DatabaseLogger, TestLogger, or whatever future implementation you need — without touching CheckoutService. Same mechanism that makes the book hierarchy work: depend on abstractions, extend by adding new implementations, never modify existing code.
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:
- Re-derive all five principles without re-reading earlier steps. Write each one in your own words.
- Discriminate under interleaving: the quiz below mixes all five principles in random order so you can’t coast on step-by-step recency.
- 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 means — one 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.
"""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}")
Consolidation — Mixed Retrieval
Min. score: 75%
1. Interleaved retrieval. A teammate’s OrderService has a method dispatch(order, carrier_type) with an if/elif chain for "ups", "fedex", and "dhl". Adding "usps" means editing this method. Which principle is primarily violated?
This is the canonical OCP smell — the Switch Statement Smell from Step 4. Each new carrier forces surgery on dispatch(), putting every existing carrier at risk of regression. Fix: a Carrier ABC with one dispatch(order) method, concrete UpsCarrier/FedexCarrier/etc., and a registry/dictionary in OrderService that maps carrier name → Carrier instance.
2. Interleaved retrieval. A StreamingService ABC declares play(), pause(), and download_offline(). A new LivePodcastStreamer subclass overrides download_offline() to raise NotImplementedError("Live streams cannot be downloaded"). Which principle(s) are in play?
(select all that apply)
Both LSP and ISP are violated here, and they’re the same Step-7/Step-8 pairing: an ABC promised capabilities some implementers can’t deliver, so a subclass tries to fake it via NotImplementedError. The structural fix is ISP (split the ABC); the LSP violation is what the ISP violation causes. Recognizing both at once is the mark of someone who has internalized the principles as a connected system, not five separate rules.
3. Vocabulary retrieval (from Step 1). In a well-designed module, coupling should be ____ and **cohesion** should be ____.
Low coupling between modules, high cohesion within each module. That’s the two-sentence summary the whole tutorial was building toward. SOLID is five specific tactics for achieving exactly that target.
4. Discrimination. Your colleague’s PDFReportGenerator.__init__ does self.storage = S3Storage(). They say: “I’m going to fix this with Dependency Injection — I’ll pass S3Storage into the constructor instead.” Is this actually a DIP fix?
The Step 9 trap — DI without DIP. Injecting a concrete S3Storage through the constructor makes the coupling later (at construction time) but doesn’t make it looser. To invert, type the parameter as an abstraction the high-level module owns (a CloudStorage ABC shaped by what PDFReportGenerator needs), so a LocalFileStorage or FakeStorage could be substituted without any change to the high-level class.
5. Evaluate — when NOT to apply SOLID. Which of the following would be over-engineering? Select all that apply. (select all that apply)
Over-engineering examples (options A and B): one-shot scripts and trivial utilities don’t benefit from abstractions — you’ll throw them away or never modify them. Writing ABCs for a disposable migration is pure ceremony.
Legitimate SOLID applications (options C and D): multiple actors (option C) is SRP’s textbook trigger. Unit-testable payment logic (option D) is DIP’s textbook payoff.
The judgment is always: does the domain complexity justify the indirection? For a one-hour script, no. For a payment system that runs for years, absolutely.
6. Synthesis. Which statement best captures how the five principles relate?
The capstone insight. The five principles aren’t a checklist — they’re a system where each one enables the next. This is also why people who learn SOLID as “five rules to memorize” tend to apply it robotically. People who learn it as a chain of design moves apply it judgmentally, which is what lets them also know when to not apply it. You now have the chain. Go write things that last.