Commands, events & state machines
11.1 General
Section titled “11.1 General”So far the entities (§6) and value objects (§5)
you have seen are structural — they describe shape and invariants. This chapter covers the three constructs that
give an aggregate behaviour: command mutates state, event records that something happened, and states
constrains which transitions are even legal.
All three live inside the root entity of an aggregate, and they all reinforce the same rule: an aggregate
can never be observed in an invalid state. A command re-checks every invariant after it mutates, and an
illegal lifecycle transition throws before any field changes.
11.2 Syntax
Section titled “11.2 Syntax”The constructs covered by this chapter are declared inside an entity body (see Aggregates (§7)
for the enclosing aggregate grammar). The relevant productions are:
commandDecl : 'command' Identifier ( '(' paramList? ')' )? ( ':' type_ref )? '{' commandStmt* '}' ;
paramList : param ( ',' param )* ;
param : softName ':' type_ref ;
commandStmt : requiresClause | transition | emitClause | resultClause ;
requiresClause : 'requires' expression StringLiteral? ;
transition : softName '->' expression ;
emitClause : 'emit' Identifier ( '(' emitArgList? ')' )? ;
emitArgList : emitArg ( ',' emitArg )* ;
emitArg : softName ':' expression ;
resultClause : 'result' expression ;
eventDecl : 'event' Identifier '{' member* '}' ;
statesDecl : 'states' softName '{' stateRule* '}' ;
stateRule : Identifier ( '->' Identifier ( ',' Identifier )* )? ( 'when' expression )? ;A commandDecl names the operation, optionally takes paramList parameters in parentheses, optionally declares
a return type_ref after :, and lists commandStmts in braces. Each commandStmt is one of:
requiresClause— a precondition guard (requires <expr> "message").transition— a field assignment (field -> value).emitClause— appends a domain-event instance (emit EventName(field: expr, …)).resultClause— hands a value back to the caller (result <expr>), only when the command has a declared return type.
An eventDecl is a peer of the entity declaration inside the aggregate and holds only member fields. A
statesDecl names the governed field via softName and lists stateRules: each rule names a source state and
optionally the target states reachable from it (omitting the arrow makes the state terminal). An optional
when expression guard refines the rule further.
The primary example — a submit command that guards a precondition, transitions a field, and emits an event:
command submit { requires status == Draft "only a draft order can be submitted" requires !lines.isEmpty "cannot submit an empty order" status -> Submitted submittedAt -> now emit OrderSubmitted(orderId: id, lineCount: lines.count)}11.3 Semantics
Section titled “11.3 Semantics”The body of a command runs in a fixed order:
requirespreconditions — each becomes a guard that throwsDomainInvariantViolationExceptionwith your message before any mutation.field -> valuetransitions — straight assignments. Assigning a field that an enumstatesblock governs also injects the legal-transition guard.emit Evt(...)— records a domain event (see §11.5).- Post-transition invariant re-check — every aggregate invariant is evaluated again, so a command can
never leave the aggregate in a state its
invariants forbid.
11.3.1 Preconditions vs. invariants
Section titled “11.3.1 Preconditions vs. invariants”requires and invariant look similar but answer different questions:
| Where | Checks | Failure | |
|---|---|---|---|
invariant | entity / value body | always true at construction and after every command | structural — the aggregate is malformed |
requires | inside a command | true before this specific mutation runs | this command isn’t applicable right now |
So requires status == Draft says “you can only submit a draft”, while the aggregate-level invariant lines.all(...) says “an order must always have valid lines, no matter how it got there”.
11.3.2 Commands with parameters
Section titled “11.3.2 Commands with parameters”A command can take parameters, which become method parameters. Field names assigned by -> reference the
parameters and existing members directly:
command record(amount: Decimal) { balance -> amount}record (lowercase) is fine as a command name — Koine PascalCases it to the C# method Record.
11.3.3 Returning a value
Section titled “11.3.3 Returning a value”A command is normally void — it mutates and emits. But the mainstream DDD case “a command returns the
identity of what it created” (a generated token, a receipt id, a child entity’s id) needs the command to hand
a value back. Declare a return type after the signature with : Type, then end the body with a result
clause naming the expression to return:
command cancel(): OrderId { requires status != Cancelled "already cancelled" status -> Cancelled emit OrderCancelled(orderId: id) result id}The result expression is evaluated over the post-mutation state, in the same scope as emit payloads, so
it can reference parameters, id, and just-assigned fields. It is the terminal statement: the value is only
returned from a fully-valid, fully-eventful aggregate (after the precondition guards, the post-transition
invariant re-check, and every emit). When the same value is also carried by an event — the create-and-return-id
idiom — Koine hoists it into a single var __result, computing it once. A result that no event references is
returned inline (return <expr>;).
Rules:
resultrequires a declared return type. Aresultwith no: Typeis reported (KOI0506).- A declared return type requires exactly one
resultclause — zero or more than one is reported (KOI0507). - The
resultexpression must be assignable to the declared return type (KOI0508).
This mirrors what factories (§12) (which already return the aggregate) and
use-cases (§15) (which already take an optional : Type mapping to Task<T>)
have always done. Commands without a return type are unchanged — they stay public void.
11.4 Translation to C#
Section titled “11.4 Translation to C#”The submit command from §11.2 emits:
public void Submit(){ if (!(Status == OrderStatus.Draft)) throw new DomainInvariantViolationException( type: nameof(Order), rule: "only a draft order can be submitted");
if (!(!(Lines.Count == 0))) throw new DomainInvariantViolationException( type: nameof(Order), rule: "cannot submit an empty order");
// legal-transition guard injected by `states` (see below) if (!((Status == OrderStatus.Draft))) throw new DomainInvariantViolationException( type: nameof(Order), rule: "illegal transition of status to Submitted"); Status = OrderStatus.Submitted; SubmittedAt = DateTimeOffset.UtcNow;
// every aggregate invariant is re-checked here... _domainEvents.Add(new OrderSubmitted(Id, Lines.Count));}The record command with a parameter (from §11.3.2) emits:
public void Record(decimal amount){ Balance = amount;}The cancel command with a return type (from §11.3.3) emits:
public OrderId Cancel(){ if (Status == OrderStatus.Cancelled) throw new DomainInvariantViolationException( type: nameof(Order), rule: "already cancelled");
Status = OrderStatus.Cancelled;
var __result = Id; _domainEvents.Add(new OrderCancelled(__result));
return __result;}11.5 Domain events
Section titled “11.5 Domain events”An event is a record of a fact that has happened. Declare it as a sibling member of the aggregate, then
emit it from a command or factory (§12):
event OrderSubmitted { orderId: OrderId lineCount: Int}This compiles to an immutable record implementing the IDomainEvent runtime interface, with PascalCase
properties and an auto-stamped OccurredOn:
public sealed record OrderSubmitted : IDomainEvent{ public OrderId OrderId { get; } public int LineCount { get; } public DateTimeOffset OccurredOn { get; init; } = DateTimeOffset.UtcNow;
public OrderSubmitted(OrderId orderId, int lineCount) { OrderId = orderId; LineCount = lineCount; }}11.5.1 Emitting and collecting events
Section titled “11.5.1 Emitting and collecting events”emit Evt(field: expr, ...) appends an event instance to the aggregate’s event collection. The first time any
member of the root emits, Koine generates the event infrastructure on the root entity:
private readonly List<IDomainEvent> _domainEvents = new();public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents;public void ClearDomainEvents() => _domainEvents.Clear();Your application’s unit of work reads DomainEvents after persisting the aggregate, dispatches them, then calls
ClearDomainEvents(). The emit argument names map positionally onto the event’s constructor — emit OrderSubmitted(orderId: id, lineCount: lines.count) becomes new OrderSubmitted(Id, Lines.Count).
11.6 State machines
Section titled “11.6 State machines”A states block declares the legal lifecycle of an enum-typed field. Each line lists a source state and the
states it may legally transition to, using the same -> token. Terminal states are listed on their own.
enum OrderStatus { Draft, Submitted, Paid, Shipped, Cancelled }
states status { Draft -> Submitted, Cancelled Submitted -> Paid, Cancelled Paid -> Shipped, Cancelled Shipped Cancelled}states status names the governed field (status: OrderStatus). The block by itself emits nothing — it is a
constraint. Its effect appears wherever a command assigns that field: Koine injects a guard that allows the
assignment only if the current state legally transitions to the target.
11.6.1 Emitted transition guards
Section titled “11.6.1 Emitted transition guards”Because the submit command does status -> Submitted, and only Draft may reach Submitted, the emitted
method gets:
if (!((Status == OrderStatus.Draft))) throw new DomainInvariantViolationException( type: nameof(Order), rule: "illegal transition of status to Submitted");Status = OrderStatus.Submitted;A cancel command targeting Cancelled — reachable from Draft, Submitted, or Paid but not Shipped —
gets an OR of all legal sources:
if (!((Status == OrderStatus.Draft) || (Status == OrderStatus.Submitted) || (Status == OrderStatus.Paid))) throw new DomainInvariantViolationException( type: nameof(Order), rule: "illegal transition of status to Cancelled");Status = OrderStatus.Cancelled;So a command can carry its own requires precondition (a business rule like “a shipped order cannot be
cancelled”) and still get the structural transition guard for free from the states block — they stack.
11.7 Full example: the Payment aggregate
Section titled “11.7 Full example: the Payment aggregate”Here is a complete, copy-pasteable context that combines a state machine, two commands, a creation event, and a
factory (§12). Adapted from the demo’s payments.koi:
context Payments version 1 {
enum PaymentMethod { Card, Transfer, Voucher } enum PaymentStatus { Authorized, Captured, Refunded, Failed }
value Money { amount: Decimal currency: String invariant amount >= 0 "an amount cannot be negative" }
aggregate Payment root Payment {
/// Raised when a payment is authorized. event PaymentAuthorized { payment: PaymentId order: OrderId }
entity Payment identified by PaymentId { order: OrderId amount: Money method: PaymentMethod status: PaymentStatus = Authorized
states status { Authorized -> Captured, Failed Captured -> Refunded Refunded Failed }
/// Capture an authorized payment. command capture { requires status == Authorized "only an authorized payment can be captured" status -> Captured }
/// Refund a captured payment. command refund { requires status == Captured "only a captured payment can be refunded" status -> Refunded }
/// Authorize a payment for an order. create authorize(order: OrderId, amount: Money, method: PaymentMethod) { emit PaymentAuthorized(payment: id, order: order) } } }}See also
Section titled “See also”- Factories (§12) — the
createblock, the->initialization operator, and why the all-args constructor goes private. - Aggregates (§7) — the consistency boundary that owns these commands and events.
- Context maps & integration events (§17) —
integration event,publishes/subscribes, and reacting to events across contexts. - Specs, services & policies (§13) — reacting to a domain event with
policy … when Event then Target.command(…). - Invariants (§10) — the guard expression grammar shared with
requiresandinvariant. - Expressions (§9) — the expression grammar used in
requires,transition, andemitpayloads.