Skip to content

← Data Modelling and Invariants step 20 of 25

Hard End-to-End

Parse at the boundary, dataclasses in the core

Once BaseModel is your domain type, your business logic is coupled to a serialization library’s field semantics, its upgrade cadence and its performance envelope. Once dict[str, Any] is your domain type, you pay for the missing schema in incidents instead. The discipline that avoids both is one sentence:

Parse once at the edge, then work with frozen dataclasses.

Three shapes, and what each costs

Measured on 3.14.6, three int fields, min-of-3, construct / ==:

shape numbers what you get
pydantic.BaseModel 354.8 ns / 224.9 ns full feature set, model_dump, JSON Schema — and your domain now imports pydantic
pydantic.dataclasses.dataclass 248.5 / 99.0 dataclass ergonomics + validation, but the docs are explicit that model_dump and JSON-Schema are not available; you need a TypeAdapter for those
TypeAdapter over a plain stdlib dataclass dataclass speed in the core validation and schema at the boundary, domain module dependency-free
hand-written parser fastest, most code zero dependency; right for a small, stable schema

Compare against a plain dataclass at 43.5 ns / 45.8 ns and the shape of the decision is clear: pay for validation once, where untrusted bytes arrive, and never again on the ten million internal constructions that follow.

Caveats worth carrying: a parameterised generic pydantic dataclass is treated as [Any] unless you go through TypeAdapter; __post_init__ runs between before- and after-validators, which is not where most people assume; and pydantic coerces by default"3" becomes 3 unless you ask for strict mode. That last one is the difference between a boundary that catches a broken producer and one that quietly repairs it and hides the outage.

This exercise builds the fourth row — the hand-written parser — because it makes the contract explicit. Everything a TypeAdapter does for you, you are about to do by hand once, so you can recognise it when a library does it.

The signature is the pattern

def parse_event(raw: object) -> Event: ...

object in — not dict[str, Any], which would let Any leak into every expression that touches the input. A fully-typed frozen dataclass out. Nothing in between is Any, and no partially-valid object is ever constructed: you either get an Event that satisfies every invariant or you get an exception.


Your task

Implement parse_event(raw: object) -> Event, raising only InvalidEvent (never a KeyError, TypeError or AttributeError from a failed cast), with these rules and these exact messages:

  • not a mapping → InvalidEvent("<root>", f"expected object, got {type(raw).__name__}");
  • any key that is not a field nameInvalidEvent(<first such key, sorted>, "unknown key"), checked before anything else. Silently ignoring unknown keys is how a producer’s renamed field becomes a month of missing data;
  • a missing key → InvalidEvent(key, "missing");
  • a wrong type → InvalidEvent(key, f"expected <type>, got {type(value).__name__}"), with <type> one of str, int, float, list, object;
  • an unknown enum value → InvalidEvent("kind", f"unknown kind {value!r}");
  • tags must be a list of str and is stored as a tuple;
  • meta is optional: absent or nullNone; otherwise an object of str -> str, or InvalidEvent("meta", "expected object of str -> str").

Strict, not coercive. "3" is not a valid count. 1 is not a valid ratio — an int where a float is declared is exactly the kind of drift a boundary exists to catch. And True is not a valid count, even though isinstance(True, int) is True: check bool first, or every boolean in your input silently becomes 0 or 1.

solve(raw) returns {"error": "", "event": {...}} with the event rendered as plain JSON types, or {"error": "<message>", "event": None}.

Note what the Event module needs to import to do all this: dataclasses, enum, collections.abc. That is the entire point.