Software Architecture · Domain Modeling
Invariants and Illegal States: Making Invalid Combinations Unrepresentable
How sum types, smart constructors, and parse-at-the-boundary design remove whole classes of runtime checks, and where the technique stops paying for itself.
An order record with status: "SHIPPED" and shippedAt: null is not a bug in one function. It is a bug in the type. The type admits four combinations of a two-valued status and a nullable timestamp, and only two of those combinations mean anything. Every function that reads the record must therefore decide what the other two mean, and each function decides differently: one throws, one treats a missing timestamp as “not yet shipped” and ignores the status, one writes null into a downstream system that rejects it. The invariant exists, but it lives in the heads of the people who wrote the readers.
The reachable state space is the model
A type is a set of values. Its usefulness as a model is bounded by how closely that set matches the states the domain permits. Add a nullable field and the set roughly doubles. Add a second nullable field and it doubles again. Three boolean flags produce eight combinations, of which perhaps two are meaningful, and the six meaningless ones are reachable by any code that can construct the record — including deserialization code, test fixtures, and the ORM.
The alternative is to make each meaningful state a distinct shape. Instead of a status discriminant plus a bag of optional fields, the type itself becomes a choice: an order is either unshipped (with its creation time and items), or shipped (with its dispatch time and carrier), or cancelled (with its reason and cancellation time). In TypeScript that is a discriminated union; in Rust an enum with payloads; in F# a discriminated union; in Java a sealed interface with one record per case; in Python a union of dataclasses checked by a match statement. The languages differ in ceremony, not in the underlying idea: the constructor set is closed, and the compiler enforces that every consumer handles every case.
type Order =
| { kind: "draft"; createdAt: Date; lines: Line[] }
| { kind: "placed"; placedAt: Date; lines: Line[]; paymentRef: string }
| { kind: "shipped"; placedAt: Date; shippedAt: Date; carrier: string; lines: Line[] }
| { kind: "cancelled"; cancelledAt: Date; reason: string };
function ship(order: Order, at: Date, carrier: string): Order {
// The draft case has no placedAt to carry forward, so it is rejected here
// rather than producing a shipped order with a missing timestamp.
if (order.kind !== "placed") throw new Error(`cannot ship a ${order.kind} order`);
return { kind: "shipped", placedAt: order.placedAt, shippedAt: at, carrier, lines: order.lines };
}
Two properties follow without further work. The impossible state has no representation, so it cannot be persisted, logged, or sent over the wire. And adding a state — a returned case, say — turns every non-exhaustive switch into a compile error, which is a list of every place that must be reconsidered. That second property is the real payoff in a long-lived model: the compiler enumerates the consequences of a domain change.
Parsing at the boundary instead of checking everywhere
The technique only holds if unvalidated data cannot enter the typed region. In practice it enters through four doors: HTTP request bodies, message payloads, database rows, and configuration files. If any door admits a raw object that is cast into the domain type, the invariant is gone and the casts hide it.
The discipline that closes the doors is to have one function per door that takes untyped input and returns either a domain value or a structured error. Inside that function, validation is explicit and local. Outside it, no code checks again, because the type already guarantees the property. This is the difference between “validate and continue with the same loose type” and “parse and continue with a narrower type”: the first leaves the check as advice, the second converts it into a fact the compiler tracks.
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class BillingPeriod:
start: date
end: date
def __post_init__(self):
if self.end <= self.start:
raise ValueError("end must be after start")
def parse_period(raw: dict) -> BillingPeriod:
# Only this function sees untrusted field values.
return BillingPeriod(
start=date.fromisoformat(raw["start"]),
end=date.fromisoformat(raw["end"]),
)
The boundary function is also the right place for the checks the type system cannot express: referential existence, uniqueness, and authorization. A CustomerId that parses to a well-formed identifier may still refer to no customer. Whether that check belongs in the parser depends on whether the invariant is local (the value is well-formed) or global (the referenced thing exists). Keeping the two separate prevents the parser from acquiring a database dependency.
Smart constructors and the cost of a narrow type
A narrow type has a cost that shows up in three places. Construction becomes indirect: callers must go through a factory that can fail, and failure has to be handled at each call site, which pushes error handling upward and sometimes forces an awkward Result through a call chain that only ever had valid inputs. Serialization becomes lossy unless a codec is written per case, and generic serializers that reflect over fields will happily emit a union with the wrong fields attached. Persistence becomes a mapping problem, because relational schemas are flat and unions are not.
The usual resolution is to keep the narrowed representation inside the domain layer and widen it at the edges: one table per state, or one table with nullable columns plus a check constraint that enforces the same combinations the type enforces, and a mapper that converts between the two. The check constraint is not redundant. It is the last line of defence against a migration script, an admin console, or a manual UPDATE, all of which bypass the application’s constructors. Writing the constraint is also a useful test of the model: if the combinations are hard to express in SQL, they are probably hard to explain to a domain expert, which is a signal worth investigating.
The cost of narrow types rises sharply when the state space is genuinely large. Splitting a type into twelve cases for a workflow with twelve steps produces twelve mapping functions and a codebase that is difficult to read as a whole. The break-even is roughly where a reader can hold the state machine in mind; beyond that, an explicit state machine table is easier to reason about than a union.
Rehydration bypasses every constructor
The most common way an illegal state reaches production is a deserializer that constructs objects without running validation. ORMs do this deliberately: they instantiate the object and set fields reflectively so that loading a row does not re-run business rules. That behaviour is correct for performance and dangerous for invariants, because it means the database can contain states the type forbids.
Three defences are available. Enforce the invariant in the schema with NOT NULL, CHECK, and foreign keys, so a row that violates it cannot be written. Make the mapping layer construct through the domain constructors when the row materializes, accepting the validation cost per load. Or validate on load in a single place and fail loudly with the row’s key, so that a corrupt row is discovered at the boundary rather than three call frames later. The first is cheapest at runtime and the most reliable; the third is the only one that catches data that predates the constraint.
The same problem appears in event-sourced systems, where state is rebuilt by replaying events. An event stream written years earlier may contain a transition the current model forbids. Replay must either tolerate the old sequence and then normalize, or refuse to load the aggregate. The choice has to be made explicitly, and it is one of the reasons model evolution is harder than it looks — see Evolving Persistent Domain Models.
Choosing which invariant goes where
Not every rule can be a type. A useful split is by scope. Rules about a single value — a currency code has three letters, a quantity is positive, a period ends after it starts — belong in the value object’s constructor, and the discussion in Value Objects and Entities applies directly. Rules that span several objects owned by one consistency boundary belong in the aggregate root, because only the root sees all the parts; Aggregate Boundaries and Transaction Scope covers how those boundaries are drawn. Rules that span aggregates cannot be enforced by a constructor at all and must be handled by a process, a reservation, or a compensating check.
A rule placed at the wrong level produces characteristic symptoms. A cross-aggregate rule enforced in a constructor produces code that loads half the database to validate one field, and then fails when two concurrent requests each validate against data the other is about to change. A single-value rule left to the aggregate produces duplicated checks in every method that touches the field.
The test for whether the work paid off is simple: count the if statements in the model that check a field for validity. In a model that has been narrowed properly, nearly all of them are gone, and the remaining ones are about permissions and existence rather than shape. That count is a more honest measure of model quality than the number of types.
References
- Parse, don't validate — Alexis King
- Domain Modeling Made Functional — Scott Wlaschin, Pragmatic Bookshelf, 2018
- Object-Oriented Software Construction — Bertrand Meyer, Prentice Hall, 1997