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.
The three message kinds
Section titled “The three message kinds”| 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.
The two layers
Section titled “The two layers”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.
How the pieces fit
Section titled “How the pieces fit” 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 busIndependently of that pipeline:
DeferralSinkis the optional seam that decides whatsubscribeAfterCommitwaits for. Install@effect-server-utils/unit-of-workand it means after commit; install nothing and those handlers run at the end of each dispatch. Handlers are written the same way either way.EventBuscarries events published inside that boundary, with the consistency model picked per subscription.Saga.runnerruns process managers over the after-commit stream.UnhandledFailurescollects failures from the isolated positions — after-commit handlers and sagas — that have no caller left to report to.
Design commitments
Section titled “Design commitments”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.