Skip to content

Commands, events & state machines

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.

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 of emit, 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)
}

The body of a command runs in a fixed order:

  1. requires preconditions — each becomes a guard that throws DomainInvariantViolationException with your message before any mutation.
  2. field -> value transitions — straight assignments. Assigning a field that an enum states block governs also injects the legal-transition guard.
  3. emit Evt(...) — records a domain event (see §11.5); publish Evt(...) records an integration event on the root’s separate IntegrationEvents collection instead.
  4. 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.

requires and invariant look similar but answer different questions:

WhereChecksFailure
invariantentity / value bodyalways true at construction and after every commandstructural — the aggregate is malformed
requiresinside a commandtrue before this specific mutation runsthis 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”.

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.

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:

TargetBinding
C#var __result = …;
TypeScriptconst __result = …;
Python__result = …
PHP$__result = …;
Rustlet __result = …;
Java<Type> __result = …;
Kotlinval __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:

  • result requires a declared return type. A result with no : Type is reported (KOI0506).
  • A declared return type requires exactly one result clause — zero or more than one is reported (KOI0507).
  • The result expression 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.

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 KOI1208KOI1214 diagnostics live with them in API annotations (§15.9).

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;
}

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;
}
}

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).

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.

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.

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)
}
}
}
}