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:
- Pure — like any
function, a workflow function may not mutate,emit, or call an operation / repository / domain service / extern (a block body is gated byloom.function-block-impure). It may call a sibling workflowfunction. - Pure over its parameters — it may not read the workflow's own state
(
this/ a state field): the helper is emitted at module/static scope, where there is nothis. A body that does is rejected —loom.workflow-function-uses-state. Pass the value in as a parameter.
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
- Fresh aggregates (
Agg.create) are always saved at workflow exit, in declaration order. - Loaded aggregates (
Repo.getByIdetc.) are saved only if at least one operation was invoked on them inside the workflow body. - There is no save statement. An explicit
Repo.save(x)(orinsert/update/add/commit) in a workflow or handler body is an error,loom.workflow-explicit-save: the exit save above is the one way a body persists.Repo.delete(x)/Repo.remove(x)is the one repository write a body states, and the removed aggregate is not saved again on the way out. - State needs a key. A command
createthat assigns workflow state on a workflow with no id-shaped state field is an error,loom.workflow-uncorrelated-state-write: without a correlation key no instance row is kept, so no later step could read the write. Reading that state (precondition total == 0) is an error for the same reason,loom.workflow-uncorrelated-state-read: there is no instance to read. - A mutation needs a save. Every aggregate a body mutates must be one
the exit save (or a loop's / branch's save) writes. A mutating call
whose result is bound —
let r = a.withdraw(x), orlet r = Transfer.run(src, dst, x)on amutatingdomain service — is not saved yet, so it is an error,loom.workflow-mutation-unsaved; write the call as its own statement. The same code refuses a mutation of a local that is neither created nor loaded from a repository, and an aggregate passed to a mutating service as an expression rather than a bound local. - Three spellings, one create.
Agg.create(a, b)(positional),Agg.create(x: a, y: b)(named) andAgg.create({ x: a, y: b })(record) are the same factory call, created and saved alike. Positions bind to the aggregate's create input in order: the parameters of its declaredcreate(…)(with crudishdeclares one from the writable fields), else its properties minus themanaged/token/internalones; trailing inputs may be left out when they are optional or defaulted. More positions than inputs, positional and named arguments mixed, a name that is not an input, or a name given twice isloom.workflow-create-args. - Aggregate-level events (raised inside an operation via
emit) drain through the aggregate's repository save. In a non-transactional workflow each save commits on its own, so its events dispatch right after it — same path as before workflows. In atransactionalworkflow the save only writes into the workflow's open transaction, so the repository buffers its events instead (node: adeferredDispatcher(events)handed to every repository built ontx; .NET: an ambientDomainEventDeferralscope the handler opens beforeBeginTransactionAsync) and the workflow dispatches the buffer only after commit. On rollback the buffer is discarded, so a consumer never sees an event for a write that was undone. Durable capture is unaffected: a durable event's outbox row is still recorded inside the transaction and commits or rolls back with it. - Workflow-level events (raised via
emitdirectly inside the workflow body) accumulate in a local list and dispatch throughIDomainEventDispatcherafter all saves complete. Whentransactional, the dispatch happens after commit (after the buffered aggregate events); on rollback / mid-workflow exception, workflow events are discarded.
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:
- .NET:
await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct)(etc.). - Hono:
await db.transaction(async (tx) => { ... }, { isolationLevel: "serializable" })(etc.); underpersistence: mikroorm,await db.transactional(async (tx) => { ... }, { isolationLevel: IsolationLevel.SERIALIZABLE }). - Python:
await session.connection(execution_options={"isolation_level": "SERIALIZABLE"})(etc.). - Java:
@Transactional(isolation = Isolation.SERIALIZABLE)(etc.).
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:
- a use-case naturally touches more than one aggregate ("place an order" reads Customer, creates Order),
- you want HTTP-callable orchestration that's bigger than an operation but smaller than a saga,
- ACID across a few aggregates is genuinely the right semantic
(
transactional).
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.isSystemis 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 readscurrentUserwithout that disjunct is warned (loom.reactor-gate-unsatisfiable). Seelanguage-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
channelin the deployablecarries:the event, an emitted event is delivered to everyon(e: Event)reactor and event-triggeredcreate(e: Event) bystarter that subscribes to it, and a handler's ownemitre-enters the dispatcher so choreography chains run. NochannelSource⇒ this in-process dispatcher is the default; a channel-less project keeps the no-op (byte-identical).
- Hono routes each
emitthrough the generatedcreateInProcessDispatcher(db). Correlation is persisted: acreatestarter loads-or-allocates its workflow-state row (keyed by the correlation field), and anonreactor routes to the existing row or drops + logsevent_unroutedwhen none exists.- .NET publishes each emitted domain event as a Mediator notification (
IDomainEvent : INotification), so every reactor / starterINotificationHandler<TEvent>runs; Program.cs registers theInProcessDomainEventDispatcher(Scoped) instead of the no-op. Correlation is persisted the same way: each correlation-bearing workflow gets an EF-mapped saga-state row (a<Workflow>StatePOCO +IEntityTypeConfigurationkeyed by the correlation field, table shared with the canonical migration); acreatestarter loads-or-allocates the row (seeding the key + typed defaults via the injectedAppDbContext), anonreactor routes to the existing row or drops + logsevent_unrouted, andthis.<stateField>reads the loaded row.- Phoenix emits a per-context
<Ctx>.Dispatcherthat pattern-matches each event struct to its handler module(s); each subscription becomes a<Wf>.Start<Event>/<Wf>.On<Event>module with ahandle(event), and a handler's ownemitre-enters<Ctx>.Dispatcher.dispatch/1. Correlation is persisted through a saga-stateEcto.Schemakeyed by the correlation field (over the table the canonical migration derives), read/written through the appRepo: acreatestarter loads-or-allocates the row, anonreactor routes to it or drops + logsevent_unrouted. An event-triggered-only workflow emits norun/2/ HTTP command surface.- Python emits an in-process dispatcher (
dispatch-builder.ts) that routes each emitted event to its reactor / starter handlers; a handler's ownemitre-enters the dispatcher. Correlation is persisted through a saga-state row keyed by the correlation field: acreatestarter loads-or-allocates the row, anonreactor routes to it or drops + logsevent_unrouted.- Java emits an in-process dispatcher (
dispatch.ts) that routes each emitted event to its reactor / starter handlers; a handler's ownemitre-enters the dispatcher. Correlation is persisted through a JPA-mapped saga-state row keyed by the correlation field: acreatestarter loads-or-allocates the row, anonreactor routes to it or drops
- logs
event_unrouted.Still deferred: external brokers (redis / kafka / nats via
channelSource). Seechannels.mdandworkflow-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.