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.
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:It is false. The setter takes
cause: string | undefinedand the column is nullable, so rows violating it already existed in production. Every one of them then failedmake.The failure mode is what makes this worth an issue, not the mistake itself:
makereturnedInvalidEntity, which the Prisma mapper folds to a defectI 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:
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
migrate-on-makehook, but that is about rows whose shape changed. This issue is about rows whose shape is fine and whose rules are new.Found while migrating a production real-estate service to 0.7.0.