We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Capstones step 3 of 5
Capstone: an immutable domain model that survives review
This is what “production Python a senior reviewer would approve” actually looks like for data modelling — and, more usefully, it is where the decisions you have met individually start interacting.
Build order_model exposing Money, Currency, OrderId, OrderLine,
Order, entity, parse_order and Codec.
House style, applied without exception
Every domain type is frozen=True, kw_only=True, slots=True.
-
frozen— an order that can be mutated in place is an order whose history you cannot reconstruct, and a value that cannot be a dict key. -
kw_only—OrderLine("widget", 2, price)reads fine until someone swaps two arguments of the same type. Named arguments make that impossible and make adding a field a non-breaking change. -
slots— no per-instance__dict__. At 100,000OrderLineobjects that is the difference between fitting in memory and not, and a typo’d attribute becomes anAttributeErrorinstead of a silently-created field nobody reads.
Rather than repeat the decorator, write @entity once — and annotate it with
@dataclass_transform(frozen_default=True, kw_only_default=True, field_specifiers=(field,)) so the type checker knows what it produces. Without
the annotation your classes are, as far as mypy is concerned, ordinary classes
with some annotations: no synthesised __init__, no frozen-assignment error,
no keyword-only enforcement. The decorator is the runtime half; the
dataclass_transform is the static half, and you need both.
The interactions that bite
slots=True breaks a naive registry. dataclass(slots=True) cannot add
__slots__ to an existing class, so it constructs a new class object and
returns that. If entity registers cls before applying the transform, the
registry holds the pre-transform husk — a different object from the one every
other module imports. Register what the transform returned. The driver checks
identity, not names, so this is not a detail you can talk your way past.
frozen=True does not make a list field safe. Freezing prevents rebinding
the attribute; it says nothing about the object the attribute points at.
order.lines.append(...) mutates a frozen order and also makes it unhashable.
Use a tuple. Hashability then comes for free, and the driver puts the order in
a dict to prove it.
asdict() would triple your serialisation cost. It deep-copies the entire
tree, Decimal leaves and all, on every call. Write Codec.dump by hand:
explicit, cheap, and it is the only place that decides Decimal becomes a
string rather than a float. Serialising money as a float is a data-corruption
bug with a long fuse.
A derived field is not a property here. total is field(init=False),
computed in __post_init__ with object.__setattr__ — the one sanctioned way
to write to a frozen instance. And because dataclasses.replace() re-runs
__init__, and therefore __post_init__, order.with_line(line) recomputes
the total instead of copying the stale one. That is the property the second test
case exists to catch.
One measured typing fact
3.13 added the __replace__ protocol and copy.replace(), and it is the more
general spelling. But under mypy 2.3, copy.replace(self, ...) inside a method
declared -> Self is a return-value error — the return type comes back as
the concrete class. dataclasses.replace(self, ...) goes through mypy’s
dataclass plugin and preserves Self. So the Self-returning builder methods
use dataclasses.replace. Knowing which spelling your checker actually
understands is not pedantry; it is the difference between a clean run and three
spurious ignores.
The boundary
parse_order(raw: object) -> Order is a T13 boundary function. Unknown keys are
rejected with a key path (lines[0].colour). Every failure is
InvalidOrder — never a KeyError, never a decimal.InvalidOperation, never
anything from a third-party library. A caller who catches your exception type
should not need to know what you parse with.
Codec.load(Codec.dump(order)) == order for every order the tests generate.
That round-trip is the only honest test of a serialiser.
The negative fixtures
OrderLine("sku", 1, line.unit_price) # type: ignore[call-arg]
line.quantity = 2 # type: ignore[misc]
order.lines.append(line) # type: ignore[attr-defined]
Never executed, always graded. --strict includes --warn-unused-ignores, so
these three lines assert, respectively, that construction really is keyword-only,
that instances really are frozen, and that lines really is a tuple. Drop
kw_only, drop frozen, or reach for a list, and the corresponding ignore
goes unused and the submission fails to type-check — even though every
behavioural test still passes.
mypy --strict, zero ignores beyond those three.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.