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 : annotation* 'command' Identifier ( '(' paramList? ')' )? ( ':' type_ref )? '{' commandStmt* '}' ;
paramList : param ( ',' param )* ;
param : softName ':' type_ref ;
commandStmt : requiresClause | transition | emitClause | publishClause | resultClause ;
requiresClause : 'requires' expression StringLiteral? ;
transition : softName '->' expression ;
emitClause : 'emit' Identifier ( '(' emitArgList? ')' )? ;
publishClause : 'publish' 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. The leading annotation* carries the optional
HTTP surface (@route, a verb, @auth — see §11.3.4). 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, …)).publishClause— appends an integration-event instance (publish EventName(field: expr, …)), the cross-context counterpart ofemit, legal only in an aggregate root’s commands. Same payload syntax; the full rules, diagnostics and outbox path live with integration events in §17.7.3.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);publish Evt(...)records an integration event on the root’s separateIntegrationEventscollection instead.- 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).
Evaluated exactly once, on every target. When the result expression is also a whole payload argument of an emit or a publish — the
create-and-return-id idiom — Koine evaluates it exactly once: it is bound to a single __result local
right after the invariant re-check, and both the recorded event payload and the returned value read that one
local. This is a guarantee of the language, not of one backend — all seven code targets hoist it, each in
its own binding syntax:
| Target | Binding |
|---|---|
| C# | var __result = …; |
| TypeScript | const __result = …; |
| Python | __result = … |
| PHP | $__result = …; |
| Rust | let __result = …; |
| Java | <Type> __result = …; |
| Kotlin | val __result: <Type> = … |
Java and Kotlin are the two that spell the type out, because a bare var/val would re-infer a target-typed
expression against no target at all. It is the expression’s own type, not the command’s declared return
type — so command maybeStamp: Instant? emitting a non-optional Stamped.at still binds an Instant that
both the payload constructor and the optional return accept.
Where a target needs a conversion at one of the two sites — Rust’s Some(…) wrap toward an optional payload
field, or its widening toward a Decimal one — the conversion is applied around the local, never folded
into it: Some(__result) in the payload beside a bare Ok(__result) at the return is still one evaluation.
It is correctness, not tidiness: a Koine expression need not be pure. result now beside
emit Closed(at: now) would, if rendered twice, read the clock at two different instants — so the instant the
event records and the instant the command returns would be different facts, and with an emit and a
publish of the same expression the domain event and the integration event meant to mirror it would disagree
too.
Two shapes deliberately do not hoist: a result no payload argument reuses is returned inline
(return <expr>;), and a sibling argument that merely shares the result’s prefix (a taxRate next to a tax
result) is left alone — the match is per whole argument, never a substring. The comparison is made on each
target’s own rendering of the two, so an argument that renders differently from the result for a reason the
backend cannot bridge is likewise left inline: a missed hoist is safe where a wrong one would not compile.
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.3.4 API annotations
Section titled “11.3.4 API annotations”A command is also one HTTP operation, derived by convention as POST /{entity}/{command} (kebab-cased) by both
the openapi target and the C# api layer. Three optional annotations override that convention — @route("…")
for the path, one of @get/@post/@put/@delete/@patch for the verb, and @auth("…") to require
authorization:
@route("/orders/{id}")@put@auth("admin")command submit(note: String) { requires status == Draft "only a draft order can be submitted" status -> Submitted}The annotations change nothing about the emitted domain method — Submit(string note) is byte-identical
either way. They are read only when you ask for an HTTP surface (--layers api, --target openapi). Queries take
the same three; the full rules, the emitted shapes, and the KOI1208–KOI1214 diagnostics live with them in
API annotations (§15.9).
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. - Application layer & CQRS (§15.9) — the
@route/verb/@authannotations a command shares with aqueryand acreatefactory.