OOD, UML & SOLID
Object-oriented design from the two core relationships up to refactoring a god class — all five SOLID principles as runnable before/after Python, climbing beginner→expert.
Object-oriented design is deciding which classes exist, what each is responsible for, and how they connect — so code stays easy to change. This chapter climbs from the two core relationships (inheritance vs composition) through UML, all five SOLID principles, up to a full refactor of a messy class. Every example is runnable before/after Python.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| class / object | a blueprint and an instance of it (from py3). |
| inheritance (is-a) | a subclass extends a base class (Dog is-a Animal). |
| composition (has-a) | an object holds another to use it (Car has-a Engine). |
| interface / ABC | a contract of methods a class promises to implement. |
| SOLID | five principles that keep OO code flexible and testable. |
What you need before starting:
- Python OOP basics — py3 (Functions & OOP).
- Nothing to install; all examples are plain Python.
- Having felt a codebase get hard to change makes SOLID click.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Choose composition vs inheritance and justify it.
- Sketch the two UML diagrams you'll actually use.
- Apply all five SOLID principles as runnable before/after Python.
- Refactor a 'god class' into clean, testable, single-responsibility parts.
code/sd3-ood-solid/ — pure Python / sqlite3, runs with no setup.1 · Inheritance vs composition essential
Here's a mistake almost everyone makes once, and only once. You have a User class. You need an AdminUser, so naturally you subclass it. Then a GuestUser, subclass again. Then PremiumUser, BannedUser, PremiumAdminUser… and suddenly you need a user who is both premium and a guest, and your inheritance tree has no branch for that. You end up copy-pasting methods or building a tangle of five-level-deep subclasses that nobody can follow. The code got rigid the moment you chose "is-a" for something that was really "has-some-traits". That day teaches you the single most important habit in object-oriented design: reach for composition first.
Think about how you'd describe a car to a child. You wouldn't say "a car is a type of engine" — that's nonsense. You'd say "a car has an engine, and wheels, and a stereo." That everyday phrasing is exactly the distinction that matters in code. Inheritance is "is-a": a subclass genuinely is a more specific kind of its parent (a Circle is-a Shape). Composition is "has-a": an object holds another object and delegates work to it (a Car has-a Engine). If you can say "X has a Y" more naturally than "X is a Y", you want composition.
Why does this matter so much in practice? Because inheritance welds two classes together permanently. A subclass inherits everything the parent has — its methods, its fields, its future changes — whether you wanted them or not. Change the base class and every subclass feels it, sometimes catastrophically (you'll watch that happen in the Liskov section below). Composition, by contrast, only depends on a small, chosen slice: the one or two methods you actually call on the collaborator. You can swap that collaborator for a different one at runtime, or inject a fake in a test, and nothing else notices.
The common trap worth naming: beginners reach for inheritance because it feels like it saves typing — "the parent already has these methods, so I'll just inherit them." But you're not buying convenience, you're buying a lifelong marriage to that parent's design. The seasoned move is to hold a collaborator as a field and delegate to it. "Prefer composition over inheritance" is probably the most-repeated line in all of OO design, and this is why: use inheritance only when the "is-a" is real and stable, and even then, keep the hierarchy shallow. With that instinct in mind, here are the two mechanisms side by side.
Two ways to reuse behavior. Inheritance ("is-a") ties you to a base class. Composition ("has-a") holds a collaborator you can swap. The industry rule: prefer composition — it's more flexible and testable.
Compare the two halves split by the dashed line: the left is inheritance, the right is composition.
- On the left, the blue arrows chain
User → AdminUser → …straight down. Every subclass inherits everything above it, and when you need a user who is premium and admin and guest there is no branch for it — the red box — so the tree turns rigid. - On the right,
OrderServicesimply has-a notifier: the blue arrow points at whichever green box (Console, Email, Sms) you inject. The holder never changes when you switch collaborators. - The payoff is the colour contrast: the red "locked to the tree" on the left versus the green "swap the collaborator" on the right — the same reason the industry rule is prefer composition.
In short: Inheritance ties you to a base class forever; composition lets you plug in one of many collaborators and change your mind later.
composition.pyclass ConsoleNotifier:
def send(self, msg): print(f"[console] {msg}")
class EmailNotifier:
def send(self, msg): print(f"[email] {msg}")
class OrderService:
def __init__(self, notifier): # HAS-A notifier, injected
self.notifier = notifier
def place(self, item):
self.notifier.send(f"order placed: {item}")
OrderService(ConsoleNotifier()).place("book")
OrderService(EmailNotifier()).place("pen") # swap behavior, no subclassing
[console] order placed: book
[email] order placed: pen
This lab shows composition — the idea that an object can hold another object and use it, instead of being a special version of it. OrderService does not know or care whether it prints to the console or sends email; it just holds "a notifier" and calls send on it. That held object can be swapped out freely.
- The first two classes,
ConsoleNotifierandEmailNotifier, each define one method —send— that prints the message in its own style. They are interchangeable because they share the same method name and shape. OrderService.__init__(self, notifier)takes a notifier as an argument and stores it onself.notifier. The comment calls this HAS-A: the service has a notifier. Passing a collaborator in like this is called dependency injection.placejust callsself.notifier.send(...)— it never mentions console or email by name, so it works with any notifier you give it.- The last two lines build the same service twice, once with a
ConsoleNotifier()and once with anEmailNotifier(). Swapping behavior needed no new subclass and no change toOrderService— that is the flexibility composition buys you.
What the output means: Two lines print — one tagged [console], one tagged [email] — proving the same OrderService code produced two different behaviors just by changing the object handed to it.
Try this: Write a third class SmsNotifier with its own send, then run OrderService(SmsNotifier()).place("phone"). Notice you did not touch OrderService at all — new behavior, zero edits to existing code.
2 · UML you'll actually use essential
Picture the last time a teammate tried to explain a system to you at a whiteboard. Within thirty seconds they were drawing boxes with arrows between them — "the client calls this service, which talks to this thing, which saves to the database." They didn't write a single line of code. That napkin sketch is UML, whether they called it that or not. UML (Unified Modeling Language) is just the shared vocabulary for those boxes and arrows, so that when you draw "A has-a B" your reader sees the same thing you meant. The whole value is thinking through the design before you've committed it to code that's expensive to unwind.
Here's the honest truth that trips up learners who've seen textbooks: UML has fourteen diagram types, and you will ignore twelve of them for the rest of your career. Trying to memorize the full notation is wasted effort — it's like learning every road sign in the world before driving to the shop. In real engineering, two diagrams do almost all the work, and they answer two different questions.
The class diagram answers "what are the pieces and how are they related?" — a static snapshot of the structure. It draws each class as a box and connects them with relationship arrows: a hollow triangle (▷) for inheritance ("is-a"), a filled diamond (◆) for composition ("has-a"). At a glance you see the shape of your design and, critically, whether your dependencies point at abstractions or concrete classes. The sequence diagram answers a different question — "in what order does this actually happen?" — a moving snapshot of one operation, following a request as it hops from object to object over time. Structure versus flow: that's the split, and most features need you to think about both.
The trap here isn't drawing the wrong diagram — it's over-drawing. Newcomers, once they discover UML, want to diagram everything with perfect notation, and the diagrams rot the moment the code changes. Don't. Treat these as disposable thinking tools: a five-minute sketch on paper or a whiteboard that clarifies which classes you need and who talks to whom, then you throw it away and write the code. Five minutes of sketching routinely saves an hour of building the wrong thing and tearing it out. The sketch below is a sequence-style view of the very OrderService you've been reading — read it as "who calls whom, in order."
Two diagrams earn their keep. A class diagram shows classes, fields, and relationships (inheritance ▷, composition ◆). A sequence diagram shows the order of calls between objects. Sketch these before coding a non-trivial feature — 5 minutes saves an hour.
This is a sequence sketch — a simplified UML-style picture that shows the order in which objects call each other to get one job done. Read it left to right, following the arrows: each box is a component, and each arrow is one component calling the next.
- The boxes are the participants. Each has a name on top (
Client,OrderService,Notifier,Repository) and a one-word role underneath (calls, orchestrates, sends, persists) telling you what that component is for. - The arrows mean "calls" — they show the direction of a request.
ClientcallsOrderService, which in turn drives theNotifierand theRepository. OrderServiceis the orchestrator in the middle: it doesn't do the low-level work itself, it coordinates the pieces that do. This mirrors theOrderServicecode in the labs above.Notifier(sends messages) andRepository(persists, i.e. saves data) are the two collaborators — the same two responsibilities you saw split apart in the Single-Responsibility lab.
In short: The picture is the code's shape at a glance: a Client asks the OrderService to do something, and the OrderService delegates to a Notifier and a Repository. Sketching this before coding shows you which classes you need and who talks to whom.
3 · SOLID — Single Responsibility & Dependency Inversion intermediate
Every engineer has met the class from hell. It's 900 lines long, it's called something vague like Manager or Helper, and touching it feels like defusing a bomb — you fix the email formatting and somehow the database saving breaks, because both live in the same tangled method. Nobody wrote it that way on purpose; it grew one "just add it here" at a time. SOLID is five principles, coined by Robert C. Martin, that exist to stop that class from ever forming. You'll use two of them — the S and the D — almost every single day, so we take those first.
Single Responsibility (S) has a deceptively simple test: a class should have only one reason to change. Think of a Swiss Army knife versus a chef's knife. The Swiss Army knife does ten things adequately and is a nightmare to sharpen; the chef's knife does one thing beautifully. When a class handles both "how we calculate the invoice total" and "how we format it as HTML," it has two bosses — the finance team and the design team — who will each demand changes at different times, and their edits will collide in the same file. Split them, and each class answers to exactly one boss. The everyday tell that you've violated S is the word "and" creeping into your description of what a class does.
Dependency Inversion (D) is the one that unlocks testing, and its name confuses people, so here's the plain version: depend on what a thing promises to do, not on the specific thing that does it. When you flip a light switch you depend on the idea of "a device that turns on when I flip this" — you don't care whether it's an incandescent bulb, an LED, or a smart lamp, and you can swap them freely because the switch only ever knew the contract. In code, that means your OrderService should be typed against an abstract Notifier (the promise "I can send a message"), not the concrete EmailNotifier. Do that and the payoff is enormous: in a test you inject a FakeNotifier that just records the message, verify your logic, and never send a real email.
The common mistake is writing the dependency the "obvious" way: OrderService reaches in and constructs its own EmailNotifier() inside __init__. It works — until you try to test it and discover you can't run the test without a live mail server, or you need SMS instead and have to edit the service. The fix is dependency injection: hand the collaborator in from outside rather than building it inside. S and D are partners — S tells you where to cut a class apart, and D tells you how the resulting pieces should refer to each other (through abstractions) so the seams stay swappable. The lab below shows both working together in one small system.
The two SOLID principles you'll reach for daily. Single Responsibility: one reason to change per class. Dependency Inversion: depend on an abstraction, not a concrete class — which makes code testable (inject a fake).
Two halves, split by the dashed line: the left shows the S cut, the right shows the D connection.
- On the left, the red
Managerbox does two jobs (logic and persist) — the "and" is the smell. The blue arrows split it into two green boxes,OrderServiceandOrderRepository, each with a single reason to change. - On the right,
OrderServicepoints at an abstractNotifier(the amber contract box), not at a concrete class. Below it, two implementations —EmailNotifierandFakeNotifier— both satisfy that contract. - The payoff is the
FakeNotifier: because the service only knows the abstraction, a test injects the fake where email would go and verifies the logic with no real side effect.
In short: S tells you where to cut a class into single-job pieces; D tells you to wire those pieces through an abstraction so you can swap a fake in for a test.
solid_sd.pyfrom abc import ABC, abstractmethod
class Notifier(ABC): # abstraction (D)
@abstractmethod
def send(self, msg: str) -> None: ...
class EmailNotifier(Notifier):
def send(self, msg): print(f"[email] {msg}")
class OrderRepository: # ONE job: persistence (S)
def save(self, order): print(f"saved {order}")
class OrderService: # ONE job: order logic (S)
def __init__(self, repo: OrderRepository, notifier: Notifier):
self.repo, self.notifier = repo, notifier # depends on abstractions (D)
def place(self, order):
self.repo.save(order); self.notifier.send(f"order {order} placed")
OrderService(OrderRepository(), EmailNotifier()).place("A1")
saved A1
[email] order A1 placed
This lab demonstrates the first two SOLID principles together. S — Single Responsibility: each class should have exactly one reason to change, i.e. one job. D — Dependency Inversion: code should depend on an abstraction (a promised set of methods) rather than a specific concrete class, so you can substitute a different implementation later.
Notifier(ABC)is an abstract base class — a contract, not a working class.@abstractmethodmarkssendas a method that any real notifier must provide. The...body means "no code here; subclasses fill it in." This is the abstraction the letter D refers to.EmailNotifier(Notifier)is a concrete class that keeps the promise by actually implementingsend. It is-a Notifier (inheritance), so it can stand in anywhere a Notifier is expected.OrderRepositoryhas exactly ONE job: saving orders (persistence).OrderServicehas exactly ONE job: order logic. Splitting these two concerns apart is the letter S in action — each has a single reason to change.OrderService.__init__receives arepoand anotifierand stores both. Crucially the type hint onnotifieris the abstractNotifier, notEmailNotifier— the service depends on the abstraction, so you could inject a fake notifier in a test. That is the letter D.- The final line wires real parts together —
OrderRepository()andEmailNotifier()— and callsplace("A1").
What the output means: Two lines: saved A1 (the repository did its one job) then [email] order A1 placed (the notifier did its one job). The service coordinated both without knowing their concrete details.
Try this: In a test you could pass a FakeNotifier that just records the message instead of emailing. Because OrderService only depends on the Notifier contract, the swap works with no change to the service — that is why Dependency Inversion makes code testable.
4 · SOLID — Open/Closed, Liskov, Interface Segregation advanced
You know the sinking feeling. A change request comes in — "just add a new payment type" — and you open the code to find a giant if kind == "card" … elif kind == "paypal" … elif … that you now have to edit, retest, and pray you didn't break the four existing branches that were working fine in production. That dread is the smell the remaining three SOLID principles were designed to kill. Open/Closed, Liskov, and Interface Segregation are the "advanced" three not because they're hard, but because you feel their absence most sharply once a system is live and change is risky.
Open/Closed (O) sounds like a paradox — how can code be open and closed at once? The resolution: open to extension, closed to modification. The gold standard is the electrical socket in your wall. You extend what your house can do — a lamp, a charger, a vacuum — by plugging in a new device, never by rewiring the wall. The socket's contract is closed; the things you plug into it are open-ended. In code, you get there through polymorphism: define a contract (Shape.area()), and add a new Triangle by writing a new class, not by editing the total_area function that already works. The dreaded elif chain is the anti-pattern — it forces you to modify old code for every new case.
Liskov Substitution (L) is the one with a real bite, and it's about honesty. Named for Barbara Liskov, it says: anywhere the base class works, a subclass must work too — without surprising the caller. The famous cautionary tale is the Square that inherits from Rectangle. Mathematically a square is a rectangle, so the inheritance feels obviously right — and it's a trap. Because a square must keep its sides equal, setting the width secretly changes the height, which silently breaks any code written for a normal rectangle. The lesson lands hard: inheritance must honor behavior, not just resemble a shape. "Is-a in the dictionary" is not the same as "is-a safely in code."
Interface Segregation (I) is the gentlest of the three: many small, focused contracts beat one fat one. Picture a universal remote with eighty buttons when all you want is volume and channel — you're forced to deal with (and be confused by) capabilities you'll never use. A fat interface does that to your classes: implement Printer and you're suddenly forced to provide fax() and staple() methods that your simple printer can only stub out or throw from. Split the fat interface into small ones (Printer, Fax, Stapler) and each class implements only what it truly honors. The common mistake across all three is treating "it compiles and passes today's tests" as "it's well-designed" — these principles are insurance against the change that arrives next quarter. The labs below make each concrete, including the Square/Rectangle bug you can watch misbehave.
The other three. Open/Closed: add features by adding code, not editing old code. Liskov: a subtype must work anywhere its base does. Interface Segregation: many small interfaces beat one fat one.
total_area; L flags the Square that silently returns 16 where 20 was expected; I breaks one fat machine into focused contracts each class can truly honor.
Three columns, one per principle, separated by dashed lines — scan each top to bottom.
- O (left): the
total_area()box is marked "never edited" in green. New shapes (Circle,Rect, and later a green+ Triangle) plug in from below — you extend by adding a class, not by editing the caller. - L (middle): a
Rectanglereturns 20 as expected, but theSquaresubtype returns 16 — the red "surprise!" box. Same calling code, different answer: the subtype does not substitute safely, which is the Liskov violation. - I (right): one red fat
Machineforcing print+fax+staple splits into three small green contracts, so each class implements only what it can honor.
In short: O = add code don't edit it; L = a subtype must never surprise the caller; I = prefer several small interfaces over one bloated one.
open_closed.pyfrom abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def __init__(self, r): self.r = r
def area(self): return 3.14159 * self.r ** 2
class Rectangle(Shape):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
def total_area(shapes): # NEVER changes when you add a shape
return round(sum(s.area() for s in shapes), 2)
print(total_area([Circle(1), Rectangle(2, 3)])) # add Triangle later, no edits here
9.14
This lab shows the O — Open/Closed principle: software should be open to new features but closed to editing existing, working code. The trick is polymorphism — many classes share one method name, and calling code uses that shared name without knowing which specific class it is holding.
Shape(ABC)declares the contract: every shape must have anareamethod. It is abstract, soShapeitself is never used directly.CircleandRectangleeach subclassShapeand give their ownareaformula. They store their sizes in__init__and compute differently, but expose the same method name.total_area(shapes)loops over the list and callss.area()on each. The comment stresses it never changes when you add a new shape — it only relies on the sharedareamethod, not on which class each item is.- The last line sums a
Circle(1)and aRectangle(2, 3). To support aTrianglelater you would add a newTriangle(Shape)class and change nothing intotal_area— new code, no edits. That is Open/Closed.
What the output means: It prints 9.14 — the circle's area (about 3.14) plus the rectangle's area (6), rounded to two decimals.
Try this: Add a Triangle(Shape) class with area returning 0.5 * base * height, drop one into the list, and re-run. You extended the program without editing total_area — proof the design is closed to modification.
liskov.py# Classic Liskov violation: Square "is-a" Rectangle mathematically, but not behaviorally.
class Rectangle:
def __init__(self, w, h): self._w, self._h = w, h
def set_w(self, w): self._w = w
def set_h(self, h): self._h = h
def area(self): return self._w * self._h
class Square(Rectangle): # BAD: overrides break the base's contract
def set_w(self, w): self._w = self._h = w
def set_h(self, h): self._w = self._h = h
def stretch_and_check(rect):
rect.set_w(5); rect.set_h(4)
return rect.area() # a caller expects 5*4 = 20
print("Rectangle:", stretch_and_check(Rectangle(1, 1))) # 20 (correct)
print("Square: ", stretch_and_check(Square(1, 1))) # 16 — surprise! LSP violated
# Fix: don't inherit; model Square and Rectangle as separate Shapes (Step 3).
Rectangle: 20
Square: 16
This lab shows a famous bug that the L — Liskov Substitution principle warns about. Liskov says: anywhere the base class works, a subclass must work too, without surprising the caller. Here Square inherits from Rectangle because a square "is-a" rectangle in math — but it breaks the base class's behavior, so substitution fails.
Rectanglestores width and height separately. Itsset_wandset_hchange only one side each, andareareturns width times height — exactly what a caller expects.Square(Rectangle)overrides both setters so that setting either side changes both (a square must stay square). This keeps the shape valid but silently breaks the promise the base class made: setting width no longer leaves height alone.stretch_and_check(rect)is written against the base contract: set width to 5, height to 4, expectareato be5 * 4 = 20. It does not know or care whether it got a Rectangle or a Square.- Called with a real
Rectangleit returns20(correct). Called with aSquareit returns16— becauseset_h(4)also reset the width to 4, giving4 * 4. The subtype did not substitute safely: an LSP violation. - The closing comment gives the fix: don't force the inheritance — model
SquareandRectangleas separate shapes (like the Open/Closed lab above).
What the output means: Two lines: Rectangle: 20 (as any caller expects) and Square: 16 — the surprising wrong answer. Same calling code, different result depending on the subtype: that mismatch is the Liskov violation.
Try this: Predict the Square result before running: after set_w(5) both sides are 5, then set_h(4) makes both 4, so area is 16. The lesson: inheritance is only safe when the subtype truly honors the base class's behavior, not just its shape.
5 · Expert — refactor a god class expert
This is the section where everything you've read stops being theory. Somewhere in almost every codebase lives a class that quietly became the center of the universe — a ReportManager that queries the database, formats the HTML, decides who to email, and sends it, all in one 200-line run() method. It didn't start that way. It started innocent, and then every new requirement got bolted onto the nearest available class because that was faster than thinking. Engineers call it a god class: an object that knows too much and does too much, so that changing anything risks breaking everything. Learning to take one apart calmly is the skill that most visibly separates a mid-level engineer from a senior one.
Here's why the god class is so dangerous, in concrete terms. Because it holds three or four responsibilities at once, it has three or four different reasons to change — and every one of those changes happens in the same file, so unrelated concerns keep colliding. You cannot test it in isolation, because to test the formatting you must also stand up a database and a mail server. You cannot reuse any part of it, because the parts are fused. It's the living embodiment of every SOLID violation at once: it breaks S (many responsibilities), it hard-codes concrete dependencies so it breaks D, and it usually can't be safely extended so it breaks O too.
The refactor is not mysterious — it's mechanical, and that's the good news. You name the verbs: list every distinct thing the class does ("it fetches, it formats, it sends"). Each verb becomes its own small class with a single responsibility (that's S). You define a tiny abstract contract for each — Source, Formatter, Sender — so nothing depends on anything concrete (that's D and I together). Finally you keep one thin orchestrator whose only job is to wire the pieces in order: send(format(fetch())). The orchestrator owns no details; it just sequences collaborators handed to it.
The mistake to avoid while doing this is trying to redesign the behavior at the same time as you restructure it — that's how refactors turn into multi-week rewrites that never ship. The discipline is: keep the output byte-for-byte identical and change only the shape. Prove it by running before and after and diffing the result. When you're done, each piece is independently testable (swap in a FakeSender), independently replaceable (a real database Source drops in unchanged), and the terrifying god class has become four boring, obvious classes — which is exactly what you want production code to be. The capstone lab below walks the full before → after.
Put it together. A god class does everything; we split it by responsibility and invert its dependencies so each part is testable in isolation. This is the single most common real-world refactor.
Read left to right, following the "split by verb" arrow from the before state to the after state.
- On the left, the red
ReportManagerdoes fetch, format, and send in one box — three reasons to change stacked in a single class, which is why it's untestable and fragile. - The middle arrow is the move: name the verbs and give each its own class.
- On the right, a thin
ReportServiceorchestrator fans out to three green single-job collaborators —Source,Formatter,Sender— and just sequences them assend(format(fetch())). It owns no details, so each part is swappable and you can inject aFakeSenderin a test.
In short: Turn one do-everything class into a short orchestrator plus one small class per verb — same behavior, but every seam is now testable and replaceable.
refactor.pyfrom abc import ABC, abstractmethod
# ---- BEFORE: one class fetches, formats, AND sends (3 responsibilities) ----
class ReportManagerBad:
def run(self, rows):
html = "<br>".join(str(r) for r in rows) # formatting
# (imagine it also queried the DB and emailed here)
return f"[emailed] {html}"
# ---- AFTER: one responsibility each, dependencies injected ----
class Source(ABC):
@abstractmethod
def fetch(self): ...
class Formatter(ABC):
@abstractmethod
def format(self, rows) -> str: ...
class Sender(ABC):
@abstractmethod
def send(self, body) -> str: ...
class ListSource(Source):
def __init__(self, rows): self.rows = rows
def fetch(self): return self.rows
class HtmlFormatter(Formatter):
def format(self, rows): return "<br>".join(str(r) for r in rows)
class FakeSender(Sender): # trivially testable
def send(self, body): return f"[emailed] {body}"
class ReportService: # orchestrates; owns no details
def __init__(self, src, fmt, sender): self.src, self.fmt, self.sender = src, fmt, sender
def run(self): return self.sender.send(self.fmt.format(self.src.fetch()))
svc = ReportService(ListSource(["a", "b"]), HtmlFormatter(), FakeSender())
print(svc.run()) # same output, now each part is swappable+testable
[emailed] a<br>b
This capstone lab refactors a god class — one class that tries to do everything — into small single-purpose parts. It brings together S (one job per class), D (depend on abstractions), and I — Interface Segregation (many small, focused contracts instead of one fat one). The refactor keeps the exact same output while making every piece swappable and testable.
- BEFORE:
ReportManagerBad.rundoes three things at once — fetches data, formats it into HTML, and "sends" it. Three responsibilities in one method means three different reasons to change it, which is exactly the rigidity SOLID targets. - AFTER starts by defining three tiny abstract contracts:
Source(promisesfetch),Formatter(promisesformat), andSender(promisessend). Splitting one big interface into three small ones is Interface Segregation — nobody depends on methods they don't use. ListSource,HtmlFormatter, andFakeSendereach implement one of those contracts.FakeSender's comment — trivially testable — shows the payoff: it just returns the body instead of really emailing, perfect for a test.ReportServiceis the orchestrator: it receives a source, a formatter, and a sender (all abstractions, injected) and owns no details itself. Itsrunmethod wires them together:send(format(fetch()))— fetch the rows, format them, send the result.- The last two lines inject three concrete parts and call
run(). Swap in a real database source or a real email sender later andReportServicestays untouched.
What the output means: It prints [emailed] a<br>b — the same result the messy ReportManagerBad would produce, but now built from three independently testable, swappable parts.
Try this: Write a CsvFormatter that joins rows with commas, then build the service with it instead of HtmlFormatter. Only the one injected part changes; the orchestrator and everything else stay exactly the same — that is the whole point of the refactor.
Exercise SD3.1 — Refactor your own god class
Context: The skill that separates senior engineers is turning a tangled class into a clean, testable design and being able to draw the before/after so a reviewer sees the dependencies point at abstractions.
Your task: Take a class that fetches, processes, and outputs, split it by Single Responsibility, invert its dependencies so each collaborator is injected, and write a test that swaps a fake for the sender — sketching the class diagram before and after.
Requirements:
- Decompose the class into fetch, process, and output collaborators, one job each
- Inject each collaborator rather than constructing it inside the class
- Depend on abstractions (interfaces) for the collaborators, not concrete classes
- Write a test that substitutes a fake sender and asserts on what it received
- Provide before/after class diagrams where the 'after' arrows point at abstractions
💡 Hint: Start by listing the reasons the original class would change; each reason becomes a collaborator, and the sender abstraction is what lets your test inject a fake.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The first instinct of new OO programmers is to inherit; the seasoned instinct is to compose. Modelling "a car has an engine" instead of "a car is an engine" is the everyday form of "prefer composition over inheritance."
Your task: Model an Engine and a Car where the car holds an engine as a field and delegates start() to it.
Requirements:
- Give
Engineastart()that depends on its kind - Have
Caraccept anEngineinstance (has-a), not subclass it - Delegate
Car.start()to the held engine - Show that swapping in a different engine needs no new subclass, just a different injected object
💡 Hint: Pass the engine into the car's constructor; because the car only calls engine.start(), any engine that offers that method drops straight in.
Show solution
Composition ("has-a") is more flexible than inheritance ("is-a"):
class Engine:
def __init__(self, kind): self.kind = kind
def start(self): return f"{self.kind} engine started"
class Car:
def __init__(self, engine): self.engine = engine # has-a
def start(self): return self.engine.start()
print(Car(Engine("electric")).start()) # electric engine started
print(Car(Engine("diesel")).start()) # diesel engine started
Swapping the engine needs no subclassing — you inject a different Engine. That flexibility is why "prefer composition" is the default advice.
Context: The Single Responsibility Principle says a class should have exactly one reason to change. A class that both computes and formats has two, and the two concerns fight every time either changes.
Your task: Refactor an Invoice that both totals its items and renders itself into two classes — one that owns the totalling, one that owns the rendering.
Requirements:
- Keep the line-item data and the
total()calculation onInvoice - Move rendering into a separate
InvoicePrinterthat takes an invoice - Ensure the printer reads the invoice's data and total rather than recomputing it
- Show that changing the print format cannot break the totalling logic
- Confirm each class now has a single, distinct reason to change
💡 Hint: Ask "what would force this class to change?" — the answer should be one thing per class; the printer depends on the invoice, never the reverse.
Show solution
One class, one responsibility — one reason to change:
class Invoice:
def __init__(self, items): self.items = items # [(name, price, qty)]
def total(self): return sum(p * q for _, p, q in self.items)
class InvoicePrinter:
def render(self, inv):
lines = [f"{n}: {p}x{q}" for n, p, q in inv.items]
return "\n".join(lines + [f"TOTAL: {inv.total()}"])
inv = Invoice([("Widget", 10, 2), ("Gadget", 5, 1)])
print(InvoicePrinter().render(inv))
# Widget: 10x2
# Gadget: 5x1
# TOTAL: 25
Changing the print format no longer risks the totaling logic — the two concerns evolve independently.
Context: Dependency Inversion says high-level policy shouldn't depend on low-level detail; both should depend on an abstraction. In practice that means a service depends on a storage interface and you inject the concrete thing.
Your task: Make a ReportService depend on an abstract Storage interface and run it against an in-memory fake, with no real database involved.
Requirements:
- Define
Storageas an ABC with an abstractsavemethod - Implement an
InMemoryStoragefake that satisfies the interface - Have
ReportServiceaccept aStoragein its constructor (inverted dependency) - Run the service with the fake and inspect what was saved
- Show that a real database storage could drop in unchanged
💡 Hint: The service should name only the abstraction; the caller chooses which concrete storage to pass, which is exactly what makes the service trivially testable.
Show solution
Depend on an abstraction; pass the concrete thing in:
from abc import ABC, abstractmethod
class Storage(ABC):
@abstractmethod
def save(self, key, value): ...
class InMemoryStorage(Storage):
def __init__(self): self.data = {}
def save(self, key, value): self.data[key] = value
class ReportService:
def __init__(self, storage: Storage): # inverted: depends on interface
self.storage = storage
def run(self, name, rows):
self.storage.save(name, f"{len(rows)} rows")
fake = InMemoryStorage()
ReportService(fake).run("daily", [1, 2, 3])
print(fake.data) # {'daily': '3 rows'}
The service is trivially testable with a fake, and a real DB storage drops in unchanged — that's dependency inversion earning its keep.
Context: The Open/Closed Principle says software should be open for extension but closed for modification — you add behavior without editing code that already works. A strategy registry is the classic way to get there.
Your task: Build a registry of discount strategies so that adding a new discount type is done by registering a new function, never by editing checkout or the existing discounts.
Requirements:
- Keep a registry mapping a discount name to its function
- Register discounts with a decorator so each strategy is self-contained
- Have
checkoutlook the strategy up by name and apply it - Add a brand-new discount type without touching
checkoutor the existing strategies - Show the new behavior works while the shipped ones are untouched (no regression risk)
💡 Hint: A dict keyed by name plus a registering decorator is the whole mechanism; the test of success is that checkout's source never changes as discounts grow.
Show solution
Open for extension, closed for modification:
DISCOUNTS = {}
def discount(name):
def reg(fn): DISCOUNTS[name] = fn; return fn
return reg
@discount("none")
def _none(price): return price
@discount("percent10")
def _p10(price): return round(price * 0.9, 2)
def checkout(price, kind="none"):
return DISCOUNTS[kind](price)
print(checkout(100, "percent10")) # 90.0
# Add a NEW discount without touching checkout() or existing ones:
@discount("flat5")
def _flat5(price): return max(0, price - 5)
print(checkout(100, "flat5")) # 95
New behavior arrives as a new registered function; checkout never changes — no risk of breaking the discounts already in production.
Context: Two SOLID principles reinforce each other here. Liskov substitution says a subtype must be usable anywhere its base is; Interface Segregation says clients shouldn't be forced to depend on methods they don't use.
Your task: Demonstrate a Liskov violation where a Square breaks Rectangle's contract, then fix it by segregating a minimal Shape interface that only exposes what clients actually need.
Requirements:
- Show why a Square that shares Rectangle's mutable-width contract violates substitution
- Introduce a minimal
Shapeabstraction exposing onlyarea() - Implement the shapes so none is forced to implement a method it can't honor
- Write a client (e.g.
total_area) that depends solely onarea() - Show the client works across shape types via the minimal interface it truly uses
💡 Hint: Segregate the interface down to the one method the client calls; once nothing depends on set_w, the square can no longer break anyone's contract.
Show solution
Keep contracts honest and interfaces small:
from abc import ABC, abstractmethod
# LSP violation: a Square that mutates both sides breaks Rectangle's contract
class Rectangle:
def __init__(self, w, h): self.w, self.h = w, h
def set_w(self, w): self.w = w
def area(self): return self.w * self.h
# Fix: segregate to a minimal Shape interface; don't force set_w on shapes
class Shape(ABC):
@abstractmethod
def area(self): ...
class Square(Shape):
def __init__(self, side): self.side = side
def area(self): return self.side ** 2
def total_area(shapes): return sum(s.area() for s in shapes)
print(total_area([Rectangle(2, 3), Square(4)])) # 6 + 16 = 22
# Rectangle isn't a Shape subclass here, but both honor area(); duck typing works.
Square is no longer forced to implement set_w it can't honor. Clients depend only on area() — the minimal interface they actually use.
Context: The most common real-world OOD task is untangling a god class — one object that parses input, applies business rules, and persists results. Splitting it along its responsibilities and wiring the parts by composition makes every seam testable.
Your task: Refactor a single class that parses, prices, and persists into three collaborators wired together by an orchestrating service, preserving the original behavior.
Requirements:
- Extract a
Parser, a pricing component, and a repository, each with one job - Have an
OrderServicereceive the three collaborators by composition - Keep
OrderService.processa short orchestration that delegates each step - Show the same end-to-end result the god class produced
- Confirm each collaborator is now independently testable and replaceable
💡 Hint: Name the verbs the god class performed — parse, price, save — and give each its own class; the service just sequences them.
Show solution
Break the monolith along its responsibilities:
class Parser:
def parse(self, line):
name, amt = line.split(",")
return {"name": name, "amt": float(amt)}
class Pricing:
def total(self, records): return sum(r["amt"] for r in records)
class Repo:
def __init__(self): self.saved = []
def save(self, total): self.saved.append(total)
class OrderService: # was the god class
def __init__(self, parser, pricing, repo):
self.parser, self.pricing, self.repo = parser, pricing, repo
def process(self, lines):
records = [self.parser.parse(l) for l in lines]
total = self.pricing.total(records)
self.repo.save(total)
return total
repo = Repo()
svc = OrderService(Parser(), Pricing(), repo)
print(svc.process(["a,10", "b,5"])) # 15.0
print(repo.saved) # [15.0]
Each collaborator is now independently testable and replaceable, and OrderService reads as a short orchestration — the god class is gone.
✓ Checkpoint — you can move on when you can…
- Choose composition over inheritance and justify it.
- Sketch a class and a sequence diagram.
- Apply all five SOLID principles with runnable before/after.
- Refactor a god class into testable, single-responsibility parts.
Knowledge check check yourself
The lesson demonstrates a classic Liskov Substitution violation with Square subclassing Rectangle. Why does substitution fail, and what fix does the lesson recommend?
Show answer
Square overrides the setters so setting one side also changes the other; when caller code sets width to 5 then height to 4 expecting an area of 20, a Square returns 16 because both sides become 4 — surprising the caller and breaking the base class's contract. The fix is to not force the inheritance and instead model Square and Rectangle as separate shapes.According to the lesson, why does depending on an abstraction (Dependency Inversion) rather than a concrete class make code more testable?
Show answer
OrderService depending on the abstract Notifier, not EmailNotifier) will accept any implementation of that contract. In a test you can inject a FakeNotifier that just records the message, with no change to the service, so its behavior can be verified without side effects like real emails.