← Back to home

Workflows

A workflow is a context-level orchestration that loads or creates multiple aggregates, invokes their operations, and (optionally) emits orchestration-level events. Aggregate operations remain the single mutation + invariant gate; workflows wire them together.

context Sales {

  enum OrderStatus { Draft, Confirmed, Shipped }

  aggregate Customer {
    name: string
    creditLimit: decimal
    derived display: string = name
    invariant creditLimit >= 0

    operation deductCredit(amount: decimal) {
      precondition amount > 0
      precondition creditLimit >= amount
      creditLimit := creditLimit - amount
    }

    operation addCredit(amount: decimal) {
      precondition amount > 0
      creditLimit := creditLimit + amount
    }
  }

  aggregate Order {
    customerId: Customer id
    status: OrderStatus
    placedAt: datetime
  }

  repository Customers for Customer { }
  repository Orders for Order { }

  event OrderPlaced { order: Order id, at: datetime }

  // Non-transactional: each save commits independently.
  workflow placeOrder {
    create(customerId: Customer id, placedAt: datetime) {
      let customer = Customers.getById(customerId)
      let order = Order.create({
        customerId: customerId,
        status: Draft,
        placedAt: placedAt
      })
      emit OrderPlaced { order: order.id, at: placedAt }
    }
  }

  // Transactional: all-or-nothing within one DB transaction.
  workflow transferCredit transactional {
    create(src: Customer id, dst: Customer id, amount: decimal) {
      precondition amount > 0
      let source = Customers.getById(src)
      let target = Customers.getById(dst)
      source.deductCredit(amount)
      target.addCredit(amount)
    }
  }
}

Body vocabulary

Form Meaning
precondition Expr Workflow-level guard. Failure → 422 (DomainException / DomainError) — RS-15.
requires Expr Authorization guard. Same syntactic shape as precondition, but failure → 403 (ForbiddenException / ForbiddenError). Use this for currentUser-based permission checks; use precondition for business-rule checks.
let x = Agg.create({ field: expr, ... }) Factory call. Saved at workflow exit.
let x = Repo.getById(idExpr) Load by id; throws AggregateNotFound (→ 404) if missing. Result is non-nullable.
let x = Repo.<find>(args) Call any repo-declared find whose return is a single non-nullable aggregate. A plain let can't bind an array result.
let x = Repo.find(<Criterion>) / Repo.findById(id) / Repo.findBy(<col>: v) / a T? find An optional read: x is T? — the first match, or none. Read it through a null test (precondition x != null, x ?? y) or narrow it with if let; a member read or an op-call on it before that is loom.nullable-receiver.
let xs = Repo.run(<Retrieval>(args), page?) / let xs = Repo.findAll(<Criterion>, page?) Bind an aggregate array; consumable only by a for loop.
for x in xs { ... } Iterate an aggregate array, binding each element to x; per-iteration mutations save inside the loop.
if let x = Repo.find(<Criterion>) { ... } else { ... } / if let x = Repo.findBy(<col>: v, ...) { ... } / if let x = Repo.findById(id) { ... } / if let x = y { ... } Look up a single aggregate by criterion (the shared findAllBy<Criterion> retrieval, capped at one row), by a declared unique (…) key (findBy), by id (findById), or through a declared optional find (T?) — or narrow an optional binding y already in scope (no second read). x is bound (non-null) only in the then-branch; else runs on no match. else is optional.
notFound / notFound "detail" End the body with the standard ProblemDetails 404 — the same response a getById miss produces. Workflow / handler bodies only (loom.not-found-placement), last in its block (loom.not-found-unreachable). The canonical home is the else of an if let; see below.
name.opName(args) Invoke a public operation on a let-bound aggregate. The op's own preconditions / invariants run inside that call.
let x = expr Plain expression binding.
emit EventName { field: expr, ... } Workflow-level event. Event must be declared in the same context. Drains through IDomainEventDispatcher after all saves (after commit when transactional).

Every Repo.… form above names a repository of the workflow's own context — see below.

A when-gated operation in a for loop is gated per row

An operation's when state gate runs inside the generated domain method on every backend (D-WHEN-GATE-DOMAIN), so a workflow — or a timer's handler — calling it on a loop element is refused exactly as an HTTP caller would be. There is no per-element isolation: the refusal throws out of the loop, the elements after it are never visited, and the run fails (a transactional workflow also rolls back the iterations before it; otherwise each earlier iteration's save has already committed). So the retrieval being iterated must select only rows the gate admits — make its criterion imply the when predicate:

aggregate Ticket {
  status: Status
  dueAt: datetime
  operation flagBreached() when status == Open { status := Breached }
}
// Overdue, AND still Open — so every row satisfies flagBreached's gate.
criterion DueOpen(asOf: datetime) of Ticket = dueAt < asOf && status == Open
retrieval Due(asOf: datetime) of Ticket { where: DueOpen(asOf) }

// in the workflow / timer body
let due = Tickets.run(Due(now()))
for t in due { t.flagBreached() }

A criterion that selects only dueAt < asOf would also pick up tickets already Breached, and the first of them would abort the sweep.

Repositories are context-local — loom.workflow-cross-context-repository

A workflow orchestrates the aggregates of ONE bounded context. Naming a repository declared in another context is an error (loom.workflow-cross-context-repository), even when both contexts ride the same deployable and share one transaction:

A fragment, deliberately REJECTED — Technicians is declared in context Directory, while the workflow is in context Dispatch:

workflow scheduleWorkOrder transactional {
  create(workOrderId: WorkOrder id, assignTo: Technician id) {
    let tech = Technicians.getById(assignTo)   // ERROR — `Technicians` is Directory's
    let wo   = WorkOrders.getById(workOrderId) // fine — Dispatch's own
    wo.assign()
  }
}

Lowering resolves a let read against the enclosing context's repositories alone, so a foreign name never becomes a repository load at all — before the gate, every backend rendered the receiver verbatim into code that references a name it never defines (node: const tech = Technicians.getById(assignTo); — no repository constructed, no await, tsc: Cannot find name 'Technicians').

Reach the other context at its public surface instead — the same two sanctioned crossings domain-services.md names: call Directory's api through a resource { kind: api } binding, or consume Directory's published events. To use event-fed data inside a workflow, fold the events into a local replica aggregate and read that through its repository — a workflow loads data only through its own context's repositories:

aggregate TechReplica { tech: Technician id immutable  unique (tech)  skills: string[] }
repository TechReplicas for TechReplica { }
criterion ForTech(t: Technician id) of TechReplica = tech == t

workflow replicateTechs {
  tech: Technician id                       // the correlation field
  create(e: TechnicianRegistered) by e.tech { let r = TechReplica.create({ tech: e.tech, skills: e.skills }) }
}
workflow assign {
  create(workOrderId: WorkOrder id, assignTo: Technician id) {
    if let t = TechReplicas.find(ForTech(assignTo)) { … } else { precondition false message "unknown technician" }
  }
}

A projection folded over the same events is not readable from a workflow — it is a read model with no repository, served through its GET endpoint and pages. let s = Seen.byKey(k) in a workflow body is refused with loom.workflow-unknown-name (before the gate, every backend emitted an undefined Seen receiver). If the two aggregates genuinely change together in one transaction, they belong in the same context.

if let — single-result criterion lookup

Repo.find(<Criterion>) is the single-result sibling of Repo.findAll: it returns an optional aggregate. A workflow body has no standalone way to bind or branch on a nullable, so the lookup and the branch are one construct — if let:

workflow ReserveSeat {
  create(c: ReserveSeat) {
    if let seat = Seats.find(AvailableSeat) {
      seat.reserve(c.holder)          // `seat` is non-null here; saved on exit of the branch
    } else {
      emit NoSeatAvailable { event: c.event }
    }
  }
}

Both find and findAll over one criterion share a single internal findAllBy<Criterion> retrieval (no public endpoint is exposed — unlike a declared find). The else-branch may create a fallback aggregate (let s = Seat.create({...})); branch-local creations / mutations save at the end of their branch.

The source may also be a natural-key read — if let o = Orders.findBy(code: c.code) { … } over an aggregate declaring unique (code) (chapter 10). The generated code calls the repository's findByCode method (derived from the call; no retrieval is synthesised) and branches on presence, exactly as above; the same form works in a commandHandler / queryHandler body, and a trailing ignoring … widens the read. A declared optional find (find byCode(c: string): Order?) is the older spelling of the same source, and if let o = Orders.findById(c.oid) { … } reads by id. Any other source (getById, getBy, a non-optional or union-valued find, a binding that is not optional) is loom.iflet-bad-source. A row the body must have reads with let o = Orders.getBy(code: c.code) — a miss answers the standard 404.

A criterion read has no uniqueness guarantee, so there is no must-exist get(<Criterion>). A body that needs the row spells the miss: notFound ends the body with the standard 404 — the error the backend's by-id read raises, so the response (status, title, type, instance) is byte-for-byte the one a getById miss produces. notFound "detail" sets the detail; a bare notFound in an if let else names the lookup's aggregate (Ticket not found).

commandHandler EscalateOpen(customer: string) {
  if let t = Tickets.find(OpenFor(customer)) { t.escalate() } else { notFound "no open ticket" }
}
// node (Hono)
const t = (await tickets.runFindAllByOpenFor(customer, { limit: 1 }))[0] ?? null;
if (t !== null) {
  t.escalate();
  await tickets.save(t);
} else {
  throw new AggregateNotFoundError("no open ticket");
}
# elixir (Phoenix) — the branch evaluates to the tagged miss
if t != nil do
  Context.escalate_ticket(t, %{})
  {:ok, t}
else
  {:error, {:not_found, "no open ticket"}}
end

.NET and Java throw AggregateNotFoundException("no open ticket"), Python raises AggregateNotFoundError("no open ticket"). notFound is refused outside a workflow / handler body: an operation is called by a handler, and refuses with precondition / requires or a declared error variant.

commandHandler BumpByCode(code: string, n: int) {
  if let hit = Orders.findBy(code: code) { hit.bump(n) }
}
// node (Hono) — the handler body
const hit = await orders.findByCode(code);
if (hit !== null) {
  hit.bump(n);
  await orders.save(hit);
}
# python (FastAPI) — the handler body
hit = await orders.find_by_code(code)
if hit is not None:
    hit.bump(n)
    await orders.save(hit)

Own-state assignment (field := value) — writing one of the workflow's own declared state fields — is allowed inside a create / on body; it mutates the persisted saga-instance row (attempts := 1). The compound mutation forms (+=, -=) and any cross-aggregate write (order.status := …) belong to aggregate operation bodies and stay rejected inside a workflow. An event-sourced workflow can't use := at all — its state is derived only by folding emitted events through apply clauses (loom.workflow-eventsourced-assign). Workflows can't call private operations, can call extern operations (parameterless or parameterized — see docs/extern.md; the workflow handler injects the user's IXAggHandler and runs the same dispatch dance the auto HTTP route does), and can't findById (use getById for must-exist loads).

function — private pure helper

A workflow is a state-bearing entity like an aggregate, so it carries the same private-helper member: a function. It factors a shared, side-effect-free expression out of the create / on bodies that use it:

workflow FulfillOrder {
  orderId: Order id

  // Private pure helper — never a route, callable from any body below.
  function slaDays(priority: int): int = priority > 5 ? 1 : 5

  create(orderId: Order id, priority: int) {
    let order = Orders.getById(orderId)
    order.scheduleShipment(slaDays(priority))   // ← call
  }
}

A workflow body is not a class, so — unlike an aggregate function (a private method on the entity class) — each backend emits a workflow function as a module/file-scoped helper, namespaced by its workflow (all of a context's workflows share one generated file), and a call to it renders as that scoped, per-backend-cased name. The Hono/TS backend:

// generated api/http/workflows.ts
function fulfillOrderSlaDays(priority: number): number { return priority > 5 ? 1 : 5; }
// … inside the create handler:
order.scheduleShipment(fulfillOrderSlaDays(priority));

and the Python/FastAPI backend:

# generated api/app/http/workflows_routes.py
def fulfill_order_sla_days(priority: int) -> int:
    return 1 if priority > 5 else 5
# … inside the create handler:
order.schedule_shipment(fulfill_order_sla_days(priority))

Both body forms an aggregate function supports are allowed, identically — the expression form (= <expr>) and the pure block form ({ let … precondition … return … }, domain-services.md rev. 4):

function slaDays(priority: int): int {
  let expedited = priority > 5
  precondition priority >= 0
  return expedited ? 1 : 5
}

Two constraints keep the helper a well-formed module/static function:

Returning a result — create(…): T

A workflow POST answers 204 by default. A command-triggered create can instead answer its caller — the commandHandler … : T shape: declare a result type after the parameter list and end the body in return <expr>. The route then answers 200 with that value as its JSON body, and the published OpenAPI response carries its schema.

workflow openAccount {
  create(owner: Customer id, number: string): Account id {
    let account = Account.create({ owner: owner, number: number, balance: 0 })
    return account.id
  }
}

workflow transfer transactional {
  create(fromAccount: Account id, toAccount: Account id, amount: int): int {
    let source = Accounts.getById(fromAccount)
    let target = Accounts.getById(toAccount)
    source.withdraw(amount)
    target.deposit(amount)
    return source.balance          // read inside the tx, answered after commit
  }
}
POST /api/workflows/open_account  {"owner": "…", "number": "A-1"}   → 200 "0192f1c4-…"
POST /api/workflows/transfer      {"fromAccount": "…", "toAccount": "…", "amount": 30} → 200 70

Generated (node, http/workflows.ts):

responses: { 200: { description: "OK", content: { "application/json": { schema: z.number().int().openapi({ format: "int32" }) } } }, … },
async (httpCtx) => {
  …
  let workflowResult!: number;
  await db.transaction(async (tx) => {
    const accounts = new AccountRepository(tx, events);
    const source = await accounts.getById(fromAccount);
    const target = await accounts.getById(toAccount);
    source.withdraw(amount);
    target.deposit(amount);
    await accounts.save(source);
    await accounts.save(target);
    workflowResult = source.balance;
  });
  return httpCtx.json(workflowResult, 200);
}

The other four: .NET's command becomes ICommand<T> and the controller answers Ok(result.Value) for an id ([ProducesResponseType(typeof(Guid), 200)]); java's service method returns the wire value (account.id().value()) and the controller method returns it (public UUID openAccount(…)); python's route is status_code=200 with a -> str / -> Int32 annotation; elixir closes the with chain on {:ok, account.id} and the controller answers it through respond_result/2. An id always crosses as its raw value — the same JSON token the aggregate's own id field carries — so it feeds straight into GET /api/accounts/{id}. Full per-backend output: language-reference/13-workflows.md.

Rules (all compile-time errors):

Rule Code
Result type is an X id, string, int, long or bool loom.workflow-return-type-unsupported
The returned value is assignable to T loom.workflow-return-type-mismatch
: T needs a terminal return; a return needs : T loom.workflow-return-missing / loom.workflow-return-untyped
return is the last top-level statement loom.workflow-return-not-last
Only the create the POST serves — not create(e: E) by …, not a named create beside the unnamed one loom.workflow-return-no-caller
Not on an eventSourced workflow (its create may only emit) loom.workflow-return-event-sourced

Returning an aggregate, a payload or a value object is not supported yet (each needs the per-backend wire projection the commandHandler route has) — return the id and read the record back, or use a commandHandler.

Save + event drain semantics

Backend status (banking-eval B-04). The post-commit guarantee above holds on node and .NET. python and elixir still dispatch aggregate events inside the open transaction: their in-process reactors run on the same session / process and so join that transaction (their database writes roll back with it), but a non-database effect a reactor or broadcast performs — Phoenix PubSub / LiveView, realtime push, mail — escapes before commit. java does not publish workflow-raised events at all (neither aggregate-level nor workflow-level). Tracked in docs/new-plan/T4-eventing-temporal.md (M-T4.3 item 6).

Transactional vs non-transactional

By default, workflows are non-transactional: each aggregate save commits independently, matching the DDD principle that the aggregate is the consistency boundary. Mid-workflow failure leaves earlier saves committed.

Adding the transactional keyword wraps the body, all saves, and their event drainage in one DB transaction (one EF BeginTransactionAsync / one Drizzle db.transaction; on persistence: dapper every repository enlists in the unit of work's one Npgsql connection + transaction, so a later failure rolls the earlier saves back there too). Use it when the workflow's aggregates share a database AND you want all-or-nothing semantics — at the cost of locking and the distribution barrier the keyword represents.

Isolation levels

transactional accepts an optional SQL-92 isolation level:

workflow transferCredit(...) transactional(serializable) { ... }
DSL token Postgres meaning When to reach for it
(omitted, bare transactional) connection default (Postgres = read committed) Default; fine for most use cases.
readCommitted Same as omitted, but explicit. Document the choice in source.
repeatableRead Snapshot taken at first read; subsequent reads see the same snapshot. Avoid non-repeatable reads when the workflow loads the same row twice.
serializable True serializable; commits may fail with 40001 and need retry. Multi-aggregate balance / invariant updates where read-write skew would corrupt state.
readUncommitted Postgres treats as read committed (no dirty reads); SQL Server allows dirty reads. Rare; usually a foot-gun.

Generators emit explicit settings only when the keyword is supplied:

Bare transactional emits the no-arg form on every backend so the connection-default behaviour is preserved.

Reactor failures

An event-triggered create(e: Event) by … starter or on(e: Event) reactor runs after the command whose event it reacts to has committed. Its failure never changes that command's answer. The command keeps its 2xx and its write, the failed reaction leaves no effects of its own, and the backend logs one reactor_failed line (handler, event_type, event_id, error):

aggregate Ticket with crudish {
  email: string
  status: string
  operation resolve() {
    precondition status == "Open"
    status := "Resolved"
    emit TicketResolved { ticket: id, email: email }
  }
}
workflow AskForRating {
  ticket: Ticket id
  create(e: TicketResolved) by e.ticket {
    precondition e.email != "boom@example.test"
    let s = Survey.create({ ticket: e.ticket, email: e.email })
  }
}
POST /api/tickets/{id}/resolve      → 204   (status = Resolved, committed)
{"level":"error","event":"reactor_failed","handler":"askForRatingStartTicketResolved",
 "event_type":"TicketResolved","event_id":"d68c…","error":"Precondition failed: …"}
GET  /api/workflows/ask_for_rating/instances/{id} → 404   (no saga row, no Survey)

Before H-28, the same model answered 500 after the commit (node, elixir) or 422 after the commit (.NET), so a client retry then hit the precondition. On java and python it answered 422 and the reactor rolled the command back.

How each backend gets there:

backend when the reaction runs isolation
node after the repository's transaction commits createInProcessDispatcher(db, { isolateReactorFailures: true }) on the request path; each handler is caught on its own
.NET after SaveChangesAsync InProcessDomainEventDispatcher catches by default and detaches the EF changes the failed reaction staged
elixir after the op's Repo.update the request path calls <Ctx>.Dispatcher.AfterCommit (rescue + catch); each handler runs in Repo.transaction, so its saga row rolls back with it
java @TransactionalEventListener(AFTER_COMMIT) <Ctx>Dispatcher.ReactorListener runs each handler in its own REQUIRES_NEW transaction
python inside the request transaction, before it commits each reaction runs in a SAVEPOINT (session.begin_nested())

No inline retry. A reaction's side effects, such as an email already sent, are not idempotent, so the request path does not re-run it. When a reaction must happen eventually, carry its event on a durable channel (retention: work | log, channels.md). The event is then recorded in __loom_outbox in the command's own transaction, and the relay delivers it at-least-once: it retries a failing reactor up to maxAttempts times, then logs event_dead_lettered and keeps the row. The relay, broker consumers, and the node timer scheduler deliberately use the raising dispatcher, because their retry and redelivery depend on the throw. On .NET the timer resolves the request-path dispatcher, so a failing tick reaction is logged rather than retried.

Generated code

.NET (ASP.NET Core + Mediator)

Application/Workflows/
  PlaceOrderRequest.cs        — wire-shape DTO (X id → Guid, datetime → string)
  PlaceOrderCommand.cs        — domain-typed Mediator command
  PlaceOrderHandler.cs        — handler that orchestrates the body
Api/
  SalesWorkflowsController.cs — POST /workflows/place_order

The handler injects each IXRepository it needs (one per repo referenced) plus IDomainEventDispatcher (when the workflow uses emit) and AppDbContext (when transactional). All DI is handled by the existing AddScoped registrations + Mediator's source generator.

Hono (TypeScript + Drizzle)

http/workflows.ts             — POST /workflows/<snake_workflow> per workflow
http/index.ts                 — mounts the workflows router under /workflows

The handler constructs each XRepository from the request's db handle (or from tx inside db.transaction(async (tx) => {...}) when transactional), runs the body, awaits each repo.save(...) in declaration order, then dispatches workflow-level events.

Phoenix LiveView (Elixir / Ecto)

lib/<app>/<ctx>/
  workflows/place_order.ex      — context module: run/2 wrapping Repo.transaction
lib/<app>_web/controllers/
  workflows_controller.ex       — POST /api/workflows/place_order per command workflow

run/2 threads current_user as its second arg, weaves the body into a with-chain over the context's public functions (create_<agg>, get_<agg>, <op>_<agg>), wraps it in Repo.transaction/1 when transactional, and broadcasts workflow-level events via Phoenix.PubSub. An event-triggered-only workflow emits no run/2 or controller — see Status below.

Python (FastAPI + SQLAlchemy)

The workflow handler constructs each repository from the request session, runs the body, saves in declaration order, and drains workflow-level events. transactional wraps the body in the session transaction, with the isolation_level execution option set when a level is pinned.

Java (Spring Boot + JPA)

The workflow handler injects each repository / service it needs, runs the body, saves in declaration order, and drains workflow-level events. A transactional workflow is annotated @Transactional (with isolation = Isolation.<Level> when a level is pinned).

When to reach for a workflow

Reach for a workflow when:

Stay with an aggregate operation when the work fits inside one aggregate's invariants — that's simpler, faster, and doesn't need a workflow file.

Triggers and command parameters

A workflow's entry points are declared by its members (the workflow body is members-only — workflow X { create(...) { ... } }):

Member Trigger
create [name](params) [by <expr>] { ... } A starter. The parameter shape discriminates the trigger (resolved at lowering): positional domain params synthesise an implicit command; a single payload param (create(c: PlaceOrder)) is an explicit command; a single event param with a by clause (create(e: OrderPlaced) by e.order) is event-triggered.
on(e: Event) [by <expr>] { ... } An external-event reactor.

A named command entry that is not a starter is a context-level commandHandler, not a workflow member — see below.

A create parameter may be typed by an event or a payload (command / query / response / error) named directly:

command SettleOrder { order: Order id, note: string }
event   PaymentReceived { order: Order id, amount: int }

workflow Fulfillment {
  invoiceId: Invoice id                          // correlation field

  create(c: SettleOrder)               { ... }   // explicit command-triggered
  create(paid: PaymentReceived) by paid.order { ... }   // event-triggered
}

The bound parameter is a flat transport record: paid.amount resolves to the field's declared type and is type-checked like any other expression (paid.amount == "x" is a type error). These transport types are in scope only in create parameter positions (and a commandHandler / queryHandler contract) — elsewhere a bare event/payload name stays unresolved. See the language reference.

There is no handle

A workflow has no handle name(params) { … } member (D-HANDLE-REMOVAL). It had no correlation surface — nothing said which saga instance a call continued — and no backend ever emitted a route for it. A named command entry is a context-level commandHandler, which every backend routes:

// before — no longer parses               // after
workflow Settle {                          workflow Settle {
  create(orderId: Order id) { … }            create(orderId: Order id) { … }
  handle close(orderId: Order id) {        }
    let o = Orders.getById(orderId)        commandHandler close(orderId: Order id) {
    o.close()                                let o = Orders.getById(orderId)
  }                                          o.close()
}                                          }

A leftover handle is a parse error naming the replacement. node scripts/codemod-workflow-handle.mjs <path> lifts each handle into a commandHandler after its workflow, and leaves in place — marked // codemod-workflow-handle: not lifted — <reason> — the ones with no faithful rewrite: a body that reads the workflow's own state or helpers, a handle of an eventSourced workflow, a requires gate (a commandHandler takes none; gate the aggregate operation it calls), a parameter named id, a body reaching more than one aggregate, or a name another handler of the context already holds. Model those continuations as an aggregate operation (the aggregate owns the state transition, and the operation gets a route on every backend), or as a second workflow started by the event the first one emits.

Principal. A reactor / event starter has no request, so it runs as the system principal (D-REACTOR-SYSTEM-PRINCIPAL): currentUser.isSystem is true, every claim is empty, and the tenant is the triggering event's. Gates on the operations it calls are evaluated against it — requires currentUser.isSystem || … admits it; a gate that reads currentUser without that disjunct is warned (loom.reactor-gate-unsatisfiable). See language-reference/17-auth.md § Reactors.

Status. The parameter surface above parses, resolves, and type-checks today. In-process dispatch ships on the Hono, .NET, Phoenix, Python, and Java backends (channels.md): when a channel in the deployable carries: the event, an emitted event is delivered to every on(e: Event) reactor and event-triggered create(e: Event) by starter that subscribes to it, and a handler's own emit re-enters the dispatcher so choreography chains run. No channelSource ⇒ this in-process dispatcher is the default; a channel-less project keeps the no-op (byte-identical).

Still deferred: external brokers (redis / kafka / nats via channelSource). See channels.md and workflow-and-applier.md.

Not sure whether a use-case is a workflow, a commandHandler, an aggregate operation or a domainService? See Choosing a callable kind — one table, with what each may mutate, call and emit.