ADR-016: Rejection Category Keywords
Context
Exhaustiveness checking requires a decide clause for every Command x State pair. Not every pair represents a valid transition — some are structurally impossible (the command’s precondition does not exist in this state), some are policy-forbidden (a business rule prohibits the action), and some are idempotent (the desired outcome is already in effect).
The current pattern to satisfy exhaustiveness while expressing rejection is require false else reject "message" with a dead event list:
decide(Deactivate, Unregistered) { require false else reject "Cannot deactivate an unregistered user" -> [Deactivated]}This pattern has two problems. First, the dead event list (-> [Deactivated]) is misleading — no events are emitted when require false fires, but the grammar currently requires the emission clause for a decide body. Second, and more critically, the rejection message is a free-form string that carries no semantic weight. The compiler cannot distinguish an idempotent rejection from a structural one from a policy one. Domain models that use this pattern are technically complete but semantically opaque.
The proposed replacement makes the category of rejection part of the syntax:
decide(Register, Active) -> already "registered"decide(Withdraw, Overdrawn) -> forbidden "insufficient funds"decide(Deposit, New) -> impossible "no account exists"Decision
The DSL introduces three rejection category keywords as a short-form decide clause. Each keyword expresses a distinct semantic reason for the rejection.
| Keyword | Category | Domain meaning | HTTP semantics |
|---|---|---|---|
already | Idempotent | Outcome already achieved | 200/409 |
forbidden | Business rule | Policy prohibits this | 403 |
impossible | Structural | Precondition doesn’t exist | 400 |
The grammar change extends Decision with a RejectionClause alternative:
Decision: 'decide' '(' command=[Command:ID] ',' state=[StateDecl:ID] ')' (shortForm=ShortDecision | rejection=RejectionClause | '{' body=DecisionBody '}');
RejectionClause: '->' category=RejectionCategory message=STRING;
RejectionCategory returns string: 'already' | 'forbidden' | 'impossible';require false becomes a hard error. The validator will emit a diagnostic with a migration suggestion pointing to the appropriate rejection keyword. Existing .ddd files using require false will not parse silently — they will fail validation with an actionable message.
Alternatives Considered
Single keyword (rejected)
Multiple single-keyword candidates were evaluated before arriving at three:
| Keyword | Evaluated | Rejected because |
|---|---|---|
forbidden | First candidate | Only covers business rule prohibition, not structural impossibility or idempotency |
never | Strong contender | Claims impossibility about the environment, but commands do arrive — the decider refuses them |
cannot | User’s initial preference | ”Can not” is two words; felt unnatural as a single keyword despite being grammatically correct |
declined | Considered | Too soft — implies discretion. “Declined a loan” suggests it could go either way |
refused | Considered | Active voice but infrastructure-flavored, not domain language |
prevents | Considered | Odd agent — what does “the decide prevents”? |
blocks | Considered | Infrastructure-flavored |
illegal | Late consideration | Closest to forbidden but carries moral/legal judgment; “no account exists” isn’t illegal, it’s structural |
impossible | Considered as sole keyword | Only covers structural impossibility |
already | Considered as sole keyword | Only covers idempotency |
Why three keywords
The HTTP semantics and documentation value justify the complexity. Each category maps to distinct error types in generated code (400/403/200|409), distinct rendering in living documentation, and distinct static analysis potential. The compiler can cross-check: a command forbidden in every state is suspicious; an already rejection should be consistent with the state the command’s events evolve to.
After exploring the domain expert’s perspective, three distinct reasons for rejection emerged:
- “That doesn’t make sense here” —
impossible(structural mismatch) - “We don’t allow that” —
forbidden(business rule) - “That’s already done” —
already(idempotent)
Each keyword forms a natural English sentence: “Impossible — no account exists.” “Forbidden — insufficient funds.” “Already registered.” Domain experts can read a decide table and immediately understand why each pair was rejected without consulting documentation or decoding error strings.
Consequences
Positive
- Domain models are self-documenting — the why is in the syntax, not a free-form string
- No dead event lists — rejection clauses carry no emission clause
- Generated code maps to distinct HTTP status codes without additional annotation
- Living documentation renders each category differently, producing a structured rejection table per decider
- Compiler can cross-check category consistency: a command marked
alreadyin a state should be consistent with the evolve definition for that state
Negative
- Three keywords to learn instead of one
- Modelers must classify each rejection, which introduces cognitive overhead during modeling
- Some edge cases are ambiguous — whether “already registered” is idempotent (
already) or a prohibition (forbidden) depends on domain interpretation require falsebecomes a hard error — existing.dddfiles need migration before they will validate