A subscription is an event reaction: it listens for a domain event and, when one arrives, dispatches a command. Where a use_case orchestrates several writes synchronously in one transaction, a subscription reacts asynchronously to a fact that already happened — often in a different sub-domain, run by a different team. It is the realized half of event-driven architecture: one side announces a fact, the other side reacts, and neither calls the other.
Chowk's fulfillment reacts to an order being placed:
subscription StartFulfillmentOnOrderPlaced {
on ordering.v1.OrderPlaced
command StartFulfillment
systems [FulfillmentSystem]
doc "On OrderPlaced, open a shipment for the order."
input {
order_id: "event.order_id"
}
retry {
max_attempts 5
backoff exponential
on_failure dead_letter
}
}on and command — the trigger and the reaction
Two clauses carry the whole intent:
on ordering.v1.OrderPlaced— the event this subscription listens for, named by its fully-qualified name (the sub-domain, its version, and the event). This subscription lives infulfillmentbut listens for an event owned byordering.command StartFulfillment— the command to dispatch when that event arrives. Here it opens a shipment for the order.
The subscription is the only thing that connects the two sub-domains, and it does so one-directionally: ordering never mentions fulfillment. PlaceOrder simply emits OrderPlaced and moves on; the fact is what travels.
input — mapping the event to the command
The dispatched command needs inputs, and they come from the event payload. The input block maps command fields from event.*:
input {
order_id: "event.order_id"
}StartFulfillment needs an order_id; the OrderPlaced event carries one, so the mapping wires event.order_id into the command's order_id. This is the same shape of input mapping a use case uses for its saga steps — here the source is the event rather than a prior step's result.
The event-driven loop, end to end
Put the command, the event, and the subscription together and you have the full asynchronous spine. PlaceOrder emits OrderPlaced; the framework delivers that event through its outbox-and-relay pipeline to the worker that runs subscriptions; the subscription dispatches StartFulfillment, which opens a shipment:
sequenceDiagram participant C as Client participant O as ordering · PlaceOrder participant B as Event pipeline participant F as fulfillment · StartFulfillmentOnOrderPlaced participant S as fulfillment · StartFulfillment C->>O: place an order O-->>B: emit OrderPlaced Note over O,B: the command commits and the event is durably recorded, atomically B->>F: deliver OrderPlaced F->>S: dispatch StartFulfillment (order_id from event) S-->>S: open a Shipment (status PENDING)
Ordering and fulfillment share no code and make no direct calls; they share only the event contract. And the fact is never lost even if fulfillment is momentarily down — because of how that middle step ("the command commits and the event is durably recorded, atomically") actually works.
The transactional outbox — why an event is never lost
The naïve way to publish an event is to write the database row and then call the broker. That is the dual-write problem: two separate systems, two separate steps. If the process dies between them, you either committed the state change but never announced it (a lost event), or announced something that then rolled back (a phantom event). No amount of retry logic around a two-step publish fully closes that gap.
Vishwakarma closes it structurally with the transactional outbox pattern, and you get it for free by declaring emits on a command:
flowchart LR
subgraph TX["one transaction (UoW)"]
W[write Order row] --- E[write event to<br/>outbox table]
end
TX -->|commits atomically| DB[(database)]
R[relay<br/>·singleton·] -->|drains outbox| BR{{broker}}
DB -.polled by.-> R
BR -->|delivers| WK[worker<br/>·scales on lag·]
WK -->|dispatches| SUB[subscription command]- The event is written in the same transaction as the aggregate. When
PlaceOrdercommits, the framework writes theOrderPlacedevent into an outbox table as part of the same unit of work as theOrderrow. Either both land or neither does — there is no window in which the order exists but the event was lost, or vice versa. The command's own transaction is the durability guarantee. - A relay drains the outbox into the broker. A dedicated
relayprocess (emitted from the project'seventsslot) reads committed outbox rows and publishes them to the message broker. It runs as a single leader-elected instance, so each event is relayed once. - A worker consumes and dispatches. A
workerprocess (emitted from yoursubscriptiondeclarations) consumes from the broker and invokes the matching subscription's command. It scales horizontally on consumer lag.
The two extra processes are not something you wire up — codegen emits relay and worker from the events slot and the subscriptions themselves. Without the events slot configured, there is no relay, and every asynchronous flow silently no-ops.
At-least-once delivery — and why that shapes the reaction
The outbox buys durability, but the delivery guarantee it provides is at-least-once, not exactly-once. A relay or worker that restarts mid-batch may redeliver an event it already published or handled. This is not a flaw to fix — it is the honest contract of any durable pipeline, and it dictates two things about how you write the reaction:
- The dispatched command should be idempotent. Handling the same event twice must be safe. This is exactly why a command like
MarkOrderPaidcarries aguard "status == 'PENDING'"(see Commands) — a redelivered event re-runs the command, the guard fails, and the repeat is a harmless no-op instead of a double effect. - Transient failure is expected, so retry is declared, not hand-rolled. A downstream row may not be visible yet; a dependency may blip. The subscription states its retry policy and the worker enforces it.
retry — reacting reliably
Because a subscription runs asynchronously and may fail transiently (a downstream row not yet visible, a brief outage), it declares how to retry:
retry {
max_attempts 5
backoff exponential
on_failure dead_letter
}max_attempts 5— try up to five times before giving up.backoff exponential— wait longer between each attempt, rather than hammering a struggling dependency.on_failure dead_letter— after the last attempt fails, route the event to a dead-letter destination for inspection instead of dropping it silently.
This is reliability you declare rather than build: the framework owns the retry loop, the backoff timing, and the dead-letter routing. You state the policy; the worker enforces it.
Subscription versus use case
Both compose operations, so it helps to keep the distinction sharp:
use_case | subscription | |
|---|---|---|
| When it runs | synchronously, when the caller invokes it | asynchronously, when an event arrives |
| Boundary | one transaction, all-or-nothing | separate transaction from the event's source |
| Coupling | the caller drives the whole flow | the source announces a fact; the reactor decides |
| Reach for it when | several aggregates must change together, now | something reacts to a fact after it happened |
If a customer clicking checkout must place the order and reserve its first line together, that is a use case. If opening a shipment should happen because an order was placed — but not block the customer's checkout — that is a subscription.