ADR-0001: Lifecycle status is an explicit enum, not derived at runtime

  • Status: Accepted
  • Date: 2026-05-30
  • Scope: core (any backend with domain lifecycles)

Context

Domain entities with a lifecycle (in Expenses: invoices, installment series,
subscriptions, …) need to express what state they’re in. It is tempting to derive that state
at display time from runtime conditions — e.g. treating PARTIALLY_PAID && cycleEnd < today
as “closed-and-partial”, or computing “refinanced” from related rows. Deriving
status this way couples display logic to date semantics, makes persistence
queries awkward (you can’t filter on a state that doesn’t exist as a column),
and lets the same status mean different things in different views.

Decision

When the domain gains a meaningful new lifecycle state, encode it as a
status enum value with explicit transitions on the entity. Do not infer it
from runtime conditions.

  • New distinction the user describes → a new enum value + explicit transition
    method on the entity (intent-expressing, not a raw setter).
  • Backfill existing rows via a Flyway migration so historical data matches the
    new model.
  • Reserve runtime checks only for things the enum genuinely doesn’t carry —
    e.g. “is this overdue right now”, which is a function of today’s date and
    needn’t be persisted.

Consequences

  • Status means the same thing in every view and is directly queryable.
  • One migration per new state (accepted cost; keeps data honest).
  • Pairs with the broader DDD stance that entities own their invariants through
    intent-expressing methods rather than exposing setters.
  • 0003-ddd-intent-methods — the entity-owned transition methods this pairs with
  • Origin (Expenses): the user rejected a PARTIALLY_PAID && cycleEnd < today runtime scheme
    “if most of the invoice stats depend on dynamic and runtime conditions, then
    they are useless… we should have clearness as much as possible.”