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

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

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

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