Skip to content

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:

// validation
if (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 Schema check, 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, not make. make throws when a schema check fails, so input that passes your named check but fails another one crashes the caller. makeEffect fails with a SchemaIssue.Issue that 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 500
const 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

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 Forbidden or RuleNotApplicable.
  • 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:

finance/spending-policy.ts
/** 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.defaultAccountId

Source: notes/09-production-concerns/invariants-and-guards.md · Decision, Rejected