Skip to content

Decider

Syntax

decider <Name> {
commands: <Command1>, <Command2>, ...
events: <Event1>, <Event2>, ...
state: <State1> | <State2>(<field>: <Type>) | ...
initial: <StateName>
terminal: <State1>, <State2>, ...
decide(<Command>, <State>) -> [<Event>]
decide(<Command>, <State>) -> already|forbidden|impossible "<message>"
decide(<Command>, <State>) { ... }
evolve(<FromState>, <Event>) -> <ToState> { ... }
}

A decider is the central construct. It declares the commands it handles, the events it produces, the states it transitions between, the initial state, and optionally which states are terminal.

Initial State

The initial: declaration is mandatory. It names the state the decider starts in. The grammar enforces exactly one initial state per decider — this is a parse-time guarantee, not a validation check.

Fieldless Initial State

initial: Unregistered

When the initial state has no fields, the initial: declaration names the state directly. No bindings are required or permitted.

Initial State with Field Bindings

When the initial state has fields, each field must be bound to a literal value:

state: Config(maxRequests: Int, windowSeconds: Int) | Exhausted
initial: Config(maxRequests = 100, windowSeconds = 60)

Inline form — bindings appear inside parentheses, separated by commas.

state: Config(maxRequests: Int, windowSeconds: Int) | Exhausted
initial: Config {
maxRequests = 100
windowSeconds = 60
}

Block form — bindings appear in a braced block, one per line.

Both forms are equivalent. The compiler validates:

  • All declared fields are bound — no missing bindings
  • No extra bindings target undeclared fields
  • No field is bound more than once
  • Each literal matches the declared field type (Int, Float, String, Boolean, or a branded alias of a primitive)

Value type fields (non-primitive composite types) are not supported in initial state bindings. See ADR-021 for the design decision and the deferred value-type-factories feature for the planned extension.

Decide Clauses

Short Form

decide(OpenCart, Empty) -> [CartOpened]

Block Form

decide(AddItem, Active) {
require items.length < 50
else reject "Cart is full"
-> [ItemAdded { cartId, productId, quantity }]
ensure items.length == old.items.length + 1
}

decide returns Result<Events[], RejectionError>. On rejection, no events are produced.

Rejection Clause

decide(Register, Active) -> already "User is already registered"
decide(Deposit, New) -> impossible "no account exists"
decide(Withdraw, Overdrawn) -> forbidden "account is overdrawn"

For (Command, State) pairs that should never produce events, a rejection clause expresses why. Three keywords: already (idempotent), forbidden (business rule), impossible (structural). See Rejection Clauses for details.

Evolve Clauses

evolve(Empty, CartOpened) -> Active { items: [], discountApplied: false }
evolve(Active, ItemAdded) -> Active { items: items + item }
evolve(Active, CartEmptied) -> Empty

The evolve function is a pure left-fold. Events are applied sequentially; the entire sequence is atomically committed.

Terminal States

terminal: CheckedOut
terminal: Closed, Cancelled

The terminal: declaration is optional. It lists one or more states that are absorbing — once the system enters a terminal state, no further commands are accepted. Multiple terminal states are separated by commas. Terminal states reject all commands. They represent completed lifecycles.

Compiler Checks

See Validation for details, error messages, and formal basis.

CheckWhat It Verifies
ExhaustivenessEvery (Command, State) pair has a decide clause
Evolve totalityEvery reachable (State, Event) pair has an evolve clause
Guard consistencyNo contradictory guards making code unreachable
require false deprecationSuggests rejection clause syntax
PostconditionsEvery ensure is derivable from the evolve definition

Architecture Decisions