Skip to content

CQRS Overview

@effect-server-utils/cqrs is a typed message bus for Effect, plus the pieces a bus is the natural place to put: transactional boundaries, event delivery, and cross-cutting middleware.

Kind Handlers Answers with Declared with
Command exactly one its declared success or failure Command.make
Query exactly one its declared success or failure Query.make
Event any number nothing — it fans out Event.make

Commands and queries are separate facades over the same machinery. That split is not cosmetic: a message carries its side in its type, so a query group is rejected where a command group is expected. The CQRS distinction is enforced by the compiler rather than by convention.

There are two distinct things called “dispatching”, and keeping them apart is most of what makes the package’s types work.

A module’s own dispatch surface. A module groups the tags it owns, implements them, and publishes a dispatcher — one method per tag. A caller inside that module reaches its own commands without naming any application-wide bus:

const placeOrder = Effect.gen(function* () {
const orders = yield* Command.dispatcher(OrderCommands);
yield* orders.PlaceOrder({ orderId }); // typed from the definition
});

The application-wide bus. At the composition root, every module’s surface is folded into one routing table, and the bus routes by tag:

const bus = makeCommandBus(mergeDispatchTables(orders, billing, inventory), {
declaredIn: [OrderCommands, BillingCommands, InventoryCommands],
});
const anywhere = Effect.gen(function* () {
yield* bus.execute(PlaceOrder, { orderId }); // same types, from anywhere
});

The table is erased — a tag to a function. It restates no message’s signature, because execute reads the signature off the definition its caller passes and each module’s registration already checked its handlers against the same definitions. declaredIn is what closes the remaining gap: see Dispatch tables.

Command.make / Query.make declare a message + its channels
Command.group a module's slice of the surface
├──► Command.handlersOf implement it (a Layer carrying the handlers' requirements)
└──► Command.dispatcher one method per tag, + span/metrics middleware
mergeDispatchTables fold every module's surface, at the composition root
makeCommandBus the application-wide bus

Independently of that pipeline:

  • DeferralSink is the optional seam that decides what subscribeAfterCommit waits for. Install @effect-server-utils/unit-of-work and it means after commit; install nothing and those handlers run at the end of each dispatch. Handlers are written the same way either way.
  • EventBus carries events published inside that boundary, with the consistency model picked per subscription.
  • Saga.runner runs process managers over the after-commit stream.
  • UnhandledFailures collects failures from the isolated positions — after-commit handlers and sagas — that have no caller left to report to.

A handler’s requirements are discharged where its module registers. Command.handlersOf returns a Layer carrying whatever services its handlers need. By the time anything dispatches, the requirement channel is empty by construction — not stripped by a cast.

Handlers run in the dispatching fiber’s context. A command dispatched from inside a caller’s transaction sees that transaction and joins it rather than opening a second one. A query resolved during a mutation reads through that mutation’s uncommitted writes rather than a stale view. Both are pinned by tests, because the obvious hand-rolled alternative gets them wrong.

Middleware may not change a message’s channels. A caller’s inferred type comes from the definition, so anything able to widen E at the seam would silently invalidate every catchTag written against it. Retry, timeout, logging, metrics, and spans all fit inside that constraint. Validation does not, which is why schema compatibility is asserted in tests instead.

Wiring mistakes are tagged defects, not messages. DuplicateDispatchTag, UnroutableTags, and MissingHandler are conditions a boot check or a test wants to match on, so they arrive as values rather than as sentences inside a generic error.