Subscriptions

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:

sub-domains/fulfillment/subscriptions/fulfillment.vishwakarma
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 in fulfillment but listens for an event owned by ordering.
  • 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]
  1. The event is written in the same transaction as the aggregate. When PlaceOrder commits, the framework writes the OrderPlaced event into an outbox table as part of the same unit of work as the Order row. 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.
  2. A relay drains the outbox into the broker. A dedicated relay process (emitted from the project's events slot) 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.
  3. A worker consumes and dispatches. A worker process (emitted from your subscription declarations) 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 MarkOrderPaid carries a guard "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_casesubscription
When it runssynchronously, when the caller invokes itasynchronously, when an event arrives
Boundaryone transaction, all-or-nothingseparate transaction from the event's source
Couplingthe caller drives the whole flowthe source announces a fact; the reactor decides
Reach for it whenseveral aggregates must change together, nowsomething 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.