Overview
Goal
Model a small payments-ledger domain as a clean object model -- an encapsulated invariant, a
polymorphic operation, a @dataclass value object, and one composition-over-inheritance refactor --
as a runnable, pytest-tested package. This is a light consolidation, not a new project: every
mechanism it combines was already taught, individually, somewhere in the Beginner, Intermediate, or
Advanced tiers of this topic.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
flowchart TD
A["Money#40;frozen dataclass#41;<br/>value object, co-06/co-05"]:::blue
B["Account<br/>encapsulated invariant, co-02/co-17"]:::orange
C["PaymentMethod#40;abc.ABC#41;<br/>CardPayment / BankTransferPayment, co-11/co-10"]:::teal
D["LedgerNaive -> Ledger<br/>composition refactor, co-13"]:::purple
A --> B
B --> C
B --> D
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
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
Concepts exercised
- encapsulated invariant enforced in
__init__/setters - polymorphism via a shared method across subclasses/duck types
-
@dataclassvalue object with__eq__/__hash__ - composition over inheritance
- an
abc.ABCinterface
All colocated code lives under learning/capstone/code/: the domain model in domain/, and the full
pytest suite in tests/. Every listing below is the complete file, verbatim -- nothing on this page
is truncated or paraphrased.
Step 1: A value object and an entity with an invariant
exercises co-02, co-05, co-06, co-07, co-17
domain/money.py defines Money, a @dataclass(frozen=True) value object: immutable, so
__post_init__ can enforce "amount is never negative" and "currency is always a 3-letter code" once,
at construction, with no later code path able to violate either rule. Because frozen=True pairs with
the default eq=True, Python auto-generates a __hash__ consistent with the auto-generated __eq__ --
Money instances compare by value and dedupe correctly inside a set.
learning/capstone/code/domain/money.py (complete file)
"""Capstone: Money -- a frozen, hashable value object with a non-negative-amount invariant.
co-06 (dataclass value object): frozen=True gives immutability, and because eq=True
(the default) is paired with frozen=True, Python auto-generates a __hash__ consistent
with the auto-generated __eq__ -- co-05's eq/hash contract, satisfied for free.
co-17 (invariant enforcement): __post_init__ rejects a negative amount or a malformed
currency code the moment a Money is constructed, so no invalid Money can ever exist.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(
frozen=True
) # => frozen -> immutable AND auto __hash__ alongside auto __eq__
class Money:
"""An immutable amount of money, stored in integer cents to avoid float rounding error."""
amount: (
int # => whole cents -- never a float, so equality never suffers rounding drift
)
currency: str = "USD" # => a default keeps most call-sites in this capstone terse
def __post_init__(
self,
) -> None: # => runs once, right after the frozen fields are set
if (
self.amount < 0
): # => co-17: the invariant -- a Money can never represent a negative amount
raise ValueError(f"Money amount cannot be negative, got {self.amount}")
if (
len(self.currency) != 3
): # => co-17: a second invariant -- currency must be a 3-letter code
raise ValueError(f"currency must be a 3-letter code, got {self.currency!r}")
def __add__(self, other: Money) -> Money: # => defines the __add__() method
if (
self.currency != other.currency
): # => guards against silently mixing currencies
raise ValueError("cannot add Money in different currencies")
return Money(
self.amount + other.amount, self.currency
) # => returns a NEW Money -- frozen, no mutationdomain/account.py defines Account, an entity that encapsulates its own balance: _balance only
ever changes through deposit/withdraw, and both methods enforce the same "balance never goes
negative" rule on every call, not only at construction.
learning/capstone/code/domain/account.py (complete file)
"""Capstone: Account -- an entity that encapsulates its own balance invariant.
co-02 (encapsulation): `_balance` only ever changes through `deposit`/`withdraw`, never
through direct assignment -- the class is the single place "balance never goes negative"
is enforced. co-07 (properties): `balance` is a read-only computed view over the private
field. co-17 (invariant enforcement): the same overdraft guard applies on every mutating
path, not just at construction time.
"""
from __future__ import annotations
from domain.money import Money
class Account:
"""A named account holding a non-negative Money balance."""
def __init__(
self, owner: str, opening_balance: Money
) -> None: # => the constructor
self._owner: str = owner # => stores owner on this instance
self._balance: Money = (
opening_balance # => co-02: private -- never assigned to directly again
)
@property # => marks the next method as a computed, read-only attribute
def owner(self) -> str: # => defines the owner() method
return self._owner # => returns this value to the caller
@property # => marks the next method as a computed, read-only attribute
def balance(self) -> Money: # => defines the balance() method
return (
self._balance
) # => co-07: read-only view -- callers cannot do account.balance = ...
def deposit(self, amount: Money) -> None: # => defines the deposit() method
if amount.amount <= 0: # => co-17: rejects a zero or negative deposit outright
raise ValueError("deposit amount must be positive")
self._balance = (
self._balance + amount
) # => the ONLY line in this class that grows the balance
def withdraw(self, amount: Money) -> None: # => defines the withdraw() method
if (
amount.amount <= 0
): # => co-17: rejects a zero or negative withdrawal outright
raise ValueError("withdraw amount must be positive")
if (
amount.currency != self._balance.currency
): # => co-17: same currency guard deposit gets for free via Money.__add__
raise ValueError("cannot withdraw Money in a different currency")
if (
amount.amount > self._balance.amount
): # => co-17: the core invariant -- no overdraft, ever
raise ValueError("insufficient funds")
self._balance = Money(
self._balance.amount - amount.amount, self._balance.currency
)
# => the ONLY line in this class that shrinks the balance -- always via a fresh Moneylearning/capstone/code/tests/test_money.py (complete file)
"""Capstone: pytest coverage for Money's invariant and eq/hash contract."""
import pytest
from domain.money import Money
def test_money_equal_amounts_compare_equal() -> None:
assert Money(500) == Money(500) # => co-05: value equality, not identity
def test_money_is_hashable_and_dedups_in_a_set() -> None:
assert (
len({Money(500), Money(500), Money(300)}) == 2
) # => frozen -> auto __hash__ alongside __eq__
def test_money_rejects_negative_amount() -> None:
with pytest.raises(
ValueError
): # => co-17: __post_init__ rejects this before construction completes
Money(-1)
def test_money_rejects_bad_currency_code() -> None:
with pytest.raises(
ValueError
): # => co-17: the second invariant -- currency must be 3 letters
Money(100, "US")
def test_money_add_combines_same_currency() -> None:
assert Money(200) + Money(300) == Money(
500
) # => __add__ returns a NEW, still-valid Money
def test_money_add_rejects_mismatched_currency() -> None:
with pytest.raises(ValueError): # => guards against silently mixing currencies
_ = Money(200, "USD") + Money(
300, "EUR"
) # => discarding the result -- only the raise matters
# => Run: pytest -- Output: 6 passedlearning/capstone/code/tests/test_account.py (complete file)
"""Capstone: pytest coverage for Account's encapsulated balance invariant."""
import pytest
from domain.account import Account
from domain.money import Money
def test_account_deposit_increases_balance() -> None:
account: Account = Account("Alice", Money(1000))
account.deposit(Money(500))
assert account.balance == Money(1500) # => the only sanctioned way balance grows
def test_account_withdraw_decreases_balance() -> None:
account: Account = Account("Alice", Money(1000))
account.withdraw(Money(400))
assert account.balance == Money(600) # => the only sanctioned way balance shrinks
def test_account_rejects_overdraft() -> None:
account: Account = Account("Alice", Money(100))
with pytest.raises(
ValueError
): # => co-17: the core invariant -- no overdraft, ever
account.withdraw(Money(200))
def test_account_rejects_negative_opening_balance() -> None:
with pytest.raises(
ValueError
): # => Money itself rejects this before Account even runs
Account("Alice", Money(-50))
def test_account_rejects_non_positive_deposit() -> None:
account: Account = Account("Alice", Money(100))
with pytest.raises(
ValueError
): # => co-17: the same guard fires on the deposit path too
account.deposit(Money(0))
def test_account_rejects_mismatched_currency_withdraw() -> None:
account: Account = Account("Alice", Money(1000, "USD"))
with pytest.raises(
ValueError
): # => co-17: withdraw guards currency match too, same as deposit does via Money.__add__
account.withdraw(Money(500, "JPY"))
# => Run: pytest -- Output: 6 passedVerify
$ pytest -q tests/test_money.py tests/test_account.py
12 passedStep 2: An abc.ABC interface with two polymorphic implementations
exercises co-10, co-11
domain/payment_method.py defines PaymentMethod, an abc.ABC interface: it cannot be instantiated
on its own, and Python enforces that at construction time, not merely by convention. CardPayment and
BankTransferPayment each implement process independently, and process_payment is one call-site,
typed against the interface, that dispatches correctly to whichever concrete implementation it is
handed -- with zero branching on which concrete type it received.
learning/capstone/code/domain/payment_method.py (complete file)
"""Capstone: PaymentMethod -- an ABC interface with two polymorphic implementations.
co-11 (abstraction / ABC): PaymentMethod cannot be instantiated on its own -- Python
enforces this at construction time. co-10 (polymorphism): `process_payment` is ONE
call-site that works identically whether it is handed a CardPayment or a
BankTransferPayment, with zero branching on which concrete type it received.
"""
from __future__ import annotations
import abc
from domain.account import Account
from domain.money import Money
class PaymentMethod(
abc.ABC
): # => abc.ABC marks this as an INTERFACE, never directly instantiable
@abc.abstractmethod # => marks the next method as a REQUIRED contract for every subclass
def process(
self, account: Account, amount: Money
) -> str: # => no body -- subclasses supply one
...
class CardPayment(PaymentMethod): # => CardPayment extends PaymentMethod
def __init__(
self, last4: str
) -> None: # => the constructor -- runs once, automatically
self.last4: str = last4 # => stores last4 on this instance
def process(
self, account: Account, amount: Money
) -> str: # => defines the process() method
account.deposit(
amount
) # => delegates to Account's own guarded deposit -- co-17 still applies
return f"card ending {self.last4} deposited {amount.amount} {amount.currency}"
class BankTransferPayment(
PaymentMethod
): # => BankTransferPayment extends PaymentMethod
def __init__(
self, iban: str
) -> None: # => the constructor -- runs once, automatically
self.iban: str = iban # => stores iban on this instance
def process(
self, account: Account, amount: Money
) -> str: # => defines the process() method
account.deposit(
amount
) # => the SAME Account.deposit call-site CardPayment also uses
return f"bank transfer {self.iban} deposited {amount.amount} {amount.currency}"
def process_payment(method: PaymentMethod, account: Account, amount: Money) -> str:
# => co-10: ONE function, typed against the INTERFACE -- never a concrete subclass
return method.process(
account, amount
) # => dispatches to whichever concrete class was actually passedlearning/capstone/code/tests/test_payment_method.py (complete file)
"""Capstone: pytest coverage proving PaymentMethod is polymorphic across implementations."""
import pytest
from domain.account import Account
from domain.money import Money
from domain.payment_method import (
BankTransferPayment,
CardPayment,
PaymentMethod,
process_payment,
)
def test_payment_method_cannot_be_instantiated_directly() -> None:
with pytest.raises(
TypeError
): # => co-11: an ABC with an unimplemented method always rejects this
PaymentMethod() # type: ignore # => deliberately triggers the ABC instantiation guard
def test_card_payment_deposits_into_account() -> None:
account: Account = Account("Alice", Money(0))
result: str = process_payment(CardPayment("4242"), account, Money(500))
assert account.balance == Money(
500
) # => the deposit genuinely landed on the account
assert (
"4242" in result
) # => the concrete implementation's own detail is still visible in the result
def test_bank_transfer_payment_deposits_into_account() -> None:
account: Account = Account("Alice", Money(0))
result: str = process_payment(BankTransferPayment("DE89"), account, Money(700))
assert account.balance == Money(
700
) # => the SAME call-site, a DIFFERENT concrete implementation
assert "DE89" in result
def test_process_payment_call_site_is_implementation_agnostic() -> None:
account: Account = Account("Alice", Money(0))
methods: list[PaymentMethod] = [CardPayment("0000"), BankTransferPayment("XX00")]
for (
method
) in methods: # => co-10: ONE loop body, dispatching polymorphically per element
process_payment(method, account, Money(100))
assert account.balance == Money(
200
) # => both payments landed, regardless of concrete type
# => Run: pytest -- Output: 4 passedVerify
$ pytest -q tests/test_payment_method.py
4 passedStep 3: Refactor a naive inheritance chain into composition
exercises co-13
domain/ledger_naive.py's LedgerNaive(list[Money]) is deliberately naive: subclassing list
inherits every list method, including insert, which a real ledger was never meant to expose --
test_ledger_naive_leaks_list_insert proves the leak actually mutates ledger state through a
non-ledger method. domain/ledger.py's Ledger is the composition-based fix: it holds a
list[Money] instead of being one, keeping record/total behavior identical while insert and
every other leaked method are simply gone.
learning/capstone/code/domain/ledger_naive.py (complete file)
"""Capstone: LedgerNaive -- the naive "is-a list" version this capstone refactors away.
Kept only as a reference: `domain/ledger.py`'s Ledger is the composition-based fix that
replaces it, with identical `record`/`total` behavior and none of the leaked interface.
"""
from __future__ import annotations
from domain.money import Money
class LedgerNaive(
list[Money]
): # => is-a list[Money] -- inherits EVERY list method, wanted or not
def record(self, entry: Money) -> None: # => defines the record() method
self.append(
entry
) # => reuses list.append -- convenient, but see the leak below
def total(self) -> int: # => defines the total() method
return sum(
entry.amount for entry in self
) # => sums every recorded entry's amountlearning/capstone/code/domain/ledger.py (complete file)
"""Capstone: Ledger -- the composition-over-inheritance refactor of LedgerNaive.
co-13 (composition over inheritance): Ledger HOLDS a list[Money] instead of BEING one --
`record`/`total` behave identically to LedgerNaive, but `insert`, `sort`, `reverse`, and
every other list method LedgerNaive accidentally exposed are simply not here anymore.
"""
from __future__ import annotations
from domain.money import Money
class Ledger: # => has-a list[Money] -- never subclasses list
def __init__(
self,
) -> None: # => the constructor -- runs once, automatically, per instantiation
self._entries: list[
Money
] = [] # => a private collaborator, not an inherited interface
def record(self, entry: Money) -> None: # => defines the record() method
self._entries.append(
entry
) # => delegates to the list, but does not EXPOSE the list
def total(self) -> int: # => defines the total() method
return sum(
entry.amount for entry in self._entries
) # => same computation as LedgerNaive.total
def __len__(self) -> int: # => defines the __len__() method
return len(
self._entries
) # => len(ledger) still works -- deliberately re-exposed, not leakedlearning/capstone/code/tests/test_ledger.py (complete file)
"""Capstone: pytest coverage proving Ledger's composition refactor preserves LedgerNaive's behavior."""
from domain.ledger import Ledger
from domain.ledger_naive import LedgerNaive
from domain.money import Money
def test_ledger_naive_records_and_totals() -> None:
ledger: LedgerNaive = LedgerNaive()
ledger.record(Money(500))
ledger.record(Money(300))
assert (
ledger.total() == 800
) # => baseline behavior, BEFORE the composition refactor
def test_ledger_naive_leaks_list_insert() -> None:
ledger: LedgerNaive = LedgerNaive()
ledger.record(Money(500))
ledger.insert(
0, Money(999)
) # => the SMELL: insert() was never meant to be part of a ledger's API
assert (
ledger.total() == 1499
) # => the leak actually mutated ledger state via a non-ledger method
def test_ledger_records_and_totals_matches_naive_behavior() -> None:
ledger: Ledger = Ledger()
ledger.record(Money(500))
ledger.record(Money(300))
assert (
ledger.total() == 800
) # => SAME behavior as LedgerNaive -- tests still green after the refactor
def test_ledger_has_no_leaked_list_interface() -> None:
ledger: Ledger = Ledger()
assert not hasattr(
ledger, "insert"
) # => the smell from LedgerNaive no longer exists on Ledger
# => Run: pytest -- Output: 4 passedVerify
$ pytest -q tests/test_ledger.py
4 passedRun it end to end
Running the full suite from learning/capstone/code/ exercises every file this capstone ships in one
pass.
Run: pytest -q
Output (genuinely captured):
20 passedThe following interactive session (genuinely captured, using the same domain package the test suite
imports) shows every concept working together: a Money value object, an Account enforcing its
overdraft invariant, two polymorphic PaymentMethod implementations dispatched through one call-site,
and a Ledger recording both payments.
>>> from domain.account import Account
>>> from domain.money import Money
>>> from domain.payment_method import CardPayment, BankTransferPayment, process_payment
>>> from domain.ledger import Ledger
>>> account = Account("Alice", Money(0))
>>> ledger = Ledger()
>>> for method, amount in [(CardPayment("4242"), Money(500)), (BankTransferPayment("DE89"), Money(700))]:
... result = process_payment(method, account, amount)
... ledger.record(amount)
... print(result)
...
card ending 4242 deposited 500 USD
bank transfer DE89 deposited 700 USD
>>> account.balance.amount, account.balance.currency
(1200, 'USD')
>>> ledger.total()
1200
>>> account.withdraw(Money(999999))
Traceback (most recent call last):
...
ValueError: insufficient fundsQuality gates: pyright --pythonversion 3.13 . (from inside learning/capstone/code/) reports
0 errors, 0 warnings, 0 informations; mypy --python-version 3.10 --explicit-package-bases . reports
Success: no issues found in 11 source files.
Acceptance criteria
pytest -q(fromlearning/capstone/code/) reports20 passed, coveringMoney's invariant and eq/hash contract,Account's encapsulated overdraft and currency-match invariants,PaymentMethod's polymorphic dispatch across two implementations, andLedgerNaive's leaked interface alongsideLedger's composition-based fix.- Invariants cannot be violated:
Money(-1),Account("Alice", Money(-50)),account.withdraw(Money(too_much)),account.withdraw(Money(amount, "JPY"))against a USD balance, andaccount.deposit(Money(0))all raiseValueError-- every path is checked, not only the constructor. - The polymorphic call-site (
process_payment) is implementation-agnostic: it is typed againstPaymentMethod, never againstCardPaymentorBankTransferPaymentconcretely, and dispatches correctly to whichever one it receives. Moneyequality and hashing behave correctly: equal amounts compare equal, and equalMoneyinstances dedupe inside aset.pyright --pythonversion 3.13 .andmypy --python-version 3.10 --explicit-package-bases .both report zero findings.
Done bar
This capstone is runnable end to end: a reader who copies the six files above (domain/money.py,
domain/account.py, domain/payment_method.py, domain/ledger_naive.py, domain/ledger.py, plus the
four tests/test_*.py files) into a learning/capstone/code/-shaped tree and runs pytest -q there
reaches the identical 20 passed result shown above, verified against a real CPython 3.13.12
interpreter run (not merely described). Every mechanism combined here -- an encapsulated invariant
(co-02, co-17), polymorphism via a shared method (co-10), a @dataclass value object with __eq__/
__hash__ (co-05, co-06), composition over inheritance (co-13), and an abc.ABC interface (co-11) --
traces to the same docs.python.org sources already cited in this topic's Accuracy notes; no new fact
was needed to write this page.
← Previous: Advanced Examples · Next: Drilling →
Last updated July 13, 2026