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
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.
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
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
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).
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
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.
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
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.
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.