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 understandterminal()will immediately understandinitial(). - The code generator emits
initialState()from a verified source. The generator readsdecider.initialStatefrom the AST with the assurance that validation has already confirmed exactly one exists and that it is constructable with zero arguments. - The
InitialStateFieldlessrule 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
InitialTerminalExclusiverule prevents a degenerate model that would produce nonsensical generated code (aninitialState()that is alsoisTerminal()).
Negative
- Grammar change. All existing
.dddfiles require aninitial()declaration to be added before they will pass theInitialStateRequiredvalidation. Theexamples/minimal/registration.dddfile 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 encounteredterminal()will recognize the pattern immediately.