ADR-0003: Domain entities own their invariants via intent-expressing methods

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

Context

Anemic entities with public setters let any caller put an entity into an invalid
state, and scatter the rules for “what does a valid X look like” across use cases.
We want the domain layer to be the place where invariants live.

Decision

Domain entities are rich and self-protecting:

  • No @Setter, no @Builder. Explicit constructors; state changes only
    through named, intent-expressing methods.
  • Type/state transitions are handled by the entity as a unit, never by the
    caller poking fields. E.g. paymentMethod.markAsCredit(closingDay, dueDay) sets
    the type and the days together; markAsNonCredit(type) sets the type and
    always nulls the days. Prefer entity.rename(...) / entity.markAsX(...) over a
    generic update(...) that takes everything.
  • A single-entity rule belongs on the entity, not in the use case. If a use
    case reads an entity’s getters and then decides-and-mutates that same entity,
    that decision is a domain method (applyChargeDate, applyChargedAmount,
    changeEarnings, toggleActive, generateInstallments). A rule duplicated
    across N use cases, or re-derived from getters, is the strongest signal it has
    leaked out and should be pulled in — caught one bug where a charge-date guard
    lived loosely in the use case and threw an unhandled 500.
  • Predicates name what they ARE, not what they’re used for. A factual
    predicate asserts a truth condition (isPaid(), isCredit(), isGlobal()); a
    policy predicate (isLocked(), isRefinanceable(), isEnrolling()) is a
    separate method built as a thin delegator on top — even when that’s one line.
    Don’t rename a fact to express a current use; the two can diverge.
  • A null check on a nullable = false field is a smell — the DB + constructor
    guarantee it; in Expenses, if the entity is CREDIT, closingDay is non-null by
    design. (See the null-check rule in the backend repo’s CLAUDE.md.)
  • Validation strategies handle user-facing 400s (field messages); domain
    methods
    enforce structural invariants (prevent invalid states in code paths).
    The two are complementary, not redundant.
  • Domain polymorphism: when multiple entities share a semantic concept,
    express it as a domain interface, not duplicated logic. Example: CashOutflow
    implemented by Expense and InvoicePayment, so GetCashOutflowsUseCase is the
    single source of truth for “money leaving the account.”

Consequences

  • Invalid states are unrepresentable through the public API of an entity.
  • Reviewers can trust that reading an entity’s methods tells the whole story of
    how it may change.
  • Watch: an invariant-maintaining method can be load-bearing for more than
    one caller
    — don’t delete a “redundant” clamp without checking every caller
    (e.g. Expense.update()’s clamps are relied on by the subscription path, which
    doesn’t run the apply* methods).
  • Canonical example (from Expenses) — the purchaseDate <= chargeDate (and
    purchaseAmount <= chargedAmount) rule.
    This universal expense invariant has no ADR of its own; it
    is owned by Expense.applyChargeDate / applyChargedAmount (a charge may only be
    deferred to a cycle, or an amount raised, strictly past the purchase, and only on
    a credit method — otherwise it pins/resets to the purchase). The same clamps are
    reused by installment generation and update(); treat these methods as the single
    home for the rule rather than re-checking dates/amounts in callers. (What the
    project does with the two-date split — analytics window by the purchase date,
    invoice membership by the charge date, and only the charge date may be in the
    future — is docs/expenses/backend/0017-purchase-date-is-never-in-the-future.md
    and the superseded 0007-effective-spend-date-for-analytics.md; the clamp that
    keeps the split valid lives here.)
  • Pairs directly with 0001-status-as-source-of-truth (lifecycle as enum +
    transition methods, not runtime-derived).