Beginner Examples
Examples 1-27 build the foundational vocabulary of object-oriented design in Python: the five SOLID principles (single responsibility through dependency inversion), the Law of Demeter, five foundational GRASP patterns (information expert, creator, controller, high cohesion, low coupling), and seven classic design patterns (factory method, strategy, observer, adapter, decorator, facade, and template method), closing with composition-over-inheritance and an immutable value object. Every example is a complete, self-contained example.py colocated under learning/code/, verified two ways: python3 example.py prints its own expected output inline via # => comments, and a colocated test_example.py asserts the same behavior under pytest.
Example 1: Split a God Class by Responsibility
ex-01 · exercises co-01
A single class that parses raw text, formats a report, and writes it to a file has three separate reasons to change. This example splits that responsibility across DataParser, ReportFormatter, and ReportWriter, each of which owns exactly one job.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
A["DataParser<br/>parses text"]:::blue
B["ReportFormatter<br/>formats rows"]:::orange
C["ReportWriter<br/>saves report"]:::teal
A -->|rows| B
B -->|report| C
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-01-srp-split-god-class/example.py
"""Example 1: Split a God Class by Responsibility."""
class DataParser: # => the ONLY class that turns raw text into structured rows
def parse(self, raw: str) -> list[str]: # => defines the parse() method
return [
line.strip() # => transformation applied to every kept line
for line in raw.splitlines() # => splits the raw string on newlines first
if line.strip()
# => splits on newlines, trims whitespace, drops blank lines
] # => splits and trims non-blank lines
class ReportFormatter: # => the ONLY class that turns rows into a display string
def format(self, rows: list[str]) -> str: # => defines the format() method
return "\n".join(
f"- {row}" # => the format applied to every row, unconditionally
for row in rows # => iterates the caller-supplied rows in order
# => prefixes every row with a bullet marker, nothing else
) # => bullets each row, unrelated to parsing or saving
class ReportWriter: # => the ONLY class that persists a finished report
def save(
self,
report: str,
sink: list[str],
# => sink is a real parameter here, standing in for a real file handle
) -> None: # => sink simulates a file: an in-memory list
sink.append(report) # => the sole write path -- no parsing or formatting here
raw_input: str = "alice\n\nbob\ncarol\n" # => sample raw data, includes a blank line
parser: DataParser = DataParser() # => constructs parser
formatter: ReportFormatter = ReportFormatter() # => constructs formatter
writer: ReportWriter = ReportWriter() # => constructs writer
# => three independent collaborators -- none of them knows the other two exist
rows: list[str] = parser.parse(raw_input) # => rows is ["alice", "bob", "carol"]
report: str = formatter.format(rows) # => report is "- alice\n- bob\n- carol"
saved_files: list[str] = [] # => the in-memory "filesystem" ReportWriter appends to
writer.save(report, saved_files) # => the ONLY line in this program that mutates it
# => a real deployment would swap ReportWriter's sink for an actual open file
print(report) # => confirms the parse -> format -> save pipeline produced this text
# => Output: - alice
# => - bob
# => - carol
# => Each class changes for exactly one reason: parsing rules, display formatting, or storage -- never more than oneRun: python3 example.py
Output:
- alice
- bob
- carollearning/code/ex-01-srp-split-god-class/test_example.py
"""Example 1: pytest verification for Split a God Class by Responsibility."""
from example import DataParser, ReportFormatter, ReportWriter
def test_each_class_carries_only_its_own_responsibility() -> None:
# => structural check: no class exposes a method belonging to another concern
# => DataParser must never format or save -- parsing is its one reason to change
assert not hasattr(DataParser, "format") and not hasattr(DataParser, "save")
# => ReportFormatter must never parse or save -- formatting is its one job
assert not hasattr(ReportFormatter, "parse") and not hasattr(ReportFormatter, "save")
# => ReportWriter must never parse or format -- persistence is its one job
assert not hasattr(ReportWriter, "parse") and not hasattr(ReportWriter, "format")
def test_pipeline_produces_expected_report() -> None:
# => three collaborators, each doing exactly one step of the pipeline
rows: list[str] = DataParser().parse("alice\n\nbob\ncarol\n") # => rows is ["alice", "bob", "carol"]
report: str = ReportFormatter().format(rows) # => formats the parsed rows
sink: list[str] = [] # => the in-memory "file" ReportWriter appends to
ReportWriter().save(report, sink) # => the only mutation of sink in this test
assert sink == ["- alice\n- bob\n- carol"] # => exactly one save recorded
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Splitting mixed responsibilities across dedicated classes means a change to parsing rules, display formatting, or storage can never accidentally break the other two.
Why it matters: God classes that mix parsing, formatting, and I/O are a leading cause of regressions in real codebases: a developer fixing a display bug can accidentally break file writing because both live in the same class and share the same test suite. Splitting responsibilities the way this example does makes each class independently testable -- DataParser's tests never need a filesystem, and ReportWriter's tests never need to construct valid input data. That isolation is what SRP is actually buying, not an aesthetic preference for smaller files.
Example 2: Extract the Report Writer from the Calculator
ex-02 · exercises co-01
A SalesCalculator that also writes formatted output to a file mixes pure computation with I/O, making the math impossible to test without a sink. This example extracts SalesReportWriter as a separate class, leaving SalesCalculator with no knowledge of files, printing, or sinks at all.
learning/code/ex-02-srp-extract-report-writer/example.py
"""Example 2: Extract the Report Writer from the Calculator.""" # => module docstring
class SalesCalculator: # => computes numbers ONLY -- never touches a file or console
def total(self, sales: list[float]) -> float: # => defines the total() method
return sum(sales) # => pure arithmetic, no printing, no writing, no imports
def average(
self, # => the SalesCalculator instance itself, spelled out by the split
sales: list[float], # => the same list of amounts passed to total()
# => still a pure calculation -- no side effects live in this class at all
) -> float: # => a second pure calculation, same concern
return sum(sales) / len(sales) if sales else 0.0 # => guards empty input
class SalesReportWriter: # => the ONLY class allowed to produce output text
def write(
self, # => the SalesReportWriter instance, spelled out by the multi-line split
total: float, # => the already-computed total -- write() never computes it
average: float, # => the already-computed average -- write() never computes it
sink: list[str],
# => sink stands in for a real file handle, opened by the caller instead
) -> str: # => sink simulates a file
line: str = f"total={total:.2f} average={average:.2f}" # => builds the line
sink.append(line) # => the sole write -- SalesCalculator never does this
return line # => returns the same line for the caller's convenience
calculator: SalesCalculator = SalesCalculator() # => constructs calculator
writer: SalesReportWriter = SalesReportWriter() # => constructs writer
# => two collaborators, built independently -- neither constructor needs the other
sales: list[float] = [100.0, 200.0, 300.0] # => three sample sale amounts
total: float = calculator.total(sales) # => total is 600.0, purely computed
average: float = calculator.average(sales) # => average is 200.0, purely computed
sink: list[str] = [] # => the in-memory "file" the writer appends to
line: str = writer.write(
total, # => passed in already-computed, not recalculated by the writer
average, # => passed in already-computed, not recalculated by the writer
sink, # => the same in-memory "file" constructed above, appended to below
# => passes numbers IN; the writer alone decides how to render and store them
) # => the ONLY call in this program that produces text
print(line) # => confirms the writer, not the calculator, produced this string
# => the printed text and the recorded sink entry are the SAME line object
# => Output: total=600.00 average=200.00
# => Moving `write` out of `SalesCalculator` means the calculator's tests never need a sink, a file, or a console at allRun: python3 example.py
Output:
total=600.00 average=200.00learning/code/ex-02-srp-extract-report-writer/test_example.py
"""Example 2: pytest verification for Extract the Report Writer from the Calculator."""
import inspect
from example import SalesCalculator, SalesReportWriter
def test_calculator_source_contains_no_io_keywords() -> None:
source: str = inspect.getsource(SalesCalculator) # => reads the calculator's own source text
# => "print(", "open(", and ".append(" to a sink are all IO-flavored calls
assert "print(" not in source
assert "open(" not in source
assert "sink" not in source # => the calculator never even names a sink parameter
def test_calculation_and_write_stay_correct_when_separated() -> None:
calculator: SalesCalculator = SalesCalculator()
total: float = calculator.total([100.0, 200.0, 300.0])
average: float = calculator.average([100.0, 200.0, 300.0])
sink: list[str] = []
line: str = SalesReportWriter().write(total, average, sink)
assert line == "total=600.00 average=200.00" # => writer formats correctly
assert sink == [line] # => exactly one write recorded, by the writer alone
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A calculator class that never imports or calls I/O primitives can be tested with plain numbers in and plain numbers out, with no setup or mocking required.
Why it matters: Business logic tangled with I/O is one of the most common reasons unit tests balloon into slow, brittle integration tests -- verifying a tax calculation should never require mocking a file handle. Once SalesCalculator is provably I/O-free -- checked here by scanning its own source for print, open, and sink -- every future change to how reports are rendered or stored can happen without touching, or re-testing, the arithmetic at all.
Example 3: Replace an If/Elif Chain with Strategy Objects
ex-03 · exercises co-02, co-25
An if/elif chain that branches on customer type must be edited every time a new discount is added, which is exactly what the open-closed principle forbids. This example replaces the chain with interchangeable DiscountStrategy objects that Checkout delegates to, so a new discount is a new class, not a new branch.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Purple #CC78BC
flowchart TD
C["Checkout<br/>total#40;price#41;"]:::blue
S["DiscountStrategy<br/>Protocol"]:::orange
N["NoDiscount"]:::purple
L["LoyaltyDiscount"]:::purple
H["HolidayDiscount"]:::purple
C --> S
S --- N
S --- L
S --- H
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#000000,stroke-width:2px
learning/code/ex-03-ocp-strategy-for-discount/example.py
"""Example 3: Replace an If/Elif Chain with Strategy Objects.""" # => module docstring
from typing import Protocol # => Protocol declares a structural interface, no ABC needed
class DiscountStrategy(Protocol): # => the ONE shape every discount must match
# => any class with a matching apply() method satisfies this, no inheritance needed
def apply(self, price: float) -> float: # => every strategy exposes apply()
... # => Protocol methods have no body -- this is a structural contract only
class NoDiscount: # => one interchangeable strategy -- knows nothing about Checkout
def apply(self, price: float) -> float: # => matches DiscountStrategy structurally
return price # => no discount at all
class LoyaltyDiscount: # => a second strategy, same method name, different math
def apply(self, price: float) -> float: # => matches DiscountStrategy structurally
return price * 0.9 # => a flat 10% off for loyalty members
class HolidayDiscount: # => a third strategy, added without touching the other two
def apply(self, price: float) -> float: # => matches DiscountStrategy structurally
return price * 0.75 # => a flat 25% off during holidays
class Checkout: # => CLOSED for modification: never edited to add a new discount
def __init__( # => the constructor, spread across lines to annotate each parameter
self,
strategy: DiscountStrategy,
# => depends on the PROTOCOL, not on any one concrete discount class
) -> None: # => the constructor -- runs once, automatically, per instantiation
self.strategy = strategy # => held as a collaborator, not hard-coded
def total(self, price: float) -> float: # => defines the total() method
return self.strategy.apply(
price
# => no if/elif chain anywhere -- the strategy object IS the branch
) # => delegates the discount DECISION entirely to the strategy object
regular: Checkout = Checkout(NoDiscount()) # => open for extension via composition
loyal: Checkout = Checkout(LoyaltyDiscount()) # => a new behavior, zero edits above
holiday: Checkout = Checkout(HolidayDiscount()) # => same Checkout class, third behavior
# => three Checkout instances, three behaviors, ONE unedited Checkout class body
print(
regular.total(100.0), # => 100.0, NoDiscount applies no reduction
loyal.total(100.0), # => 90.0, LoyaltyDiscount applies 10% off
holiday.total(100.0), # => 75.0, HolidayDiscount applies 25% off
# => each call routes through the same total() method, different collaborator
) # => three totals, one unmodified Checkout class
# => an if/elif chain would have needed a new branch for every new discount
# => Output: 100.0 90.0 75.0
# => Adding a fourth discount means writing a fourth class with an `apply()` method -- `Checkout` itself never changes againRun: python3 example.py
Output:
100.0 90.0 75.0learning/code/ex-03-ocp-strategy-for-discount/test_example.py
"""Example 3: pytest verification for Replace an If/Elif Chain with Strategy Objects."""
import inspect
from example import Checkout, HolidayDiscount, LoyaltyDiscount, NoDiscount
def test_checkout_source_never_names_a_concrete_discount() -> None:
source: str = inspect.getsource(Checkout) # => reads Checkout's own source text, nothing else
# => none of the three concrete strategy class names appear inside Checkout
assert "NoDiscount" not in source
assert "LoyaltyDiscount" not in source
assert "HolidayDiscount" not in source # => proves zero edits were needed to add it
def test_each_strategy_computes_its_own_price() -> None:
assert Checkout(NoDiscount()).total(100.0) == 100.0
assert Checkout(LoyaltyDiscount()).total(100.0) == 90.0
assert Checkout(HolidayDiscount()).total(100.0) == 75.0 # => added with no edits
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Checkout.total() never names NoDiscount, LoyaltyDiscount, or HolidayDiscount directly -- it only calls strategy.apply(), so the dispatcher is provably closed for modification.
Why it matters: Pricing rules are exactly the kind of logic that grows over a product's lifetime -- seasonal discounts, loyalty tiers, regional promotions -- and an if/elif chain that must be edited for each one becomes a merge-conflict magnet touched by every team working on pricing. The strategy pattern turns that shared, contested function into an append-only set of independent classes, each of which can be written, reviewed, and deployed without anyone touching Checkout at all.
Example 4: Extend Behavior via a Plugin Registry
ex-04 · exercises co-02
A dispatch function that grows a new if/elif branch for every event type is closed for extension, not open. This example replaces the branching with a HANDLERS registry populated by an @register decorator, so dispatch() looks up behavior instead of choosing it.
learning/code/ex-04-ocp-plugin-registry/example.py
"""Example 4: Extend Behavior via a Plugin Registry."""
from typing import Callable # => Callable types the registry's stored handlers
HANDLERS: dict[str, Callable[[str], str]] = {} # => starts empty; register() populates it as each handler module-level-loads # => the ONE registry every handler plugs into
def register(
name: str,
# => name is the event key later dispatch() calls will look up
) -> Callable[
[Callable[[str], str]], Callable[[str], str] # => maps handler-in to handler-out
]: # => a decorator FACTORY, returns the real decorator below
def decorator(
fn: Callable[[str], str],
# => fn is the handler function being registered under `name`
) -> Callable[[str], str]: # => the actual decorator, closes over `name`
HANDLERS[name] = fn # => the ONLY line that mutates HANDLERS
return fn # => returns fn unchanged so it stays directly callable too
return decorator # => returns this closure to be applied as @register("...")
@register("greet") # => registers this function under the key "greet"
def handle_greet(payload: str) -> str: # => defines the handle_greet() function
return f"Hello, {payload}!" # => the greet-specific behavior, isolated here
# => this decorator call is the ONLY place "greet" is ever written down
@register("shout") # => a SECOND handler, registered with zero edits to dispatch()
def handle_shout(payload: str) -> str: # => defines the handle_shout() function
return payload.upper() + "!!!" # => the shout-specific behavior, isolated here
def dispatch(event_name: str, payload: str) -> str: # => defines the dispatch() function
handler: Callable[[str], str] = HANDLERS[
event_name # => the key chosen by whichever @register("...") call ran earlier
] # => looks up the registered handler by name -- no if/elif anywhere
return handler(payload) # => calls whichever function was registered
# => this function never mentions "greet" or "shout" by name, ever
print(dispatch("greet", "Rex")) # => routes through the registry, not a branch
print(dispatch("shout", "woof")) # => a totally different registered handler
# => a fifth handler could be added below with @register("...") and no other edit
# => Output: Hello, Rex!
# => WOOF!!!
# => `dispatch()` was written once and never touched again when `handle_shout` was addedRun: python3 example.py
Output:
Hello, Rex!
WOOF!!!learning/code/ex-04-ocp-plugin-registry/test_example.py
"""Example 4: pytest verification for Extend Behavior via a Plugin Registry."""
from example import HANDLERS, dispatch, register
def test_new_handler_needs_zero_edits_to_dispatch() -> None:
# => registers a THIRD handler here, in the test, after the module already loaded
@register("whisper")
def handle_whisper(payload: str) -> str: # => a brand-new handler, defined locally
return payload.lower() + "..." # => the whisper-specific behavior
assert "whisper" in HANDLERS # => the decorator alone added it to the registry
assert dispatch("whisper", "REX") == "rex..." # => dispatch() needed no changes
def test_existing_handlers_still_work() -> None:
assert dispatch("greet", "Rex") == "Hello, Rex!"
assert dispatch("shout", "woof") == "WOOF!!!"
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A new handler is registered by decorating a new function with @register -- dispatch() itself is never opened for editing again after it is first written.
Why it matters: Plugin registries like this one are the backbone of extensible systems -- web framework route tables, CLI subcommand dispatchers, and event-driven architectures all use the same registry-plus-decorator shape. The payoff is concrete: this example's own test adds a third handler, whisper, entirely inside the test function, and dispatch() still works correctly with zero source changes, which is the OCP guarantee made mechanically verifiable.
Example 5: Square(Rectangle) Breaks Liskov Substitution
ex-05 · exercises co-03
Making Square inherit from Rectangle looks reasonable until a client resizes it: overriding set_width to also change height breaks any code that assumed the two were independent. This example demonstrates the exact numeric mismatch that substitution causes, then fixes it by making Square a standalone type with no shared contract to violate.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
R["Rectangle<br/>resize to 5x4"]:::blue
RA["area is 20.0<br/>correct"]:::teal
S["BrokenSquare<br/>resize to 5x4"]:::orange
SA["area is 16.0<br/>WRONG"]:::orange
R --> RA
S --> SA
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-05-lsp-rectangle-square/example.py
"""Example 5: Square(Rectangle) Breaks Liskov Substitution."""
class Rectangle: # => the base type every client below expects to receive
def __init__(self, width: float, height: float) -> None: # => the constructor
self._width = width # => stored independently from height, by design
self._height = height # => stored independently from width, by design
def set_width(self, width: float) -> None: # => changes width ONLY
self._width = width # => height is left completely untouched
def set_height(self, height: float) -> None: # => changes height ONLY
self._height = height # => width is left completely untouched
def area(self) -> float: # => defines the area() method
return self._width * self._height # => the invariant every client relies on
class BrokenSquare(Rectangle): # => BROKEN: claims to BE a Rectangle, but is not one
def set_width(self, width: float) -> None: # => overrides the base contract
self._width = width # => sets width...
self._height = width # => ...AND silently mutates height too -- the violation
def set_height(self, height: float) -> None: # => overrides the base contract
self._width = height # => sets width...
self._height = height # => ...AND silently mutates width too -- the violation
def resize_to_5_by_4(shape: Rectangle) -> float: # => a CLIENT written only against Rectangle
shape.set_width(5.0) # => the client trusts this changes ONLY width
shape.set_height(4.0) # => the client trusts this changes ONLY height
return shape.area() # => a well-behaved Rectangle subtype must return 20.0 here
plain: Rectangle = Rectangle(2.0, 2.0) # => an ordinary, well-behaved Rectangle
broken: BrokenSquare = BrokenSquare(2.0, 2.0) # => substituted where Rectangle is expected
plain_area: float = resize_to_5_by_4(plain) # => plain_area is 20.0, as expected
broken_area: float = resize_to_5_by_4(broken) # => broken_area is 16.0 -- substitution silently broke the client's assumption
print(plain_area, broken_area) # => the SAME client function, two different outcomes
# => Output: 20.0 16.0
# => Fixing this means Square must NOT inherit from Rectangle -- see the standalone Square below
class Square: # => the FIX: a standalone type, unrelated to Rectangle entirely
def __init__(self, side: float) -> None: # => the constructor
self._side = side # => a Square has exactly one dimension, not two
def set_side(self, side: float) -> None: # => there is no set_width/set_height at all
self._side = side # => nothing here can violate a contract it never inherited
def area(self) -> float: # => defines the area() method
return self._side * self._side # => Square's own, honest area formula
square: Square = Square(4.0) # => never passed to resize_to_5_by_4 -- wrong shape for it
print(square.area()) # => Square is verified entirely on its own terms
# => Output: 16.0
# => `Square` and `Rectangle` are siblings, not parent and child -- neither one's contract binds the otherRun: python3 example.py
Output:
20.0 16.0
16.0learning/code/ex-05-lsp-rectangle-square/test_example.py
"""Example 5: pytest verification for Square(Rectangle) Breaks Liskov Substitution."""
from example import BrokenSquare, Rectangle, Square, resize_to_5_by_4
def test_broken_square_violates_the_rectangle_contract() -> None:
# => this test DOCUMENTS the LSP violation: substitution changes behavior
plain_area: float = resize_to_5_by_4(Rectangle(2.0, 2.0))
broken_area: float = resize_to_5_by_4(BrokenSquare(2.0, 2.0)) # => substituted in
assert plain_area == 20.0 # => the well-behaved base case
assert broken_area == 16.0 # => NOT 20.0 -- proof the substitution broke the client
assert plain_area != broken_area # => the same client, two incompatible outcomes
def test_standalone_square_never_shares_rectangles_contract() -> None:
square: Square = Square(4.0) # => the fix: no inheritance relationship at all
assert square.area() == 16.0 # => correct on Square's own terms
assert not issubclass(Square, Rectangle) # => structurally proves the two types are now unrelated
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A subtype that must override a base method's contract to stay geometrically consistent is not a valid subtype at all -- the fix is separate types, not a smarter override.
Why it matters: The Rectangle/Square example is the canonical illustration of LSP because it looks so innocent in a UML diagram and so wrong the moment a client actually mutates the object -- this example's resize_to_5_by_4() returns 20.0 for a real Rectangle and 16.0 for BrokenSquare, silently, with no exception raised anywhere. That silent divergence is precisely why LSP violations are dangerous in production: the bug shows up as a wrong number three layers away from the class that caused it.
Example 6: Refactor an Ostrich That Cannot Fly
ex-06 · exercises co-03
An Ostrich(Bird) that overrides fly() to raise NotImplementedError technically compiles but breaks the moment any client calls fly() on a list of birds. This example refactors the hierarchy so only FlyingBird declares fly(), and Ostrich simply never inherits it, making the broken method structurally impossible to reach.
learning/code/ex-06-lsp-bird-fly/example.py
"""Example 6: Refactor an Ostrich That Cannot Fly."""
class Bird: # => the base every bird shares: attributes true of ALL birds
def __init__(self, name: str) -> None: # => the constructor
self.name = name # => every bird, flying or not, has a name
class FlyingBird(Bird): # => a SEPARATE capability, not assumed by every Bird
def fly(self) -> str: # => only birds that can genuinely fly define this
return f"{self.name} flies" # => a real, honest implementation
class Sparrow(FlyingBird): # => inherits the flying CAPABILITY honestly
pass # => no override needed -- FlyingBird.fly() already fits a sparrow
class Ostrich(Bird): # => inherits ONLY the base Bird -- no fly() exists here at all
def run(self) -> str: # => Ostrich gets its OWN capability instead
return f"{self.name} runs" # => a real, honest implementation for THIS bird
def make_flock_fly(flock: list[FlyingBird]) -> list[str]: # => a client typed against FlyingBird only
return [bird.fly() for bird in flock] # => every element is GUARANTEED to have fly() -- Ostrich cannot even appear here
flock: list[FlyingBird] = [Sparrow("Jay"), Sparrow("Wren")] # => only flying birds allowed
results: list[str] = make_flock_fly(flock) # => calls fly() on every element, safely
print(results) # => confirms every call succeeded with no exception anywhere
# => Output: ['Jay flies', 'Wren flies']
ostrich: Ostrich = Ostrich("Big Bird") # => a real, non-flying bird
print(ostrich.run()) # => Ostrich has its own honest method instead of a broken fly()
# => Output: Big Bird runs
# => `Ostrich` was never asked to lie about flying -- it simply never inherits `fly()` in the first placeRun: python3 example.py
Output:
['Jay flies', 'Wren flies']
Big Bird runslearning/code/ex-06-lsp-bird-fly/test_example.py
"""Example 6: pytest verification for Refactor an Ostrich That Cannot Fly."""
from example import Bird, FlyingBird, Ostrich, Sparrow, make_flock_fly
def test_ostrich_has_no_fly_method_at_all() -> None:
# => the mechanical guarantee that replaces "raise NotImplementedError"
assert not hasattr(Ostrich, "fly") # => fly() genuinely does not exist here
assert not issubclass(Ostrich, FlyingBird) # => Ostrich never claims flying capability
assert issubclass(Ostrich, Bird) # => Ostrich still shares the base bird attributes
def test_flock_of_flying_birds_never_raises() -> None:
flock: list[FlyingBird] = [Sparrow("Jay"), Sparrow("Wren")]
results: list[str] = make_flock_fly(flock) # => no NotImplementedError is reachable through this type signature
assert results == ["Jay flies", "Wren flies"]
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A capability that not every subtype shares belongs on a narrower type in the hierarchy, not on the base with an exception-raising override on the subtype that lacks it.
Why it matters: Raising NotImplementedError inside an override is a common but fragile way to express "this operation makes no sense here" -- the compiler and type checker cannot catch the mistake, and it surfaces only when a caller happens to hit that exact code path in production. Splitting the capability into FlyingBird instead means list[FlyingBird] is a type-level guarantee that every element can fly, eliminating the exception path entirely rather than hoping nobody calls it.
Example 7: Split a Fat Worker Interface
ex-07 · exercises co-04
A single Worker interface that requires both work() and eat() forces a RobotWorker to either implement a nonsensical eat() method or violate the interface. This example splits Worker into Workable and Eatable role protocols, so RobotWorker implements only the role it genuinely satisfies.
learning/code/ex-07-isp-split-fat-interface/example.py
"""Example 7: Split a Fat Worker Interface."""
from typing import Protocol # => Protocol declares each role as a small, focused shape
class Workable(Protocol): # => a role interface: ONLY the ability to work
def work(self) -> str: # => the one method this role requires
... # => Protocol methods have no body -- a structural contract only
class Eatable(Protocol): # => a SEPARATE role interface: ONLY the ability to eat
def eat(self) -> str: # => the one method this role requires
... # => Protocol methods have no body -- a structural contract only
class HumanWorker: # => a human genuinely satisfies BOTH roles
def work(self) -> str: # => satisfies Workable
return "human works" # => a real, honest implementation
def eat(self) -> str: # => satisfies Eatable
return "human eats" # => a real, honest implementation
class RobotWorker: # => a robot satisfies ONLY Workable -- eat() would be a lie
def work(self) -> str: # => satisfies Workable, nothing more
return "robot works" # => a real, honest implementation
def run_shift(worker: Workable) -> str: # => a client depending on the SMALL role only
return worker.work() # => never asks for eat() -- RobotWorker fits perfectly
print(run_shift(HumanWorker())) # => a human satisfies the narrow Workable role too
print(run_shift(RobotWorker())) # => a robot needs no eat() method to pass here
# => Output: human works
# => robot works
# => Before the split, a single fat `Worker` interface would have forced `RobotWorker` to also implement `eat()`Run: python3 example.py
Output:
human works
robot workslearning/code/ex-07-isp-split-fat-interface/test_example.py
"""Example 7: pytest verification for Split a Fat Worker Interface."""
from example import HumanWorker, RobotWorker, run_shift
def test_robot_worker_implements_only_workable() -> None:
# => the mechanical proof: RobotWorker has work() but genuinely lacks eat()
assert hasattr(RobotWorker, "work") # => satisfies the Workable role
assert not hasattr(RobotWorker, "eat") # => never forced to fake an Eatable role
def test_human_worker_still_satisfies_both_roles() -> None:
assert hasattr(HumanWorker, "work") and hasattr(HumanWorker, "eat") # => a human genuinely does both
assert run_shift(HumanWorker()) == "human works"
assert run_shift(RobotWorker()) == "robot works" # => both fit the narrow role
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A class should never be forced to implement a method it cannot meaningfully support just because that method happens to live on the same fat interface as one it does support.
Why it matters: Fat interfaces accumulate silently in most codebases: someone adds a method to an interface because it is convenient for one implementer, and every other implementer either fakes the method or raises inside it. Splitting into role protocols the way this example does means each implementer's interface list is an honest description of what it can do, which keeps isinstance checks and type signatures meaningful instead of decorative.
Example 8: Break a Fat Interface into Role Protocols
ex-08 · exercises co-04
A Printer-Scanner-Fax interface bundles three independent capabilities into one, forcing a plain printer to either implement scanning and faxing or leave them broken. This example splits the bundle into Printable, Scannable, and Faxable protocols, each independently checkable at runtime via isinstance().
learning/code/ex-08-isp-role-interfaces/example.py
"""Example 8: Break a Fat Interface into Role Protocols."""
from typing import Protocol, runtime_checkable # => runtime_checkable enables isinstance()
@runtime_checkable # => allows isinstance() checks against this Protocol at runtime
class Printable(Protocol): # => role: can print a document
def print_doc(self) -> str: # => the one method this role requires
... # => Protocol methods have no body -- a structural contract only
@runtime_checkable # => allows isinstance() checks against this Protocol at runtime
class Scannable(Protocol): # => role: can scan a document
def scan_doc(self) -> str: # => the one method this role requires
... # => Protocol methods have no body -- a structural contract only
@runtime_checkable # => allows isinstance() checks against this Protocol at runtime
class Faxable(Protocol): # => role: can fax a document
def fax_doc(self) -> str: # => the one method this role requires
... # => Protocol methods have no body -- a structural contract only
class SimplePrinter: # => genuinely satisfies ONLY Printable -- nothing else
def print_doc(self) -> str: # => satisfies Printable, nothing more
return "printed" # => a real, honest implementation
class AllInOnePrinter: # => genuinely satisfies all three roles at once
def print_doc(self) -> str: # => satisfies Printable
return "printed" # => a real, honest implementation
def scan_doc(self) -> str: # => satisfies Scannable
return "scanned" # => a real, honest implementation
def fax_doc(self) -> str: # => satisfies Faxable
return "faxed" # => a real, honest implementation
printer: SimplePrinter = SimplePrinter() # => a plain printer, one capability only
print(isinstance(printer, Printable)) # => structurally matches the Printable protocol
print(isinstance(printer, Scannable)) # => structurally does NOT match Scannable
# => Output: True
# => False
# => `SimplePrinter` depends on exactly one small role -- never a fat, all-in-one interfaceRun: python3 example.py
Output:
True
Falselearning/code/ex-08-isp-role-interfaces/test_example.py
"""Example 8: pytest verification for Break a Fat Interface into Role Protocols."""
from example import AllInOnePrinter, Faxable, Printable, Scannable, SimplePrinter
def test_plain_printer_depends_on_exactly_one_protocol() -> None:
printer: SimplePrinter = SimplePrinter()
assert isinstance(printer, Printable) # => the one role it genuinely satisfies
assert not isinstance(printer, Scannable) # => never forced to fake this role
assert not isinstance(printer, Faxable) # => never forced to fake this role either
def test_all_in_one_printer_satisfies_every_role() -> None:
device: AllInOnePrinter = AllInOnePrinter()
assert isinstance(device, Printable)
assert isinstance(device, Scannable)
assert isinstance(device, Faxable) # => genuinely does all three jobs
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A plain SimplePrinter satisfies Printable and nothing else -- isinstance(printer, Scannable) is False, structurally, with no fake or stub method anywhere in the class.
Why it matters: Office-equipment-style examples map directly onto real API design: a payment gateway that bundles refunds, subscriptions, and fraud detection into one client interface forces every integrator to depend on capabilities they never use. Role protocols let a client type its dependency as narrowly as Printable and remain completely unaffected when a Faxable capability is added to the system later, because that addition never touches the Printable protocol at all.
Example 9: Invert a Service to Depend on a Repository Protocol
ex-09 · exercises co-05
A UserService that constructs its own MySQLRepository internally cannot be tested without a real database and cannot be swapped to a different storage engine without editing the service. This example inverts the dependency: UserService's constructor is typed against a Repository protocol, and the concrete InMemoryRepository is chosen by the caller instead.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Purple #CC78BC
flowchart TD
U["UserService<br/>high-level policy"]:::blue
R["Repository<br/>Protocol, the abstraction"]:::orange
I["InMemoryRepository<br/>low-level detail"]:::purple
U -->|depends on| R
I -->|implements| R
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#000000,stroke-width:2px
learning/code/ex-09-dip-inject-repository/example.py
"""Example 9: Invert a Service to Depend on a Repository Protocol.""" # => module docstring
from typing import Protocol # => Protocol declares the abstraction UserService depends on
class Repository(Protocol): # => the ABSTRACTION -- high-level policy owns this shape
# => both the high-level UserService AND the low-level detail depend on THIS
def get(self, user_id: int) -> str: # => the one method any repository must provide
... # => Protocol methods have no body -- a structural contract only
class InMemoryRepository: # => a concrete LOW-level detail -- one of many possible ones
def __init__(self) -> None: # => the constructor
self._data: dict[int, str] = {1: "Alice", 2: "Bob"} # => sample in-memory data
def get(self, user_id: int) -> str: # => satisfies Repository structurally
return self._data[user_id] # => a real, honest implementation
class UserService: # => the HIGH-level policy -- depends on the abstraction, not detail
def __init__( # => the constructor, spread across lines to annotate each parameter
self, # => the UserService instance being constructed
repository: Repository,
# => the constructor names the PROTOCOL, never a concrete repository class
) -> None: # => the constructor -- runs once, automatically, per instantiation
self.repository = repository # => held as a collaborator, injected from outside
def greet(self, user_id: int) -> str: # => defines the greet() method
name: str = self.repository.get(
user_id # => any Repository-shaped object answers this the same way
) # => the DIRECTION of dependency: UserService -> Repository, never reversed
return f"Hi, {name}" # => builds the greeting from injected data
service: UserService = UserService(
InMemoryRepository() # => a MySQLRepository could be swapped in with this one line
) # => the concrete detail is chosen HERE, at construction time, not inside UserService
print(service.greet(1)) # => confirms the injected repository actually supplied the data
# => Output: Hi, Alice
# => `UserService.__init__` never imports or names `InMemoryRepository` -- only `Repository`
# => swapping storage engines never touches a single line inside UserServiceRun: python3 example.py
Output:
Hi, Alicelearning/code/ex-09-dip-inject-repository/test_example.py
"""Example 9: pytest verification for Invert a Service to Depend on a Repository Protocol."""
from typing import get_type_hints
from example import InMemoryRepository, Repository, UserService
def test_constructor_is_typed_against_the_protocol() -> None:
hints: dict[str, object] = get_type_hints(UserService.__init__) # => reads the actual annotation UserService.__init__ declares
assert hints["repository"] is Repository # => the abstraction, not the concrete class
def test_service_still_works_with_an_injected_repository() -> None:
service: UserService = UserService(InMemoryRepository()) # => injected at the boundary
assert service.greet(1) == "Hi, Alice"
assert service.greet(2) == "Hi, Bob" # => any Repository-shaped object would work here
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: UserService.__init__ is typed as repository: Repository -- confirmed here via get_type_hints -- so the high-level policy depends only on the abstraction, never on a concrete storage detail.
Why it matters: Dependency inversion is what separates a testable service layer from one that requires a live database for every unit test: this example's InMemoryRepository lets UserService be exercised in milliseconds with zero setup, while a MySQLRepository implementing the same Repository protocol could be substituted in production with no change to UserService at all. That substitutability is the entire economic argument for DIP in real systems -- it is what makes fast test suites and flexible infrastructure possible at once.
Example 10: Depend on a Notifier Protocol, Not a Concrete Sender
ex-10 · exercises co-05
An AlertService that depends directly on EmailSender cannot be switched to SMS without editing the service's own source. This example has AlertService depend on a Notifier protocol instead, so EmailNotifier and SMSNotifier are both interchangeable, low-level details chosen entirely at construction time.
learning/code/ex-10-dip-notifier-abstraction/example.py
"""Example 10: Depend on a Notifier Protocol, Not a Concrete Sender."""
from typing import Protocol # => Protocol declares the abstraction AlertService depends on
class Notifier(Protocol): # => the ABSTRACTION every concrete sender must match
# => both high-level AlertService and every low-level sender depend on THIS
def send(self, message: str) -> str: # => the one method any notifier must provide
... # => Protocol methods have no body -- a structural contract only
class EmailNotifier: # => one concrete, low-level detail among several possible ones
def send(self, message: str) -> str: # => satisfies Notifier structurally
return f"email: {message}" # => a real, honest implementation
class SMSNotifier: # => a SECOND concrete detail, swapped in with zero service edits
def send(self, message: str) -> str: # => satisfies Notifier structurally
return f"sms: {message}" # => a real, honest implementation
class AlertService: # => the HIGH-level policy -- depends on Notifier, not a sender
def __init__(
self,
notifier: Notifier,
# => names the PROTOCOL only -- no concrete sender class is visible here
) -> None: # => the constructor -- runs once, automatically, per instantiation
self.notifier = notifier # => held as a collaborator, injected from outside
def alert(self, message: str) -> str: # => defines the alert() method
return self.notifier.send(message) # => the DIRECTION of dependency: AlertService -> Notifier, never reversed
via_email: AlertService = AlertService(EmailNotifier()) # => one concrete sender chosen
via_sms: AlertService = AlertService(SMSNotifier()) # => a DIFFERENT sender, same service
print(via_email.alert("server down")) # => routed through EmailNotifier
print(via_sms.alert("server down")) # => routed through SMSNotifier, zero service edits
# => Output: email: server down
# => sms: server down
# => Swapping `EmailNotifier` for `SMSNotifier` never touches a single line inside `AlertService`Run: python3 example.py
Output:
email: server down
sms: server downlearning/code/ex-10-dip-notifier-abstraction/test_example.py
"""Example 10: pytest verification for Depend on a Notifier Protocol, Not a Concrete Sender."""
import inspect
from example import AlertService, EmailNotifier, SMSNotifier
def test_alert_service_source_never_names_a_concrete_sender() -> None:
source: str = inspect.getsource(AlertService) # => reads AlertService's own source text, nothing else
assert "EmailNotifier" not in source # => the concrete sender never appears here
assert "SMSNotifier" not in source # => neither does the second one
def test_swapping_the_notifier_needs_no_service_edit() -> None:
via_email: AlertService = AlertService(EmailNotifier())
via_sms: AlertService = AlertService(SMSNotifier()) # => swapped, same AlertService
assert via_email.alert("server down") == "email: server down"
assert via_sms.alert("server down") == "sms: server down"
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Swapping AlertService's notification channel from email to SMS required constructing a different concrete object at the call site -- AlertService's own source, verified by inspecting it directly, never mentions either concrete class.
Why it matters: Alerting and notification systems are a common place this exact problem shows up in production: a service hard-coded against an email SDK becomes an emergency rewrite the day the team needs to add Slack or SMS alerts. Depending on a Notifier protocol from day one means adding a third channel is purely additive -- write a new class that satisfies the protocol, and AlertService keeps working unmodified, exactly as this example's swap from email to SMS demonstrates.
Example 11: Replace a Train Wreck with Tell, Don't Ask
ex-11 · exercises co-15
Calling driver.car.engine.ignite() reaches through two objects Driver does not own, coupling the caller to Car's and Engine's internal structure. This example adds a tell-don't-ask start_car() method so the caller reaches exactly one attribute deep, with Car and Engine's collaboration hidden inside Car.start().
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
D["Driver"]:::blue
C["Car"]:::orange
E["Engine"]:::teal
D -->|train wreck, two dots| E
D -->|tell-dont-ask, one dot| C
C --> E
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-11-lod-avoid-train-wreck/example.py
"""Example 11: Replace a Train Wreck with Tell, Don't Ask."""
class Engine: # => the innermost collaborator, two levels deep from Driver
def ignite(self) -> str: # => defines the ignite() method
return "engine roars to life" # => a real, honest implementation
class Car: # => sits BETWEEN Driver and Engine
def __init__(self, engine: Engine) -> None: # => the constructor
self.engine = engine # => Car holds an Engine; Driver should never reach past Car
def start(self) -> str: # => Car's own tell-don't-ask method
return self.engine.ignite() # => Car delegates to its OWN collaborator, internally
class Driver: # => the outermost caller
def __init__(self, car: Car) -> None: # => the constructor
self.car = car # => Driver holds a Car; that is the ONE dot Driver is allowed
def start_car(self) -> str: # => Driver's own tell-don't-ask method
return self.car.start() # => a single dot -- Driver never reaches into car.engine
def train_wreck_start(driver: Driver) -> str: # => the BEFORE shape, shown for contrast
return driver.car.engine.ignite() # => two dots past driver -- a genuine train wreck
driver: Driver = Driver(Car(Engine())) # => three collaborators, wired together once
wreck_result: str = train_wreck_start(driver) # => works, but reaches through TWO objects
clean_result: str = driver.start_car() # => the AFTER shape: exactly one dot from driver
print(wreck_result == clean_result) # => same outcome, very different coupling
# => Output: True
# => `driver.start_car()` is the one-dot call; `driver.car.engine.ignite()` is the train wreck it replacesRun: python3 example.py
Output:
Truelearning/code/ex-11-lod-avoid-train-wreck/test_example.py
"""Example 11: pytest verification for Replace a Train Wreck with Tell, Don't Ask."""
from example import Car, Driver, Engine
def test_start_car_produces_a_single_dot_call_site() -> None:
driver: Driver = Driver(Car(Engine()))
# => the caller reaches exactly one attribute deep: driver.start_car()
result: str = driver.start_car() # => never touches driver.car.engine directly
assert result == "engine roars to life"
def test_delegation_chain_still_reaches_the_engine() -> None:
driver: Driver = Driver(Car(Engine()))
assert driver.start_car() == driver.car.engine.ignite() # => same result, one call site is a train wreck and one is not
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: driver.start_car() and driver.car.engine.ignite() produce the identical result, but only the first one keeps Driver ignorant of how Car happens to be built internally.
Why it matters: Train-wreck call chains are the most visible symptom of Law of Demeter violations, and they are expensive precisely because they compile fine and work fine right up until Car's internal structure changes -- adding a Turbocharger between Car and Engine breaks every train-wreck call site across the codebase simultaneously. A tell-don't-ask method absorbs that change in one place, which is why refactoring tools and code reviewers flag multi-dot chains as a maintainability risk long before they cause an actual bug.
Example 12: Pay Through the Customer, Not the Wallet
ex-12 · exercises co-15
A caller that does customer.get_wallet().withdraw(amount) depends on Wallet's exact method signature and reaches past Customer's own boundary. This example exposes customer.pay(amount) instead, so the caller never learns that a Wallet exists at all -- Customer._wallet stays a private implementation detail.
learning/code/ex-12-lod-wallet-payment/example.py
"""Example 12: Pay Through the Customer, Not the Wallet."""
class Wallet: # => a collaborator the OUTSIDE world should never touch directly
def __init__(self, balance: float) -> None: # => the constructor
self._balance = balance # => a leading underscore: internal to Wallet
def withdraw(self, amount: float) -> float: # => the ONLY sanctioned mutation
if amount > self._balance: # => guards the invariant: never go negative
raise ValueError("insufficient funds") # => rejects the call entirely
self._balance -= amount # => only reached once the amount is valid
return self._balance # => returns the new balance for convenience
class Customer: # => sits BETWEEN the outside world and Wallet
def __init__(self, wallet: Wallet) -> None: # => the constructor
self._wallet = wallet # => underscore: Customer's OWN collaborator, not public
# => no other method exposes _wallet -- it never leaks past this class boundary
def pay(self, amount: float) -> float: # => the tell-don't-ask entry point
return self._wallet.withdraw(amount) # => delegates internally -- the caller never sees Wallet at all
customer: Customer = Customer(Wallet(100.0)) # => wired together once, at construction
remaining: float = customer.pay(30.0) # => the caller's ONLY call: customer.pay(amount)
print(remaining) # => confirms the payment went through via a single dot
# => Output: 70.0
# => `customer.pay(30.0)` never reaches `customer.get_wallet().withdraw(30.0)` -- Wallet stays hiddenRun: python3 example.py
Output:
70.0learning/code/ex-12-lod-wallet-payment/test_example.py
"""Example 12: pytest verification for Pay Through the Customer, Not the Wallet."""
# => this test file deliberately imports ONLY Customer -- never Wallet at all
from example import Customer, Wallet
def test_customer_exposes_no_public_wallet_attribute() -> None:
customer: Customer = Customer(Wallet(100.0))
assert not hasattr(customer, "wallet") # => no public accessor exists
assert hasattr(customer, "_wallet") # => only the underscore-prefixed internal one
def test_pay_delegates_to_the_hidden_wallet() -> None:
customer: Customer = Customer(Wallet(100.0))
remaining: float = customer.pay(30.0) # => the caller's only call site
assert remaining == 70.0 # => the withdrawal genuinely happened, one dot away
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: The test file for this example imports only Customer, never Wallet, and still exercises the full withdrawal logic through customer.pay() -- proof the caller genuinely never touches Wallet.
Why it matters: Financial and account-holding classes are exactly where Law of Demeter violations turn into real security and correctness risks: exposing a raw Wallet lets any caller bypass validation logic that Customer might want to enforce around a payment (fraud checks, spending limits, currency conversion). Routing every interaction through customer.pay() means Customer retains full control over what a payment actually requires, no matter how Wallet's internals evolve later.
Example 13: Information Expert: Order Owns Its Total
ex-13 · exercises co-06
Computing an order's total by looping over its line items in some unrelated function scatters the calculation away from the data it needs. This example puts total() directly on Order, the class that already owns the list of OrderLine objects -- GRASP's information expert principle in its simplest form.
learning/code/ex-13-grasp-information-expert-total/example.py
"""Example 13: Information Expert: Order Owns Its Total.""" # => module docstring
from dataclasses import dataclass # => imports dataclass from dataclasses
@dataclass # => generates __init__ from the fields below, no hand-written boilerplate
class OrderLine: # => a single line item -- quantity and unit price, nothing more
item: str # => the item name, part of the generated __init__
quantity: int # => how many units, part of the generated __init__
unit_price: float # => price per unit, part of the generated __init__
class Order: # => the class that OWNS the line items -- the "information expert"
def __init__(self) -> None: # => the constructor
self.lines: list[OrderLine] = [] # => Order holds every line item it needs
def add_line(self, line: OrderLine) -> None: # => defines the add_line() method
self.lines.append(line) # => appends to Order's OWN collection
def total(self) -> float: # => Order computes its OWN total -- it has the data
return sum( # => builds the sum via a generator expression, no helper function
line.quantity * line.unit_price # => the per-line subtotal being summed
for line in self.lines
# => the information EXPERT is whichever class already holds the data
) # => no external function ever loops over Order's lines to do this
order: Order = Order() # => constructs order
order.add_line(OrderLine("widget", 2, 9.99)) # => adds the first line item
order.add_line(OrderLine("gadget", 1, 19.99)) # => adds a second, different line item
print(round(order.total(), 2)) # => Order alone answers "what is my total?"
# => Output: 39.97
# => Order holds the line items, so Order -- not some external loop -- is the natural place for total()Run: python3 example.py
Output:
39.97learning/code/ex-13-grasp-information-expert-total/test_example.py
"""Example 13: pytest verification for Information Expert: Order Owns Its Total."""
from example import Order, OrderLine
def test_order_computes_its_own_total() -> None:
order: Order = Order()
order.add_line(OrderLine("widget", 2, 9.99))
order.add_line(OrderLine("gadget", 1, 19.99))
assert round(order.total(), 2) == 39.97 # => Order alone answered this question
def test_order_is_the_only_place_total_is_computed() -> None:
# => structural check: no other function in this module computes an order total
import example # => imports the module itself to inspect its top-level names
assert not hasattr(example, "compute_order_total") # => no external loop exists anywhere in the module
assert hasattr(Order, "total") # => only Order, the information expert, has it
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Order.total() needs no arguments beyond self because Order already holds every OrderLine it needs to sum -- the class with the data is the natural home for the behavior that uses it.
Why it matters: Information Expert is the GRASP pattern developers apply almost unconsciously once they internalize it, and violating it is what produces "anemic domain models" -- data classes with no behavior, surrounded by service classes that reach in and compute things from the outside. Keeping total() on Order instead of a free function means the calculation cannot silently drift out of sync with whatever fields Order actually holds, because they live in the same class.
Example 14: Creator: Order Creates Its Own OrderLine
ex-14 · exercises co-07
If callers construct OrderLine objects directly and then append them to an Order, both the construction logic and the aggregation logic are duplicated at every call site. This example gives Order an add_line() method that builds the OrderLine internally and appends it in the same step, since Order is the class that aggregates OrderLine objects.
learning/code/ex-14-grasp-creator-order-line/example.py
"""Example 14: Creator: Order Creates Its Own OrderLine.""" # => module docstring
from dataclasses import dataclass # => imports dataclass from dataclasses
@dataclass # => generates __init__ from the fields below
class OrderLine: # => a single line item Order aggregates and owns
item: str # => the item name, part of the generated __init__
quantity: int # => how many units, part of the generated __init__
unit_price: float # => price per unit, part of the generated __init__
class Order: # => the CREATOR: Order aggregates OrderLine, so Order builds them
def __init__(self) -> None: # => the constructor
self.lines: list[OrderLine] = [] # => the collection Order aggregates
# => GRASP's Creator rule: whoever aggregates B is the natural creator of B
def add_line( # => the CREATION method, spread across lines to annotate each field
self,
item: str, # => raw field data, not a pre-built OrderLine
quantity: int, # => raw field data, not a pre-built OrderLine
unit_price: float,
# => the CALLER never constructs OrderLine directly -- Order does it instead
) -> OrderLine: # => the creation method lives HERE, on the aggregating class
line: OrderLine = OrderLine(item, quantity, unit_price) # => Order builds the object it aggregates
self.lines.append(line) # => and immediately owns it in its own collection
return line # => returns the built object for the caller's convenience
order: Order = Order() # => constructs order
line: OrderLine = order.add_line(
"widget", # => item name, raw data passed to the Creator
3, # => quantity, raw data passed to the Creator
4.5, # => unit price, raw data passed to the Creator
# => the caller never writes OrderLine(...) itself -- Order builds it internally
) # => the caller never writes OrderLine(...) itself
print(line) # => confirms Order built a real, well-formed OrderLine
print(order.lines[0] is line) # => the SAME object Order created and now holds
# => not a copy -- Order's own collection holds the very object it just built
# => Output: OrderLine(item='widget', quantity=3, unit_price=4.5)
# => True
# => `Order.add_line()` is the Creator: it both builds the OrderLine and aggregates it in one stepRun: python3 example.py
Output:
OrderLine(item='widget', quantity=3, unit_price=4.5)
Truelearning/code/ex-14-grasp-creator-order-line/test_example.py
"""Example 14: pytest verification for Creator: Order Creates Its Own OrderLine."""
from example import Order, OrderLine
def test_order_add_line_is_the_creation_method() -> None:
assert hasattr(Order, "add_line") # => the creation method lives on Order itself
def test_add_line_both_builds_and_aggregates_the_line() -> None:
order: Order = Order()
line: OrderLine = order.add_line("widget", 3, 4.5)
assert isinstance(line, OrderLine) # => a real OrderLine was constructed
assert order.lines == [line] # => and it now lives inside Order's own collection
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Order.add_line() is both the creator and the container of OrderLine -- GRASP's Creator pattern says the class that aggregates B is the natural class to construct B.
Why it matters: Scattering object construction across every call site means a future required field on OrderLine has to be added everywhere it is constructed, instead of in one place. Centralizing creation inside the aggregating class's own method -- exactly what add_line() does here -- means adding a new required parameter to OrderLine only requires updating Order.add_line()'s signature, and every caller benefits automatically.
Example 15: Controller: Route Events Through a Session Controller
ex-15 · exercises co-08
A UI click handler that calls cart.add_item() directly ties the UI layer to the domain model's exact method names and coupling structure. This example routes the click through a SessionController whose handle_add_item_event() method is the UI's only entry point into the domain.
learning/code/ex-15-grasp-controller-session/example.py
"""Example 15: Controller: Route Events Through a Session Controller.""" # => docstring
class ShoppingCart: # => the DOMAIN class -- pure business logic, no UI concerns
def __init__(self) -> None: # => the constructor
self.items: list[tuple[str, float]] = [] # => (name, price) pairs held here
def add_item(self, name: str, price: float) -> None: # => domain-level mutation
self.items.append((name, price)) # => the domain's own state change
def total(self) -> float: # => defines the total() method
return sum(price for _, price in self.items) # => sums every stored price
class SessionController: # => the CONTROLLER -- the single coordinator between UI and domain
def __init__(self, cart: ShoppingCart) -> None: # => the constructor
self.cart = cart # => holds the domain object the UI is never handed directly
# => GRASP's Controller: one coordinating object, not the UI, talks to the domain
def handle_add_item_event( # => the single ENTRY POINT, spread across lines
self,
name: str, # => raw event data, not yet a domain call
price: float,
# => a UI event comes IN here; the domain call happens INSIDE this method
) -> None: # => the ONE entry point every UI click routes through
self.cart.add_item(name, price) # => forwards to the domain, safely coordinated
def simulate_click( # => a free function standing in for a real UI event handler
controller: SessionController, # => the UI's only handle on the domain -- via the controller
name: str,
price: float,
# => the UI layer's type hint names SessionController, never ShoppingCart
) -> None: # => simulates a UI event handler firing
controller.handle_add_item_event(name, price) # => the UI never calls cart.add_item() directly, ever
cart: ShoppingCart = ShoppingCart() # => constructs cart
controller: SessionController = SessionController(cart) # => constructs controller
# => the UI code below never sees `cart` directly -- only `controller`
simulate_click(controller, "widget", 9.99) # => routed entirely through the controller
print(round(cart.total(), 2)) # => confirms the domain state actually changed
# => proof the event reached ShoppingCart WITHOUT the UI ever naming ShoppingCart
# => Output: 9.99
# => Every UI event flows through `SessionController` -- the UI layer never imports `ShoppingCart`'s mutation methodsRun: python3 example.py
Output:
9.99learning/code/ex-15-grasp-controller-session/test_example.py
"""Example 15: pytest verification for Controller: Route Events Through a Session Controller."""
import inspect
from example import SessionController, ShoppingCart, simulate_click
def test_ui_function_is_typed_against_the_controller_not_the_domain() -> None:
signature: inspect.Signature = inspect.signature(simulate_click) # => reads simulate_click's real parameter types
controller_param = signature.parameters["controller"]
assert controller_param.annotation is SessionController # => the UI never names ShoppingCart in its own signature
def test_clicking_through_the_controller_mutates_the_cart() -> None:
cart: ShoppingCart = ShoppingCart()
controller: SessionController = SessionController(cart)
simulate_click(controller, "widget", 9.99) # => the ONE call the UI ever makes
assert round(cart.total(), 2) == 9.99 # => the domain change genuinely happened
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: The UI-facing simulate_click() function is typed against SessionController, never ShoppingCart -- confirmed by inspecting its actual parameter annotation -- so the UI layer has no way to reach the domain except through the controller.
Why it matters: GRASP's Controller pattern is what keeps UI frameworks and domain logic independently testable and independently replaceable: a web UI and a CLI can share the exact same SessionController and ShoppingCart, differing only in how they call handle_add_item_event(). Without a controller boundary, every UI event handler becomes an ad hoc, undocumented entry point into business rules, making it much harder to reason about which code paths can mutate domain state.
Example 16: High Cohesion: Split a Mixed-Concern Class
ex-16 · exercises co-10
A class that mixes account fields like username with infrastructure fields like smtp_host has methods that each touch only half of its own state, a classic low-cohesion smell. This example splits it into UserAccount and EmailSender, where every method genuinely reads and writes only its own class's fields.
learning/code/ex-16-grasp-high-cohesion-split/example.py
"""Example 16: High Cohesion: Split a Mixed-Concern Class."""
class UserAccount: # => AFTER the split: every method touches ONLY account fields
def __init__(self, username: str, email: str) -> None: # => the constructor
self.username = username # => account-owned state
self.email = email # => account-owned state
def display_name(self) -> str: # => reads ONLY UserAccount's own fields
return f"@{self.username}" # => never touches anything email-server related
class EmailSender: # => AFTER the split: every method touches ONLY email fields
def __init__(self, smtp_host: str) -> None: # => the constructor
self.smtp_host = smtp_host # => email-owned state, unrelated to accounts
def send(self, to: str, subject: str) -> str: # => reads ONLY EmailSender's fields
return f"sent via {self.smtp_host} to {to}: {subject}" # => stays in its lane
account: UserAccount = UserAccount("alice", "alice@example.com") # => constructs account
sender: EmailSender = EmailSender("smtp.example.com") # => constructs sender, separately
print(account.display_name()) # => a pure account concern
print(sender.send(account.email, "Welcome")) # => a pure email concern, using account data
# => Output: @alice
# => sent via smtp.example.com to alice@example.com: Welcome
# => Before the split, one class mixed account fields with SMTP fields -- every method touched only HALF its own stateRun: python3 example.py
Output:
@alice
sent via smtp.example.com to alice@example.com: Welcomelearning/code/ex-16-grasp-high-cohesion-split/test_example.py
"""Example 16: pytest verification for High Cohesion: Split a Mixed-Concern Class."""
from example import EmailSender, UserAccount
def test_user_account_has_no_email_infrastructure_fields() -> None:
account: UserAccount = UserAccount("alice", "alice@example.com")
assert not hasattr(account, "smtp_host") # => never carries the other class's state
assert not hasattr(UserAccount, "send") # => never carries the other class's method
def test_email_sender_has_no_account_fields() -> None:
sender: EmailSender = EmailSender("smtp.example.com")
assert not hasattr(sender, "username") # => never carries the other class's state
assert not hasattr(EmailSender, "display_name") # => never carries the other method
def test_each_class_still_produces_correct_output() -> None:
account: UserAccount = UserAccount("alice", "alice@example.com")
sender: EmailSender = EmailSender("smtp.example.com")
assert account.display_name() == "@alice"
assert sender.send(account.email, "Welcome") == ("sent via smtp.example.com to alice@example.com: Welcome") # => the two cohesive classes still cooperate correctly
# => Run: pytest -- Output: 3 passedVerify: pytest -q
Output:
3 passedKey takeaway: UserAccount carries no smtp_host and no send() method, and EmailSender carries no username and no display_name() method -- each class's responsibilities are mutually related, which is what high cohesion means concretely.
Why it matters: Low-cohesion classes are expensive because every change forces a reviewer to reason about two unrelated concerns at once: a bug fix in account display logic risks an unintended side effect in email delivery, purely because they share a class body. Splitting along cohesion lines the way this example does means UserAccount's tests never need an SMTP mock, and EmailSender's tests never need a fabricated username, which keeps both test suites fast and focused.
Example 17: Low Coupling: Decouple via an Event Bus
ex-17 · exercises co-09
OrderModule calling InventoryModule's methods directly means every future change to InventoryModule's interface risks breaking OrderModule too. This example decouples them through an EventBus: OrderModule publishes an order_placed event, and InventoryModule subscribes to react to it, with neither module importing or naming the other.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Purple #CC78BC
flowchart LR
O["OrderModule"]:::blue
B["EventBus<br/>the only shared dependency"]:::purple
I["InventoryModule"]:::orange
O -->|publish order_placed| B
B -->|notify subscriber| I
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#000000,stroke-width:2px
learning/code/ex-17-grasp-low-coupling-event/example.py
"""Example 17: Low Coupling: Decouple via an Event Bus.""" # => module docstring
from typing import Callable # => Callable types the event handlers the bus stores
class EventBus: # => the ONLY object either module below depends on
def __init__(self) -> None: # => the constructor
self._subscribers: dict[str, list[Callable[[int], None]]] = {} # => event -> handlers
# => GRASP's Low Coupling: both modules depend on this bus, never on each other
def subscribe( # => the registration method, spread across lines
self, # => the EventBus instance itself
event: str, # => the event NAME being subscribed to, a plain string key
handler: Callable[[int], None],
# => handler is stored generically -- the bus never inspects who registered it
) -> None: # => defines the subscribe() method
self._subscribers.setdefault(event, []).append(handler) # => records the handler
def publish(self, event: str, quantity: int) -> None: # => defines the publish() method
for handler in self._subscribers.get(
event,
[], # => an empty list if nobody subscribed -- publish() never fails
): # => calls every handler registered for this event
handler(quantity) # => the bus never knows WHAT a handler does
# => low coupling: the bus is the ONLY thing either module depends on
class OrderModule: # => publishes an event -- never imports or calls the other module
def __init__(self, bus: EventBus) -> None: # => the constructor
self.bus = bus # => the ONLY collaborator OrderModule holds
def place_order(self, quantity: int) -> None: # => defines the place_order() method
self.bus.publish("order_placed", quantity) # => announces the event, nothing more
class InventoryModule: # => subscribes to an event -- never imports or calls the other module
def __init__(self, bus: EventBus) -> None: # => the constructor
self.stock = 100 # => starting stock level
bus.subscribe("order_placed", self._on_order_placed) # => reacts to a named EVENT, never to a concrete publisher class
def _on_order_placed(self, quantity: int) -> None: # => the registered handler
self.stock -= quantity # => decrements stock in response to the event
bus: EventBus = EventBus() # => constructs bus
inventory: InventoryModule = InventoryModule(bus) # => subscribes itself to the bus
order_module: OrderModule = OrderModule(bus) # => holds only the bus, nothing else
order_module.place_order(10) # => OrderModule never calls InventoryModule directly
print(inventory.stock) # => confirms InventoryModule reacted anyway, via the event
# => a THIRD module could subscribe to "order_placed" with no edit to either class above
# => Output: 90
# => Neither module's source code mentions the other module's class name at allRun: python3 example.py
Output:
90learning/code/ex-17-grasp-low-coupling-event/test_example.py
"""Example 17: pytest verification for Low Coupling: Decouple via an Event Bus."""
import inspect
from example import EventBus, InventoryModule, OrderModule
def test_neither_module_names_the_other_in_its_own_source() -> None:
order_source: str = inspect.getsource(OrderModule) # => OrderModule's own source only
inventory_source: str = inspect.getsource(InventoryModule) # => and vice versa
assert "InventoryModule" not in order_source # => zero coupling in one direction
assert "OrderModule" not in inventory_source # => zero coupling in the other direction
def test_placing_an_order_still_updates_inventory_via_the_bus() -> None:
bus: EventBus = EventBus()
inventory: InventoryModule = InventoryModule(bus)
order_module: OrderModule = OrderModule(bus)
order_module.place_order(10) # => the only call OrderModule ever makes
assert inventory.stock == 90 # => the event still reached InventoryModule
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: inspect.getsource() on both OrderModule and InventoryModule confirms neither class's source ever mentions the other by name -- the only thing either one depends on is EventBus.
Why it matters: Low coupling through an event bus is the architectural pattern behind most microservice and plugin-based systems, because it lets teams evolve OrderModule and InventoryModule on independent schedules as long as the event contract stays stable. This example's test adds no third module, but the same bus could gain a ShippingModule subscriber with zero changes to either existing class -- the coupling that matters is bounded entirely by the event's name and payload shape, not by any direct class reference.
Example 18: Factory Method: ShapeFactory Hides Concrete Types
ex-18 · exercises co-16
A caller that writes Circle(2.0) directly must import Circle and commit to that concrete type at the call site. This example routes construction through ShapeFactory.create("circle", 2.0), which returns the Shape abstraction while keeping Circle and Square as internal implementation details the caller never names.
%% Color Palette: Blue #0173B2, Orange #DE8F05
flowchart LR
A["ShapeFactory.create#40;kind, size#41;<br/>a template, not an object"]:::blue
B["Circle or Square<br/>constructed internally"]:::orange
A -->|"returns Shape"| B
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-18-factory-method-shape/example.py
"""Example 18: Factory Method: ShapeFactory Hides Concrete Types.""" # => docstring
from typing import Protocol # => Protocol declares the shape every factory product matches
class Shape(Protocol): # => the abstraction the caller programs against
def area(self) -> float: # => the one method every shape must provide
... # => Protocol methods have no body -- a structural contract only
class Circle: # => a concrete product -- the caller never names this class directly
def __init__(self, radius: float) -> None: # => the constructor
self.radius = radius # => stores radius on this instance
def area(self) -> float: # => satisfies Shape structurally
return 3.14159 * self.radius**2 # => a real, honest implementation
class Square: # => a SECOND concrete product, also hidden behind the factory
def __init__(self, side: float) -> None: # => the constructor
self.side = side # => stores side on this instance
def area(self) -> float: # => satisfies Shape structurally
return self.side**2 # => a real, honest implementation
class ShapeFactory: # => the FACTORY METHOD -- defers instantiation to one place
@staticmethod # => no instance state needed to build a shape
def create( # => the FACTORY METHOD, spread across lines to annotate each argument
kind: str, # => a plain string selector, never a class reference
size: float,
# => the caller passes a STRING, never a concrete class name like Circle
) -> Shape: # => returns the abstraction, not a named concrete type
if kind == "circle": # => the ONLY place that knows Circle exists
return Circle(size) # => constructs the concrete product internally
return Square(size) # => the ONLY place that knows Square exists
shape: Shape = ShapeFactory.create(
"circle", # => the selector string, decides Circle vs Square inside create()
2.0, # => the size argument, forwarded to whichever constructor is chosen
# => this file's caller code never writes `from example import Circle`
) # => the caller obtains a Circle without ever importing Circle itself
print(round(shape.area(), 2)) # => confirms the factory built a working shape
# => the returned object is typed as Shape -- its exact class stays an implementation detail
# => Output: 12.57
# => Adding a Triangle later means editing ShapeFactory.create() ONCE -- callers never changeRun: python3 example.py
Output:
12.57learning/code/ex-18-factory-method-shape/test_example.py
"""Example 18: pytest verification for Factory Method: ShapeFactory Hides Concrete Types."""
# => this test deliberately imports ONLY Shape and ShapeFactory -- never Circle
from example import Shape, ShapeFactory
def test_caller_obtains_a_circle_without_importing_circle() -> None:
shape: Shape = ShapeFactory.create("circle", 2.0) # => the caller never wrote Circle(...)
assert round(shape.area(), 2) == 12.57 # => a genuine, correctly-built Circle
def test_caller_obtains_a_square_without_importing_square() -> None:
shape: Shape = ShapeFactory.create("square", 3.0)
assert shape.area() == 9.0 # => a genuine, correctly-built Square
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: The test file for this example imports only Shape and ShapeFactory, never Circle, and still obtains a fully functional Circle instance through the factory method.
Why it matters: Factory methods are the standard way to keep object construction logic in one place while callers stay decoupled from concrete types -- adding a Triangle later means editing ShapeFactory.create() exactly once, and every existing caller that already depends on the Shape abstraction picks up the new capability with zero changes. This pattern shows up constantly in parser libraries, ORMs, and UI toolkits where the concrete class returned depends on a runtime configuration value.
Example 19: Simple Factory: Centralize Parser Construction
ex-19 · exercises co-16
Scattering parser construction logic across the codebase -- some code building CsvParser directly, other code building JsonParser directly -- means adding a new file format requires hunting down every construction site. This example centralizes construction in ParserFactory.create(extension), which dispatches by extension string and raises a clean ValueError for anything it does not recognize.
learning/code/ex-19-simple-factory-parser/example.py
"""Example 19: Simple Factory: Centralize Parser Construction."""
from typing import Protocol # => Protocol declares the shape every parser must match
class Parser(Protocol): # => the abstraction every concrete parser satisfies
def parse(self, text: str) -> list[str]: # => the one method every parser provides
... # => Protocol methods have no body -- a structural contract only
class CsvParser: # => a concrete parser for comma-separated text
def parse(self, text: str) -> list[str]: # => satisfies Parser structurally
return text.split(",") # => a real, honest implementation
class JsonParser: # => a SECOND concrete parser, for a single JSON array of strings
def parse(self, text: str) -> list[str]: # => satisfies Parser structurally
return [
item.strip().strip('"')
for item in text.strip("[]").split(",")
# => trims whitespace THEN quotes -- ["x", "y"] both need both trims
] # => a minimal, dependency-free JSON-array parser
class ParserFactory: # => centralizes parser CONSTRUCTION in exactly one place
@staticmethod # => no instance state needed to build a parser
def create(extension: str) -> Parser: # => dispatches by file extension
if extension == "csv": # => one branch per KNOWN extension
return CsvParser() # => constructs the matching concrete parser
if extension == "json": # => a second known extension
return JsonParser() # => constructs the matching concrete parser
raise ValueError(
f"unknown extension: {extension}"
# => a clean, specific error -- never a silent None or a cryptic KeyError
) # => rejects anything the factory does not recognize
csv_parser: Parser = ParserFactory.create("csv") # => centralized construction
# => the caller never wrote `CsvParser()` directly -- ParserFactory did that internally
print(csv_parser.parse("a,b,c")) # => confirms the correct concrete parser was built
# => a fifth file format needs one new branch inside create(), nowhere else
# => Output: ['a', 'b', 'c']
# => `ParserFactory.create()` is the ONE place new extensions get registeredRun: python3 example.py
Output:
['a', 'b', 'c']learning/code/ex-19-simple-factory-parser/test_example.py
"""Example 19: pytest verification for Simple Factory: Centralize Parser Construction."""
import pytest # => pytest.raises asserts a specific exception is raised
from example import Parser, ParserFactory
def test_known_extensions_build_the_right_parser() -> None:
csv_parser: Parser = ParserFactory.create("csv")
assert csv_parser.parse("a,b,c") == ["a", "b", "c"]
json_parser: Parser = ParserFactory.create("json")
assert json_parser.parse('["x", "y"]') == ["x", "y"] # => a different parser entirely
def test_unknown_extension_raises_a_clean_value_error() -> None:
# => the test PASSES only because ValueError fires with a specific, readable message
with pytest.raises(ValueError, match="unknown extension"):
ParserFactory.create("xml") # => an extension the factory does not recognize
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: ParserFactory.create("xml") raises ValueError with a specific, readable message instead of returning None or throwing a cryptic KeyError, because the factory validates its own input explicitly.
Why it matters: Centralized factories with clean error handling matter most at system boundaries, where the input (a file extension, a content-type header, a plugin name) originates outside the program's control -- a silent None return here would surface as a confusing AttributeError several calls later, far from the actual mistake. Failing fast with a specific message, as this example's ParserFactory does, turns a debugging session into a one-line log message.
Example 20: Strategy: A Pluggable Sort Key
ex-20 · exercises co-25
Sorting by name in one place and by price in another usually means duplicating the sorted() call with different key arguments scattered around a codebase. This example centralizes the mechanism in sort_items(items, key_func), where by_name, by_price, and a later by_name_length are all interchangeable plain functions passed in as the strategy.
learning/code/ex-20-strategy-sort-key/example.py
"""Example 20: Strategy: A Pluggable Sort Key.""" # => module docstring
from dataclasses import dataclass # => imports dataclass from dataclasses
from typing import Any, Callable # => Any lets key_func return any sortable type
@dataclass # => generates __init__ from the fields below
class Item: # => a simple product record, sortable by more than one field
name: str # => the item name, part of the generated __init__
price: float # => the item price, part of the generated __init__
def by_name(item: Item) -> str: # => STRATEGY one: sort key based on name
return item.name # => a real, honest sort-key implementation
# => each strategy is a plain function -- no shared base class required
def by_price(item: Item) -> float: # => STRATEGY two: sort key based on price
return item.price # => a real, honest sort-key implementation
def sort_items( # => the STRATEGY-ACCEPTING function, spread across lines
items: list[Item], # => the data being sorted, unrelated to which strategy is chosen
key_func: Callable[[Item], Any],
# => Any (not object) so Pyright accepts str, float, or int keys -- sorted() needs
# => a genuinely comparable return type, which plain object cannot guarantee
# => sort_items() is NEVER edited to add a new sorting strategy
) -> list[Item]: # => defines the sort_items() function
return sorted(items, key=key_func) # => delegates the comparison entirely to key_func
# => any zero-argument-returning callable works as key_func -- functions ARE strategies
items: list[Item] = [
Item("banana", 1.5), # => sample item one
Item("apple", 3.0), # => sample item two
Item("cherry", 2.0), # => sample item three
# => three sample items, unsorted -- same list, three interchangeable strategies below
] # => three sample items, unsorted
by_name_result: list[str] = [
item.name # => extracts just the name for the printed result
for item in sort_items(items, by_name)
# => passes by_name as the STRATEGY object -- a plain function works fine here
] # => sorted alphabetically via the by_name strategy
by_price_result: list[str] = [
item.name # => extracts just the name for the printed result
for item in sort_items(items, by_price)
# => passes a DIFFERENT strategy; sort_items() itself did not change at all
] # => sorted by price via the DIFFERENT by_price strategy, zero edits to sort_items
def by_name_length(item: Item) -> int: # => a THIRD strategy, added just by writing a function
return len(item.name) # => a real, honest sort-key implementation
# => no class hierarchy needed -- a plain function satisfies the same shape
by_length_result: list[str] = [
item.name # => extracts just the name for the printed result
for item in sort_items(items, by_name_length)
# => a fourth strategy could be plugged in here the same way, forever
] # => added with zero changes to sort_items() itself
print(by_name_result, by_price_result, by_length_result) # => three different orderings
# => three interchangeable strategies, one unmodified sort_items() function
# => Output: ['apple', 'banana', 'cherry'] ['banana', 'cherry', 'apple'] ['apple', 'banana', 'cherry']
# => `sort_items()` was written ONCE and never touched again when `by_name_length` was addedRun: python3 example.py
Output:
['apple', 'banana', 'cherry'] ['banana', 'cherry', 'apple'] ['apple', 'banana', 'cherry']learning/code/ex-20-strategy-sort-key/test_example.py
"""Example 20: pytest verification for Strategy: A Pluggable Sort Key."""
from example import Item, by_name, by_price, sort_items
def test_by_name_and_by_price_produce_different_orderings() -> None:
items: list[Item] = [Item("banana", 1.5), Item("apple", 3.0), Item("cherry", 2.0)]
by_name_names: list[str] = [item.name for item in sort_items(items, by_name)]
by_price_names: list[str] = [item.name for item in sort_items(items, by_price)]
assert by_name_names == ["apple", "banana", "cherry"]
assert by_price_names == ["banana", "cherry", "apple"] # => a genuinely different order
def test_a_new_strategy_needs_no_change_to_sort_items() -> None:
# => defines a fourth strategy right here, inside the test, with zero edits above
def by_reverse_name(item: Item) -> str:
return item.name[::-1]
items: list[Item] = [Item("banana", 1.5), Item("apple", 3.0)]
result: list[str] = [item.name for item in sort_items(items, by_reverse_name)]
assert result == ["banana", "apple"] # => "ananab" sorts before "elppa" reversed
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: A fourth sort key, by_name_length, is added as an ordinary function with no edits to sort_items() at all -- in Python, a strategy does not need a class or a shared base type, just a matching callable signature.
Why it matters: Python's first-class functions make the Strategy pattern nearly free to apply -- there is no need for a StrategyInterface base class the way there might be in a language without first-class functions, just a Callable[[Item], Any] type hint. This example's test file defines a fourth strategy, by_reverse_name, entirely inside the test function itself, which is the clearest possible demonstration that the sorting mechanism and the sorting criteria are genuinely independent.
Example 21: Observer: Notify Subscribers on Publish
ex-21 · exercises co-26
A Newsletter that calls out to specific subscriber functions by name would need editing every time a new subscriber is added. This example has Newsletter.publish() iterate over a list of registered subscribers built entirely through subscribe(), so log_subscriber and urgent_subscriber both react without Newsletter ever referencing either by name.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
N["Newsletter<br/>publish#40;headline#41;"]:::blue
S1["log_subscriber"]:::orange
S2["urgent_subscriber"]:::teal
N -->|notifies| S1
N -->|notifies| S2
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-21-observer-newsletter/example.py
"""Example 21: Observer: Notify Subscribers on Publish.""" # => module docstring
from typing import Callable # => Callable types each subscriber this newsletter holds
class Newsletter: # => the SUBJECT -- notifies subscribers, never inspects who they are
def __init__(self) -> None: # => the constructor
self._subscribers: list[Callable[[str], None]] = [] # => the growing subscriber list
def subscribe( # => the registration method, spread across lines
self, # => the Newsletter instance itself
handler: Callable[[str], None],
# => subscribe() is NEVER edited to support a new kind of subscriber
) -> None: # => defines the subscribe() method
self._subscribers.append(handler) # => the ONLY line that grows the list
def publish(self, headline: str) -> None: # => defines the publish() method
for handler in self._subscribers: # => notifies EVERY subscriber, in order
handler(headline) # => the subject never knows what a handler does with it
received: list[str] = [] # => a plain list one subscriber will append into
urgent: list[str] = [] # => a SECOND, independent list a different subscriber appends into
def log_subscriber(headline: str) -> None: # => the FIRST subscriber, added via subscribe()
received.append(headline) # => records every headline this subscriber sees
def urgent_subscriber(headline: str) -> None: # => a SECOND subscriber, zero edits to Newsletter
if "URGENT" in headline: # => this subscriber filters on its own terms
urgent.append(headline) # => only records headlines it cares about
newsletter: Newsletter = Newsletter() # => constructs newsletter
newsletter.subscribe(log_subscriber) # => registers subscriber one
newsletter.subscribe(urgent_subscriber) # => registers subscriber two, same method call
newsletter.publish("Weekly digest") # => both subscribers react, publish() never branches
newsletter.publish("URGENT: outage") # => both subscribers react again, differently
print(received, urgent) # => confirms both subscribers independently received events
# => Output: ['Weekly digest', 'URGENT: outage'] ['URGENT: outage']
# => Adding a third subscriber is one more `newsletter.subscribe(...)` call -- `publish()` never changesRun: python3 example.py
Output:
['Weekly digest', 'URGENT: outage'] ['URGENT: outage']learning/code/ex-21-observer-newsletter/test_example.py
"""Example 21: pytest verification for Observer: Notify Subscribers on Publish."""
from example import Newsletter
def test_every_subscriber_is_notified_on_publish() -> None:
received: list[str] = []
newsletter: Newsletter = Newsletter()
newsletter.subscribe(lambda headline: received.append(headline)) # => one subscriber
newsletter.publish("hello")
assert received == ["hello"] # => the subscriber genuinely fired
def test_a_new_subscriber_needs_zero_edits_to_newsletter() -> None:
# => registers a SECOND subscriber here, without touching Newsletter's source at all
seen_by_second: list[str] = []
newsletter: Newsletter = Newsletter()
newsletter.subscribe(lambda headline: seen_by_second.append(headline.upper()))
newsletter.publish("breaking news")
assert seen_by_second == ["BREAKING NEWS"] # => a brand-new behavior, zero edits above
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Adding a third subscriber is exactly one more newsletter.subscribe(...) call -- publish() itself, defined once, never changes no matter how many subscribers register.
Why it matters: The Observer pattern is the foundation of event systems everywhere from GUI frameworks to reactive state libraries, and its core value is exactly what this example demonstrates: the subject (Newsletter) stays completely ignorant of how many observers exist or what they do with each notification. urgent_subscriber even filters events on its own terms (only headlines containing URGENT), logic Newsletter never needs to know about, which is what keeps the subject's code stable as the number of interested parties grows.
Example 22: Adapter: Fahrenheit Sensor to Celsius Interface
ex-22 · exercises co-20
A legacy FahrenheitSensor exposes get_fahrenheit(), but a client written to expect Celsius cannot use it directly without a conversion step somewhere. This example wraps the legacy sensor in a CelsiusSensorAdapter that exposes get_celsius(), performing the Fahrenheit-to-Celsius conversion internally so the client never sees the mismatch.
learning/code/ex-22-adapter-celsius-fahrenheit/example.py
"""Example 22: Adapter: Fahrenheit Sensor to Celsius Interface."""
class FahrenheitSensor: # => the LEGACY interface -- reports only in Fahrenheit
def __init__(self, reading_f: float) -> None: # => the constructor
self.reading_f = reading_f # => stores the raw Fahrenheit reading
def get_fahrenheit(self) -> float: # => the ONLY method this legacy sensor offers
return self.reading_f # => returns the value exactly as read
class CelsiusSensorAdapter: # => the ADAPTER -- wraps Fahrenheit, exposes Celsius
def __init__(self, sensor: FahrenheitSensor) -> None: # => the constructor
self._sensor = sensor # => holds the incompatible legacy object internally
# => neither FahrenheitSensor nor the client below was ever modified
def get_celsius(self) -> float: # => the interface the CLIENT actually wants
fahrenheit: float = self._sensor.get_fahrenheit() # => delegates to the legacy call
return (fahrenheit - 32) * 5 / 9 # => the conversion formula, isolated here
def read_temperature(sensor: CelsiusSensorAdapter) -> float: # => the CLIENT, expects Celsius
return sensor.get_celsius() # => never calls get_fahrenheit() at all
legacy: FahrenheitSensor = FahrenheitSensor(98.6) # => an incompatible legacy sensor
adapted: CelsiusSensorAdapter = CelsiusSensorAdapter(legacy) # => wraps the legacy object to match what the client expects
print(round(read_temperature(adapted), 1)) # => the client reads Celsius, transparently
# => Output: 37.0
# => `CelsiusSensorAdapter` translates one interface into another -- neither side was rewrittenRun: python3 example.py
Output:
37.0learning/code/ex-22-adapter-celsius-fahrenheit/test_example.py
"""Example 22: pytest verification for Adapter: Fahrenheit Sensor to Celsius Interface."""
from example import CelsiusSensorAdapter, FahrenheitSensor, read_temperature
def test_adapter_converts_fahrenheit_to_celsius_correctly() -> None:
legacy: FahrenheitSensor = FahrenheitSensor(98.6)
adapted: CelsiusSensorAdapter = CelsiusSensorAdapter(legacy)
assert round(adapted.get_celsius(), 1) == 37.0 # => the standard conversion, exact
def test_client_reads_celsius_through_the_adapter_only() -> None:
adapted: CelsiusSensorAdapter = CelsiusSensorAdapter(FahrenheitSensor(32.0))
result: float = read_temperature(adapted) # => the client never calls get_fahrenheit()
assert result == 0.0 # => freezing point, correctly translated
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: read_temperature(sensor) only ever calls sensor.get_celsius() -- it never calls get_fahrenheit(), because CelsiusSensorAdapter's whole job is to make that distinction invisible to the client.
Why it matters: Adapters are the standard tool for integrating a legacy or third-party interface into code that expects something different, without modifying either side -- exactly the situation this example simulates with an unmodifiable FahrenheitSensor. The pattern shows up constantly at integration boundaries: wrapping a vendor SDK's response shape to match an internal domain model, or adapting an old logging interface to a new one, all without touching the legacy code or the new client code.
Example 23: Decorator: Log a Service Call Without Editing It
ex-23 · exercises co-21
Adding a logging statement directly inside charge()'s body means every service method that needs logging must be edited individually, and the business logic gets cluttered with cross-cutting concerns. This example wraps charge() with a @log_calls decorator that logs before and after the real call, leaving charge()'s own body completely free of logging code.
learning/code/ex-23-decorator-logging/example.py
"""Example 23: Decorator: Log a Service Call Without Editing It."""
from functools import wraps # => wraps preserves the wrapped function's name and docstring
from typing import Callable # => Callable types the function log_calls wraps
LOG: list[str] = [] # => the shared record every decorated call appends to
def log_calls(
fn: Callable[[float], str],
# => the decorator FACTORY -- takes the original function, returns a wrapped one
) -> Callable[[float], str]: # => returns a function with the SAME signature as fn
@wraps(fn) # => keeps fn.__name__ intact for debugging and introspection
def wrapper(amount: float) -> str: # => the wrapper -- runs BEFORE and AFTER fn
LOG.append(f"calling {fn.__name__} with {amount}") # => logs BEFORE the real call
result: str = fn(amount) # => the ORIGINAL behavior, completely unedited
LOG.append(f"{fn.__name__} returned {result}") # => logs AFTER the real call
return result # => returns the original result, unchanged
return wrapper # => this wrapper REPLACES the original function at the call site
@log_calls # => the ONLY edit needed -- charge()'s own body never changes
def charge(amount: float) -> str: # => the ORIGINAL service method, untouched internally
return f"charged {amount}" # => the real, honest business logic
result: str = charge(50.0) # => calls the WRAPPED version transparently
print(result) # => the business result is identical to calling charge() undecorated
print(LOG) # => confirms logging happened, entirely OUTSIDE charge()'s own body
# => Output: charged 50.0
# => ['calling charge with 50.0', 'charge returned charged 50.0']
# => `charge`'s source code has zero logging statements -- `@log_calls` added the behavior from outsideRun: python3 example.py
Output:
charged 50.0
['calling charge with 50.0', 'charge returned charged 50.0']learning/code/ex-23-decorator-logging/test_example.py
"""Example 23: pytest verification for Decorator: Log a Service Call Without Editing It."""
import inspect
from example import LOG, charge
def test_charge_source_contains_no_logging_statements() -> None:
source: str = inspect.getsource(charge) # => reads the DECORATED function's own source text (the wrapper, via @wraps)
assert "LOG" not in source # => proves the wrapper's logging lives outside this body
def test_calling_charge_still_records_a_log_entry() -> None:
LOG.clear() # => resets the shared log before this test's own assertions
result: str = charge(50.0)
assert result == "charged 50.0" # => the original business result is unchanged
assert len(LOG) == 2 # => one entry logged before the call, one logged after
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: inspect.getsource(charge) confirms the decorated function's source contains no reference to LOG at all -- the logging behavior lives entirely inside the decorator, added from the outside.
Why it matters: Decorators are Python's idiomatic answer to cross-cutting concerns like logging, timing, caching, and retry logic, because @wraps(fn) lets the wrapped function keep its own name and docstring for introspection while gaining new behavior transparently. This pattern avoids the alternative of copy-pasting logging code into every service method, which both clutters the business logic and creates dozens of places that could silently drift out of sync if the logging format ever needs to change.
Example 24: Facade: One Call Hides Three Subsystems
ex-24 · exercises co-22
A caller that has to coordinate Inventory.reserve(), Payment.charge(), and Shipping.schedule() itself must understand all three subsystems and their correct call order. This example wraps them behind a single CheckoutFacade.checkout() method, so the caller makes one call and the facade handles the sequencing and any early failures internally.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart TD
Caller["caller"]:::blue
F["CheckoutFacade<br/>checkout#40;#41;"]:::orange
Inv["Inventory"]:::teal
Pay["Payment"]:::teal
Ship["Shipping"]:::teal
Caller -->|one call| F
F --> Inv
F --> Pay
F --> Ship
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-24-facade-checkout/example.py
"""Example 24: Facade: One Call Hides Three Subsystems.""" # => module docstring
class Inventory: # => subsystem ONE -- the caller never touches this directly
def reserve(self, item: str, qty: int) -> bool: # => reserves stock for an order
return qty <= 10 # => a simplified stock check, always succeeds for qty <= 10
class Payment: # => subsystem TWO -- the caller never touches this directly
def charge(self, amount: float) -> bool: # => charges the customer
return amount > 0 # => a simplified charge check, succeeds for any positive amount
class Shipping: # => subsystem THREE -- the caller never touches this directly
def schedule(self, item: str) -> str: # => schedules delivery
return f"{item} scheduled for delivery" # => a real, honest implementation
class CheckoutFacade: # => the FACADE -- one simplified entry point over all three
def __init__(self) -> None: # => the constructor
self._inventory = Inventory() # => wires subsystem one internally
self._payment = Payment() # => wires subsystem two internally
self._shipping = Shipping() # => wires subsystem three internally
def checkout( # => the SIMPLIFIED entry point, spread across lines
self, # => the CheckoutFacade instance, already wired to all three subsystems
item: str, # => a plain value, never a subsystem object
qty: int, # => a plain value, never a subsystem object
amount: float,
# => the caller passes plain values -- never an Inventory, Payment, or Shipping object
) -> str: # => the ONE method the caller ever needs to call
if not self._inventory.reserve(item, qty): # => step one, hidden inside checkout()
return "out of stock" # => an early, honest failure
if not self._payment.charge(amount): # => step two, hidden inside checkout()
return "payment failed" # => an early, honest failure
return self._shipping.schedule(item) # => step three, the final hidden call
facade: CheckoutFacade = CheckoutFacade() # => constructs facade, wiring all three internally
result: str = facade.checkout(
"widget", # => the item name, forwarded to Inventory and Shipping internally
2, # => the quantity, forwarded to Inventory internally
9.99, # => the amount, forwarded to Payment internally
# => three subsystem calls happen inside checkout(), invisible from here
) # => the caller's ONE call -- no Inventory, Payment, or Shipping mentioned here
print(result) # => confirms all three subsystems cooperated behind one call
# => a caller unfamiliar with Inventory/Payment/Shipping can still complete a checkout
# => Output: widget scheduled for delivery
# => The caller never imports `Inventory`, `Payment`, or `Shipping` -- only `CheckoutFacade`Run: python3 example.py
Output:
widget scheduled for deliverylearning/code/ex-24-facade-checkout/test_example.py
"""Example 24: pytest verification for Facade: One Call Hides Three Subsystems."""
# => this test deliberately imports ONLY CheckoutFacade -- never the three subsystems
from example import CheckoutFacade
def test_checkout_succeeds_with_a_single_call() -> None:
facade: CheckoutFacade = CheckoutFacade()
result: str = facade.checkout("widget", 2, 9.99) # => the caller's only call
assert result == "widget scheduled for delivery" # => all three steps ran internally
def test_checkout_reports_out_of_stock_without_charging() -> None:
facade: CheckoutFacade = CheckoutFacade()
result: str = facade.checkout("widget", 999, 9.99) # => fails the inventory step first
assert result == "out of stock" # => the caller sees a clean, single-string result
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: The test file for this example imports only CheckoutFacade -- never Inventory, Payment, or Shipping -- and still exercises the full three-step checkout flow through one method call.
Why it matters: Facades are how large systems stay approachable for new integrators: a payment gateway, a cloud SDK, or an internal platform team's client library all typically expose one or two facade methods over dozens of underlying operations, hiding sequencing rules and failure handling that would otherwise be duplicated (and inevitably gotten wrong) by every caller. CheckoutFacade's early-return on out-of-stock is exactly the kind of subsystem-ordering logic that belongs in one place rather than copy-pasted at every checkout call site.
Example 25: Template Method: One Skeleton, Many Subclasses
ex-25 · exercises co-28
SalesReport and InventoryReport both need a header, a body, and a footer assembled in the same order, but their body content genuinely differs. This example puts that fixed order in Report.generate(), the template method, while header() and footer() have shared defaults and body() is the one step every subclass overrides.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
G["Report.generate#40;#41;<br/>the fixed skeleton"]:::blue
H["header#40;#41;<br/>shared default"]:::teal
Bd["body#40;#41;<br/>overridden per subclass"]:::orange
F["footer#40;#41;<br/>shared default"]:::teal
G --> H --> Bd --> F
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-25-template-method-report/example.py
"""Example 25: Template Method: One Skeleton, Many Subclasses."""
class Report: # => the BASE -- defines the algorithm SKELETON exactly once
def header(self) -> str: # => a default STEP every subclass may reuse as-is
return "=== Report ===" # => shared across every subclass, unless overridden
def body(self) -> str: # => the STEP every subclass is expected to override
raise NotImplementedError # => the base has no sensible default body
def footer(self) -> str: # => a default STEP every subclass may reuse as-is
return "--- End ---" # => shared across every subclass, unless overridden
def generate(self) -> str: # => the TEMPLATE METHOD -- the shared skeleton, defined ONCE
return "\n".join(
[self.header(), self.body(), self.footer()]
# => the ORDER of steps is fixed here; only the step CONTENTS vary per subclass
) # => the skeleton never changes, no matter which subclass calls it
class SalesReport(Report): # => fills in ONLY the body() step
def body(self) -> str: # => overrides body(), inherits header()/footer()/generate()
return "Sales: $1000" # => a real, honest implementation
class InventoryReport(Report): # => fills in ONLY the body() step, differently
def body(self) -> str: # => overrides body(), inherits header()/footer()/generate()
return "Stock: 42 units" # => a real, honest implementation
sales: SalesReport = SalesReport() # => constructs sales
inventory: InventoryReport = InventoryReport() # => constructs inventory
print(sales.generate()) # => the SAME generate() method, different body() content
print("---") # => a visual separator between the two printed reports
print(inventory.generate()) # => the SAME generate() method, called on a DIFFERENT subclass
# => Output: === Report ===
# => Sales: $1000
# => --- End ---
# => ---
# => === Report ===
# => Stock: 42 units
# => --- End ---
# => `generate()` is defined ONCE on `Report` -- neither subclass overrides it at allRun: python3 example.py
Output:
=== Report ===
Sales: $1000
--- End ---
---
=== Report ===
Stock: 42 units
--- End ---learning/code/ex-25-template-method-report/test_example.py
"""Example 25: pytest verification for Template Method: One Skeleton, Many Subclasses."""
from example import InventoryReport, Report, SalesReport
def test_generate_is_never_duplicated_in_either_subclass() -> None:
# => the mechanical proof: both subclasses inherit the EXACT SAME generate() object
assert SalesReport.generate is Report.generate # => no override anywhere
assert InventoryReport.generate is Report.generate # => no override here either
def test_each_subclass_still_produces_its_own_body() -> None:
sales: SalesReport = SalesReport()
inventory: InventoryReport = InventoryReport()
assert "Sales: $1000" in sales.generate()
assert "Stock: 42 units" in inventory.generate() # => a genuinely different body
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: SalesReport.generate is Report.generate and InventoryReport.generate is Report.generate -- confirmed by identity comparison -- proving the shared algorithm is defined exactly once, never duplicated in either subclass.
Why it matters: Template Method is the pattern to reach for whenever multiple classes share the same overall algorithm but differ in one or two steps -- report generation, request-processing pipelines, and test fixture setup/teardown all fit this shape. Without it, SalesReport and InventoryReport would each need their own copy of the header-body-footer assembly logic, and a change to the shared order (say, adding a timestamp step) would require editing every subclass instead of the one base method.
Example 26: Composition Over Inheritance: Model a Badge as Has-A
ex-26 · exercises co-09
Modeling gold, silver, and platinum distinctions as GoldUser, SilverUser, and PlatinumUser subclasses means every new tier requires a new class, and any user needing two badges at once has nowhere to go. This example models a Badge as data User holds in a list instead, so a new distinction is just a new Badge value passed to the same User constructor.
learning/code/ex-26-composition-over-inheritance-badge/example.py
"""Example 26: Composition Over Inheritance: Model a Badge as Has-A.""" # => docstring
from dataclasses import dataclass # => imports dataclass from dataclasses
@dataclass # => generates __init__ from the fields below
class Badge: # => a small, independent value -- NOT a base class anyone inherits from
color: str # => the badge's color, part of the generated __init__
label: str # => the badge's label, part of the generated __init__
class User: # => a SINGLE class, regardless of how many badge combinations exist
# => has-a: User HOLDS badges; it never becomes GoldUser, SilverUser, and so on
def __init__( # => the constructor, spread across lines to annotate each field
self, # => the User instance being constructed
name: str, # => a plain string field, unrelated to the has-a relationship
badges: list[Badge],
# => badges is a HAS-A relationship -- User holds Badge objects, never inherits them
) -> None: # => the constructor -- runs once, automatically, per instantiation
self.name = name # => stores name on this instance
self.badges = badges # => stores the badge LIST, not a subclass identity
def describe(self) -> str: # => defines the describe() method
labels: str = ", ".join(
badge.label # => extracts just the label text from each Badge value
for badge in self.badges # => reads whatever badges THIS user has
) # => reads badge data without needing a GoldUser or SilverUser subclass
return f"{self.name} ({labels})" if labels else self.name # => handles no badges too
gold_user: User = User(
"Alice", # => the name field, plain data
[Badge("gold", "Top Seller")], # => the DATA changes; the class never does
) # => a "gold" distinction via composition, NOT a GoldUser subclass
silver_user: User = User(
"Bob", # => the name field, plain data
[Badge("silver", "Rising Star")], # => a different Badge value, same User class
) # => a DIFFERENT distinction, same User class, zero new subclasses
print(gold_user.describe()) # => confirms composition produced the right description
print(silver_user.describe()) # => confirms a totally different badge, same User class
# => inheritance would have needed GoldUser, SilverUser, and every future combination
# => Output: Alice (Top Seller)
# => Bob (Rising Star)
# => A `PlatinumUser` subclass is never needed -- `User(name, [Badge("platinum", "...")])` is enoughRun: python3 example.py
Output:
Alice (Top Seller)
Bob (Rising Star)learning/code/ex-26-composition-over-inheritance-badge/test_example.py
"""Example 26: pytest verification for Composition Over Inheritance: Model a Badge as Has-A."""
from example import Badge, User
def test_user_class_never_gains_subclasses_for_new_badge_kinds() -> None:
gold_user: User = User("Alice", [Badge("gold", "Top Seller")])
silver_user: User = User("Bob", [Badge("silver", "Rising Star")])
# => a brand-new "platinum" distinction needed ZERO new classes, just a new Badge value
platinum_user: User = User("Carol", [Badge("platinum", "Legend")])
assert User.__subclasses__() == [] # => no GoldUser, SilverUser, or PlatinumUser exists
assert type(gold_user) is type(silver_user) is type(platinum_user) # => one shared class
def test_describe_reflects_each_users_own_badges() -> None:
gold_user: User = User("Alice", [Badge("gold", "Top Seller")])
assert gold_user.describe() == "Alice (Top Seller)"
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: User.__subclasses__() returns an empty list even after constructing gold, silver, and platinum users -- composition kept a single User class handling every combination, with zero subclass explosion.
Why it matters: Subclass explosion is a common design smell in systems that model combinations of independent traits -- user tiers, product variants, permission levels -- because the number of needed subclasses grows multiplicatively as traits combine (a user with two badges has no natural subclass at all under inheritance). Composition sidesteps the problem entirely: User(name, badges) can represent any combination of badges without a single additional class, which is why "favor composition over inheritance" is one of the most repeated pieces of object-oriented design advice.
Example 27: Value Object: Immutable Money with Value Equality
ex-27 · exercises co-10
A Money class with mutable fields and identity-based equality would let two separately constructed $10.00 amounts compare as unequal, which contradicts how money should behave conceptually. This example makes Money a frozen dataclass whose every method concerns only representing and combining a monetary amount, giving it both immutability and value-based equality for free.
learning/code/ex-27-value-object-money/example.py
"""Example 27: Value Object: Immutable Money with Value Equality."""
from dataclasses import dataclass # => imports dataclass from dataclasses
@dataclass(frozen=True) # => frozen=True makes every field read-only after construction
class Money: # => a VALUE object -- every method here concerns ONLY a monetary amount
amount: int # => stored in cents, part of the generated __init__
currency: str # => the currency code, part of the generated __init__
def add(self, other: "Money") -> "Money": # => never mutates self -- returns a NEW Money
if self.currency != other.currency: # => guards against mixing currencies
raise ValueError("currency mismatch") # => rejects the call entirely
return Money(self.amount + other.amount, self.currency) # => a fresh, independent Money -- neither operand was touched
ten_usd: Money = Money(1000, "usd") # => $10.00, immutable from the moment it's built
five_usd: Money = Money(500, "usd") # => a second, independent Money value
total: Money = ten_usd.add(five_usd) # => produces a THIRD Money -- the first two are unchanged
print(total) # => the generated __repr__ shows every field explicitly
# => Output: Money(amount=1500, currency='usd')
same_value: Money = Money(1000, "usd") # => a SEPARATE object with identical field values
print(ten_usd == same_value, ten_usd is same_value) # => value equality, NOT identity
# => Output: True False
# => `@dataclass(frozen=True)` gives `Money` value-based `__eq__` for free -- two equal amounts always compare equalRun: python3 example.py
Output:
Money(amount=1500, currency='usd')
True Falselearning/code/ex-27-value-object-money/test_example.py
"""Example 27: pytest verification for Value Object: Immutable Money with Value Equality."""
import pytest # => pytest.raises asserts a specific exception is raised
from example import Money
def test_equal_amounts_compare_equal_even_as_separate_objects() -> None:
ten_usd: Money = Money(1000, "usd")
same_value: Money = Money(1000, "usd") # => a distinct object, identical field values
assert ten_usd == same_value # => value equality, generated by frozen=True
assert ten_usd is not same_value # => two genuinely different objects nonetheless
def test_money_is_immutable_after_construction() -> None:
ten_usd: Money = Money(1000, "usd")
with pytest.raises(AttributeError): # => frozen=True raises rather than allowing mutation
ten_usd.amount = 2000 # type: ignore # => deliberately triggers the frozen guard this example teaches
# => Run: pytest -- Output: 2 passedVerify: pytest -q
Output:
2 passedKey takeaway: Two separately constructed Money(1000, "usd") objects compare equal with == while remaining distinct objects under is, because @dataclass(frozen=True) generates value-based equality automatically.
Why it matters: Value objects like Money are cohesive by construction: every field and every method on the class exists purely to represent and manipulate one concept, an amount of currency, with no unrelated state ever creeping in. Immutability additionally means add() must return a brand-new Money rather than mutating either operand, which eliminates an entire category of aliasing bugs where two variables unexpectedly point at the same, now-changed, monetary value -- a correctness property that matters enormously anywhere money is actually being calculated.
← Previous: Overview · Next: Intermediate Examples →
Last updated July 16, 2026