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. Preferentity.rename(...)/entity.markAsX(...)over a
genericupdate(...)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 = falsefield is a smell — the DB + constructor
guarantee it; in Expenses, if the entity is CREDIT,closingDayis non-null by
design. (See the null-check rule in the backend repo’sCLAUDE.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 byExpenseandInvoicePayment, soGetCashOutflowsUseCaseis 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 theapply*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 byExpense.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 andupdate(); 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 — isdocs/expenses/backend/0017-purchase-date-is-never-in-the-future.md
and the superseded0007-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).
Related
- 0001-status-as-source-of-truth
- 0002-clean-architecture-and-use-cases
- Operational detail: the backend repo’s
CLAUDE.md