Skip to content

← Capstones step 3 of 5

Hard End-to-End

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_onlyOrderLine("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,000 OrderLine objects that is the difference between fitting in memory and not, and a typo’d attribute becomes an AttributeError instead 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.