Invariants, guards & assertions
Every domain has rules, and without a scheme they all become “validation” and land wherever the first author was working. This page answers where a rule is asserted and what happens the moment it is violated.
Classify every rule as invariant, policy or preference
Section titled “Classify every rule as invariant, policy or preference”Impact: HIGH the kind decides where the rule lives and what breaking it means
- An invariant must always hold for the model to be valid. A policy is a domain rule that can change deliberately. A preference is a UX or configuration choice.
- Name the kind next to the rule, in its doc comment, ADR or Decision (“a policy, not an invariant”).
- Classifying takes a moment per rule, and some rules get argued about (is a monthly limit a policy or a preference?). That argument is the point.
| kind | enforced | a violation is | example |
|---|---|---|---|
| invariant | where the value is made, plus a DB constraint where storage can express it | input: a typed error. Our own stored data: a defect | a Transaction’s Postings sum to zero |
| policy | a named pure function | a typed domain error (Forbidden, RuleNotApplicable) |
adjustments are excluded from spending reports |
| preference | config, or a stored user or org setting | not a violation: a default is applied | the default account |
❌ Incorrect — one unnamed bucket, so nobody knows what kind of rule this is:
// validationif (input.postings.length === 0) return yield* new ValidationError()if (!input.accountId) input.accountId = "BCA"✅ Correct — each rule says what it is:
/** Invariant: a Transaction's Postings sum to zero. */export const sumsToZero = (postings: ReadonlyArray<Posting>) => /* … */
/** Policy, not an invariant: adjustments are excluded from spending reports. */export const includeInSpending = (tx: Transaction) => /* … */Source: notes/09-production-concerns/invariants-and-guards.md · Decision
Enforce an invariant where the value is made
Section titled “Enforce an invariant where the value is made”Impact: HIGH no value can exist that breaks the model
- Enforce it in a smart constructor or a
Schemacheck, and in a database constraint where storage can express it. - An invariant is never only a UI check, and never only a permission check. The client may copy it for feedback, but the server’s constructor is the rule.
- Build with
makeEffect, notmake.makethrows when a schema check fails, so input that passes your named check but fails another one crashes the caller.makeEffectfails with aSchemaIssue.Issuethat you map to a typed error. - A cross-row invariant, such as postings that sum to zero, cannot be a database constraint. It relies on the constructor plus one write path.
❌ Incorrect — make throws eagerly, outside any Effect:
export const make = (input: CreateTransactionInput) => sumsToZero(input.postings) ? Effect.succeed(Transaction.make(input)) // throws on an empty postings : Effect.fail(new UnbalancedTransaction({ transactionId: input.id }))✅ Correct — the invariant is named, and the schema’s checks fail as typed errors:
export const make = (input: CreateTransactionInput) => sumsToZero(input.postings) ? Transaction.makeEffect(input).pipe( Effect.mapError((issue) => new InvalidTransaction({ transactionId: input.id, issue }))) : Effect.fail(new UnbalancedTransaction({ transactionId: input.id }))Source: notes/09-production-concerns/invariants-and-guards.md · Decision, amended
Fail input as a typed error, stored data as a defect
Section titled “Fail input as a typed error, stored data as a defect”Impact: HIGH bad input is not a bug; corrupt own data is
- Bad input breaking an invariant is a typed error at the boundary (a 4xx), see schema at boundaries.
- The same invariant failing on data we stored ourselves is a defect: a broken invariant, see
error boundaries. Name the invariant in a comment at the
die. - Never log and continue. It keeps the process up but lets invalid state spread into everything that reads it next.
❌ Incorrect — crash on bad input, and shrug at corrupt stored data:
const tx = Transaction.make(input) // bad input becomes a 500const stored = yield* fromStored(rows).pipe( Effect.catch((e) => Effect.logWarning(e)), // invalid state spreads)✅ Correct — typed for input, defect for our own data:
const tx = yield* make(input) // UnbalancedTransaction | InvalidTransaction
// invariant: postings were written in one atomic unit (sql-and-transactions §3)const stored = yield* fromStored(rows).pipe(Effect.orDie)Source: notes/09-production-concerns/invariants-and-guards.md · Decision, Rejected
Write a policy as a named pure function
Section titled “Write a policy as a named pure function”Impact: MEDIUM a rule that changes on purpose needs one place to change
- Access rules live in a pure
policy.ts(see authorization). Other domain rules live in<subject>-policy.ts. - A violation is a typed domain error such as
ForbiddenorRuleNotApplicable. - A policy change is a product decision. Record it with its revisit condition in a context ADR (see strategic DDD).
❌ Incorrect — a policy buried inline in a handler:
const report = yield* repo.list(orgId).pipe( Effect.map((txs) => txs.filter((t) => t.kind !== "adjustment")),)✅ Correct — the policy has a name and a file:
/** Policy, not an invariant: adjustments are excluded from spending reports. */export const includeInSpending = (tx: Transaction): boolean => tx.kind !== "adjustment"Source: notes/09-production-concerns/invariants-and-guards.md · Decision
Treat a preference as a default, never a check
Section titled “Treat a preference as a default, never a check”Impact: MEDIUM keeps a UX choice from hardening into the model
- A preference lives in config (see config and secrets) or in a stored user or org setting. Missing means a default is applied.
- A preference is never a constructor check or a database constraint.
- If a “default” fails validation, it was an invariant or a policy all along. Reclassify it.
❌ Incorrect — a preference architected as an invariant:
ALTER TABLE transactions ADD CONSTRAINT default_account CHECK (account_id = 'BCA');✅ Correct — the preference fills a gap and nothing more:
const accountId = input.accountId ?? settings.defaultAccountIdSource: notes/09-production-concerns/invariants-and-guards.md · Decision, Rejected