Skip to content

← Capstone Projects step 4 of 6

Hard End-to-End

Capstone: Build an Immutable Order Model from Untrusted Data

Build an order type that nobody can change once it exists. You parse it from untrusted data such as decoded JSON, change it only by making new orders, and turn it back into a plain dictionary.

order = parse_order({
    "id": "o-1",
    "customer_email": "ada@example.com",
    "currency": "GBP",
    "lines": [{"sku": "widget", "quantity": 2, "unit_price": "9.99"}],
})
bolts = OrderLine(
    sku="bolt",
    quantity=3,
    unit_price=Money(amount=Decimal("0.50"), currency=Currency.GBP),
)
bigger = order.with_line(bolts)
placed = bigger.transition(Status.PLACED)
print(order.total.amount, bigger.total.amount)  # 19.98 21.48
print(bigger.status, placed.status)  # draft placed
print(Codec.dump(placed)["lines"][1])
# {'sku': 'bolt', 'quantity': 3, 'unit_price': '0.50'}

with_line and transition return new orders and leave the old ones as they were. Bad input raises InvalidOrder with the path of the field that failed, such as "lines[0].quantity".

What to write

Fill in entity, Money, OrderLine, Order, parse_order, Codec and _parse_line. The starter supplies the Currency and Status enums, OrderId, the TRANSITIONS table, both exceptions and the driver. Make the file pass mypy --strict on Python 3.13 or newer.

Write one decorator for all three classes

The dataclass_transform exercise builds a decorator like this one.

  • entity applies dataclass(frozen=True, kw_only=True, slots=True) to the class. Frozen means that assigning to a field raises an error. Keyword-only means that callers pass every field by name. Slots means that instances have no __dict__.
  • It registers the class in REGISTRY under the class's name, so REGISTRY["Order"] is Order.
  • With slots=True, dataclass returns a new class. Register and return that new class, not the one passed in.
  • Decorate entity with @dataclass_transform(frozen_default=True, kw_only_default=True, field_specifiers=(field,)). It tells mypy that classes made with entity are frozen, keyword-only dataclasses, and that field(...) sets their field options.

Define the three classes

Decorate each class with @entity and give it these fields:

Class Fields
Money amount: Decimal, currency: Currency
OrderLine sku: str, quantity: int, unit_price: Money
Order id: OrderId, customer_email: str, currency: Currency, status: Status = Status.DRAFT, lines: tuple[OrderLine, ...] = (), total: Money
  • Store lines as a tuple of OrderLine objects. Then nobody can append to it, and the whole order is hashable, so it can be a dictionary key.
  • Declare customer_email with field(repr=False), so the email stays out of repr(order) and out of log lines that print the order.

Compute the total

A derived field is one that the class computes from its other fields. The caller never passes it in. The __post_init__ exercise computes one the same way.

  • Adding two Money values with + adds their amounts. If the currencies differ, raise InvalidOrder("currency").
  • Money.times(count) multiplies the amount by count.
  • OrderLine.subtotal is a property that returns the unit price times the quantity.
  • Order.total is a derived field. Declare it with field(init=False).
  • In __post_init__, add up the line subtotals, starting from zero in the order's currency. The class is frozen, so set the field with object.__setattr__(self, "total", ...).
  • If any of this arithmetic raises decimal.DecimalException, or gives an amount that is not finite, raise InvalidOrder("total").

Change an order by making a new one

The exercise on updating a frozen record with replace() covers this.

  • with_line(line) -> Self returns a new order with line added at the end. Build it with dataclasses.replace, which calls __init__ again, so __post_init__ computes the new total.
  • transition(to) -> Self returns a new order with status to, if TRANSITIONS allows the change from the current status. Otherwise it raises IllegalTransition. For example, a draft can become placed or cancelled, but not shipped.
  • Both methods leave the original order unchanged.

Check untrusted data at the boundary

The boundary is the place where outside data enters your code. Check everything there, and the rest of your code can trust any Order it gets. The exercise on parsing untrusted JSON into a dataclass covers this.

  • parse_order(raw: object) -> Order accepts a mapping with string keys. If raw is not a mapping, or one of its keys is not a string, raise InvalidOrder("$"). "$" stands for the whole document.
  • Every error is an InvalidOrder whose field_name is the path of the bad field, such as "customer_email" or "lines[0].quantity". Never let a KeyError, a TypeError or a decimal error escape.
  • Check for unknown keys before you check any values. The order allows only id, customer_email, currency, status and lines. Report any other key by its name, as in InvalidOrder("discount").
  • A total key is unknown too, because the order computes its own total.
  • id and customer_email must be strings. You don't need to check anything else about them.
  • currency must be "GBP", "USD" or "EUR".
  • status defaults to "draft". If present, it must be the value of a Status member.
  • lines defaults to an empty list. If present, it must be a list.

Check each line the same way. Every path inside a line starts with the line's own path, such as "lines[0]".

  • A line must be a mapping with string keys. Otherwise report the line's own path.
  • A line allows only sku, quantity and unit_price. Report any other key with its full path, such as "lines[0].colour".
  • sku must be a string.
  • quantity must be an int of at least 1. Reject True and False, which Python counts as ints.
  • unit_price must be an int or a string that holds a number, such as "9.99". Reject a bool, a float and a string that is not a number.
  • Also reject a price that is not finite, such as "NaN", "sNaN" or "Infinity". Accept negative prices.
  • Each line's prices use the order's currency.
  • _parse_line(raw, currency, name) parses one line, and name is the start of its paths. The driver passes name="added" for the line it adds, so its errors have paths such as "added.quantity".

Write the dictionary out yourself

  • Codec.dump(order) returns a dictionary with exactly the keys id, customer_email, currency, status and lines. Leave out total.
  • Write currency and status as their string values. Write lines as a list of dictionaries, with each unit_price as str(amount), such as "9.99".
  • Codec.load(raw) returns parse_order(raw). Loading a dump gives back an order equal to the one you dumped.

Don't use dataclasses.asdict here. It would include total, keep the Decimal and enum objects, and turn each price into a nested dictionary.

Keep the starter's type checks

  • Leave _negative_fixtures as it is. Nothing calls it. Each of its three lines must stay a type error. They call a constructor with positional arguments, assign to a frozen field, and call append on a tuple.
  • mypy --strict reports a # type: ignore comment that hides no error. So these lines fail the type check unless your classes are keyword-only and frozen, and lines is a tuple.
  • The driver has one more # type: ignore, for its own append test. Add none to your code.

What the driver checks

Keep solve, _negative_fixtures and _EMPTY_LINE as they are. The driver parses raw. When a test supplies add_line, the driver parses that line with _parse_line and adds it with with_line. When a test supplies transition, the driver applies that change.

  • ok is False when a step fails. error then holds the InvalidOrder path, "transition" for an IllegalTransition, or "status" for a transition name that is not a status.
  • total is str(order.total.amount), such as "19.98". status is the final status, and line_count is the number of lines.
  • hashable is True when the order works as a dictionary key.
  • frozen is True when setting status raises an error.
  • lines_immutable is True when order.lines has no append method.
  • slotted is True when the order has no __dict__.
  • email_in_repr is the repr option of the customer_email field. It must be False.
  • email_in_dump is True when the dump holds the email.
  • roundtrip is True when Codec.load(Codec.dump(order)) == order.
  • registry_ok is True when REGISTRY holds the decorated Order, OrderLine and Money classes.

Going further

  • frozen=True stops assignment to the order's own fields only. A list inside a frozen object can still change, so lines is a tuple of frozen lines.
  • repr=False hides the email from repr() only. The dump still holds it.
  • Decimal arithmetic follows the current decimal context, and this exercise adds no rounding. Real prices often need rounding to the currency's smallest unit, with Decimal.quantize.
  • A round trip can pass even when dump and load agree on the wrong key names. In your own tests, check the dump's keys and strings too.
  • The Python documentation covers dataclasses and decimal.