Skip to content

ADR-017: Explicit Initial State Declaration

Context

The Emmett code generator (Phase 4) must emit initialState() for Emmett’s Decider type. Without an explicit declaration in the .ddd file, the generator has no reliable basis for determining which state is the starting state.

The .ddd DSL declares state as a union:

state: Unregistered | Active(email: Email, name: DisplayName) | Inactive(reason: Reason)

A convention-based approach — “first state listed is the initial state” — appears workable until it encounters two failure modes. First, a developer or linter reordering the union for alphabetical or conceptual grouping reasons silently changes the generated initialState() without a compiler error or warning. Second, the compiler has no way to cross-check whether the chosen initial state is actually reachable from the decider’s decide clauses, because the relationship between the union ordering and lifecycle semantics is nowhere in the grammar.

The language already has terminal(State) = true for marking terminal states. Terminal states receive dedicated validation: every decider must have at least one, and no decide clause may emit events when the current state is terminal. The absence of a symmetric construct for initial states is an asymmetry in the DSL design rather than a deliberate choice — terminal states were added to support dead-code detection, and initial states were deferred because the generator had not yet been started.

Decision

Add initial(State) = true syntax to the grammar, mirroring terminal() exactly. Three validation rules enforce correctness.

InitialStateRequired: Every decider SHALL have exactly one initial(State) = true declaration. Zero declarations is an error (generator cannot emit initialState()). Two or more declarations is an error (a state machine has one starting state).

InitialStateFieldless: The state marked initial SHALL have no required fields. The initial state must be constructable with zero arguments — a bare state like Unregistered, or a state where every field has a default value. A state with required fields (e.g., Active(email: Email)) cannot be instantiated without data that does not exist at the moment of aggregate creation.

InitialTerminalExclusive: A state SHALL NOT carry both initial and terminal markers. A state that is simultaneously the start and the end of the lifecycle is a degenerate automaton. The validator produces a dedicated error rather than allowing it to pass through to generated code.

The grammar change extends DeciderMember with a new alternative:

DeciderMember:
Decision | Evolution | TerminalDecl | InitialDecl;
InitialDecl:
'initial' '(' state=[StateDecl:ID] ')' '=' value=BooleanValue;

BooleanValue is the same production used by TerminalDecl, keeping the surface syntax consistent.

Example usage:

decider User {
commands: Register, Deactivate
events: Registered, Deactivated
state: Unregistered | Active(email: Email, name: DisplayName) | Inactive(reason: Reason)
initial(Unregistered) = true
decide(Register, Unregistered) -> [Registered { userId, email, name }]
decide(Register, Active) -> already "registered"
decide(Register, Inactive) -> forbidden "reactivation requires a separate command"
evolve(Unregistered, Registered) -> Active { email: event.email, name: event.name }
decide(Deactivate, Active) -> [Deactivated { userId, reason }]
decide(Deactivate, Unregistered) -> impossible "no active account to deactivate"
decide(Deactivate, Inactive) -> already "deactivated"
evolve(Active, Deactivated) -> Inactive { reason: event.reason }
terminal(Inactive) = true
}

The initial() and terminal() declarations appear as natural bookends in the decider body, with initial near the top (before decide clauses, after state declarations) and terminal near the bottom — matching the lifecycle flow of the aggregate.

Alternatives Considered

Convention: first state in union

The first-listed state in the state: union becomes the initial state implicitly. No grammar or validation change required. The code generator reads decider.states[0].

Rejected. The convention breaks silently when the union is reordered. The compiler cannot distinguish intentional ordering from accidental ordering. “Silent breakage” is the class of error Weltenwanderer explicitly refuses to accept — the compiler either verifies or reports an error; it does not silently succeed on unverifiable assumptions.

Annotation: @initial decorator on StateDecl

A decorator system where individual state declarations carry attributes:

state: @initial Unregistered | Active(email: Email) | Inactive(reason: Reason)

Rejected. The grammar currently has no annotation system. Adding one for a single boolean attribute introduces a general-purpose mechanism to solve a specific problem. The terminal() pattern already exists and establishes the correct precedent — a named declaration at decider scope, not an inline attribute on a type.

Default value syntax: state: Unregistered = initial | Active(...)

Borrowing from Haskell-style default notation:

state: Unregistered = initial | Active(email: Email) | Inactive(reason: Reason)

Rejected. This conflates type declaration syntax with lifecycle semantics. The state: line declares what states exist; it is not the correct location for declaring which state is active at creation time. The approach also creates a visual asymmetry with terminal(), which is a separate declaration.

Consequences

Positive

  • No silent breakage from state union reordering. The initial state is a named, validated declaration, not an implicit index.
  • Symmetric with terminal(). The DSL now has explicit bookends for lifecycle boundaries. Users who understand terminal() will immediately understand initial().
  • The code generator emits initialState() from a verified source. The generator reads decider.initialState from the AST with the assurance that validation has already confirmed exactly one exists and that it is constructable with zero arguments.
  • The InitialStateFieldless rule catches a common modeling error early: declaring an initial state that cannot actually be instantiated. Without this rule, the error would surface as a runtime exception in generated TypeScript.
  • The InitialTerminalExclusive rule prevents a degenerate model that would produce nonsensical generated code (an initialState() that is also isTerminal()).

Negative

  • Grammar change. All existing .ddd files require an initial() declaration to be added before they will pass the InitialStateRequired validation. The examples/minimal/registration.ddd file is the only current example and will be updated as part of this change.
  • Three new validation rules add complexity to the validation layer. The rules are straightforward (single-declaration check, field presence check, set exclusion check), but each requires a new validator function and corresponding test cases.
  • One additional concept for DSL users to learn. The symmetry with terminal() mitigates this: a user who has encountered terminal() will recognize the pattern immediately.