Skip to content

Rehydration and construction share one door: adding an invariant retroactively rejects stored rows #71

Description

@btravers

Problem

make(state) is the only construction door, so it serves two different jobs: constructing a new aggregate, and rehydrating a row written long before the current model. Invariants are enforced identically in both.

That makes adding an invariant a retroactively breaking change against all persisted data — and it is a breaking change no compiler and no unit test catches.

What happened

Refactoring a real aggregate (EndOfRentalManagementMission, ~18 fields, zod 4.3.6, entity 0.7.0) I added what looked like an obvious rule:

a mission in step ERROR carries the reason it failed

It is false. The setter takes cause: string | undefined and the column is nullable, so rows violating it already existed in production. Every one of them then failed make.

The failure mode is what makes this worth an issue, not the mistake itself:

  • make returned InvalidEntity, which the Prisma mapper folds to a defect
  • the defect surfaced inside a Temporal activity, which retried forever
  • the only observable symptom was one integration spec timing out at 120 s
  • nothing in the output named the invariant, the field, or the entity

I removed it, and the aggregate now ships with zero invariants. Part of that is #70 (invariants cannot carry a domain error code), but part is this: an invariant is a liability against existing rows, and there is no way to add one safely.

Why the two cases are genuinely different

A field-schema violation on read is corruption — a string where a date belongs means something is broken, and failing hard is right.

An invariant violation on read is not corruption. It is evidence about history: the rule did not exist when that row was written. Refusing to load the row converts a modelling improvement into an outage, and it does so retroactively, which is precisely the class of change that cannot be rolled out incrementally.

Proposal

Give rehydration its own door, where field schemas still fail hard but invariants are reported rather than fatal:

// today — one door, invariants enforced against data that predates them
Entity.make(state): Result<T, InvalidEntity>

// proposal
Entity.rehydrate(state): Result<{ entity: T; violations: Invariant[] }, InvalidEntity>

That lets a caller decide per context: a read model logs and continues, a command handler refuses, a data-quality job reports. All three are reasonable, and today the library picks the harshest one for everybody.

If a separate method is too much surface, an option on make ({ invariants: "enforce" | "report" }) would carry most of the value, as would documenting the hazard prominently — right now nothing warns that an invariant added on Tuesday applies to every row written before it.

Related

Found while migrating a production real-estate service to 0.7.0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions