CQRS
CQRS — Command Query Responsibility Segregation — splits the two things your app does to data: commands change state, queries read it. Heximon gives each its own typed bus and exactly one handler per message, all wired at compile time. The payoff is a codebase where every write and every read is an explicit, named, individually testable unit.
Setup
Install the package and register its compiler plugin alongside the HTTP plugin:
pnpm add @heximon/cqrs
npm install @heximon/cqrs
yarn add @heximon/cqrs
bun add @heximon/cqrs
import { defineHeximonConfig } from "@heximon/build";
import { CqrsPlugin } from "@heximon/cqrs/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin(), new CqrsPlugin()],
});
Define a command
A Command is a request to change state. It states intent and returns nothing — its handler
resolves to void. The payload is just constructor parameters.
import { Command } from "@heximon/cqrs";
import type { UserId } from "../user";
export class CreateUserCommand extends Command {
constructor(
public readonly id: UserId,
public readonly name: string,
public readonly email: string,
) {
super();
}
}
UserId.generate(), pass it into the command, then read the new state back with a query. The command
never has to hand an id back — and the caller knew the id all along.Handle a command
implements CommandHandler<CreateUserCommand> is the link from message to handler — the compiler reads
the command from the type argument and routes it here. There is exactly one handler per command;
declaring a second is a build error.
The constructor is the dependency declaration, so the kernel injects UserStore by class identity — no
token to declare. (extends CommandHandler<...> binds identically — use it when you want your own
base-class hierarchy on top of a handler.)
import type { CommandHandler } from "@heximon/cqrs";
import { UserStore } from "../user.store";
import { CreateUserCommand } from "./create-user.command";
export class CreateUserCommandHandler implements CommandHandler<CreateUserCommand> {
constructor(private readonly userStore: UserStore) {}
public handle(command: CreateUserCommand): void {
this.userStore.save({ id: command.id, name: command.name, email: command.email, version: 1 });
}
}
Define and handle a query
A Query<TResult> is a request to read state that produces a typed result. The result type flows
from the query's generic, through the bus, to the call site — so queryBus.execute(new GetUserQuery(id))
is typed User | undefined with nothing to annotate.
import { Query, type QueryHandler } from "@heximon/cqrs";
import type { User, UserId } from "../user";
import { UserStore } from "../user.store";
export class GetUserQuery extends Query<User | undefined> {
constructor(public readonly id: UserId) {
super();
}
}
export class GetUserQueryHandler implements QueryHandler<GetUserQuery> {
constructor(private readonly userStore: UserStore) {}
public handle(query: GetUserQuery): User | undefined {
return this.userStore.findById(query.id);
}
}
Dispatch from a controller
CommandBus and QueryBus are provided for you, so a plain Controller injects them straight through
its constructor — no base class, no tokens. The write/read split shows up at every endpoint: a write
executes a command (which returns nothing), then re-reads with a query.
import { CommandBus, QueryBus } from "@heximon/cqrs";
import { NotFoundError } from "@heximon/runtime/errors";
import type { Controller, Get, Post } from "@heximon/http";
import { CreateUserCommand } from "./commands/create-user.command";
import { GetUserQuery } from "./queries/get-user.query";
import { type User, UserId } from "./user";
import { CreateUserBody } from "./users.schema";
export class UsersController implements Controller<"/users"> {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
public async get(action: Get<"/:id">): Promise<User> {
const id = UserId.from(action.request.pathParams.id);
const user = await this.queryBus.execute(new GetUserQuery(id));
if (!user) throw new NotFoundError(`User ${id} not found`);
return user;
}
public async create(action: Post<"/", { body: CreateUserBody }>): Promise<User> {
const body = await action.request.readValidatedBody();
const id = UserId.generate(); // mint the id up front
await this.commandBus.execute(new CreateUserCommand(id, body.name, body.email));
// The command returned nothing — read the new state back through the read side.
const user = await this.queryBus.execute(new GetUserQuery(id));
if (!user) throw new NotFoundError(`User ${id} not found`);
action.response.status = 201;
return user;
}
}
CqrsController injects both buses for you and exposes them as
protected readonly. It's optional — a plain Controller listing the buses in its own constructor
works identically.Customize dispatch with interceptors
Interceptors are the main place to add cross-cutting behavior — logging, metrics, auth, caching, or
short-circuiting a dispatch. intercept(message, next) is an onion: call next() to continue to the
handler, or return without calling it to short-circuit (a query interceptor can return a cached
result this way).
Scope one to a single message (implements CommandInterceptor<CreateUserCommand>) or make it app-wide
(implements CommandInterceptor — no type argument). The same scoping applies to QueryInterceptor.
import { Command, type CommandInterceptor, MessageClassName } from "@heximon/cqrs";
import { AuditLog } from "../audit-log";
export class AuditCommandInterceptor implements CommandInterceptor { // app-wide (no type arg)
constructor(private readonly auditLog: AuditLog) {}
public async intercept(command: Command, next: () => Promise<void>): Promise<void> {
this.auditLog.record({ command: MessageClassName.of(command), phase: "before" });
await next(); // omit this call to short-circuit the dispatch
this.auditLog.record({ command: MessageClassName.of(command), phase: "after" });
}
}
command.constructor.name mangles under production minification, so this interceptor logs
MessageClassName.of(command) instead — the compiler-stamped name that survives it.
A query interceptor short-circuits by returning a value without calling next — the right home for a
cache gate:
import type { QueryInterceptor } from "@heximon/cqrs";
import type { User } from "../user";
import { UserCache } from "../user-cache";
import type { GetUserQuery } from "../queries/get-user.query";
export class CacheGetUserInterceptor implements QueryInterceptor<GetUserQuery, User | undefined> {
constructor(private readonly cache: UserCache) {}
public async intercept(
query: GetUserQuery,
next: () => Promise<User | undefined>,
): Promise<User | undefined> {
const hit = this.cache.get(query.id);
if (hit) return hit; // short-circuit — handler skipped
const result = await next();
if (result) this.cache.set(query.id, result);
return result;
}
}
Interceptors are ordinary DI participants: their constructor declares dependencies, and the compiler composes them into the around-chain.
Per-handler retry policy
The default ConcurrencyError retry (3 attempts, 100 ms delay) applies to every command. Pass
{ retry } as a second type argument to CommandHandler to override it for a specific command:
export class CreateUserCommandHandler implements CommandHandler<CreateUserCommand, {
retry: { attempts: 5; delay: 50 };
}> {
// ...
}
retry.attempts is required; retry.delay is the millisecond gap between attempts. Queries are
never retried — the { retry } field is ignored for QueryHandler.
Automatic ConcurrencyError retry
Commands automatically retry on ConcurrencyError — it's baked into the wiring, not your handler. Pair
it with optimistic locking in your repositories (a version column): if two concurrent writes conflict,
the losing command re-runs with fresh state. Queries are never retried.
| Setting | Default |
|---|---|
| Max attempts | 3 |
| Delay between attempts | 100 ms |
Only ConcurrencyError triggers a retry — every other exception propagates immediately, which keeps
retries scoped to real contention rather than application bugs. If all attempts are exhausted, the final
ConcurrencyError is rethrown for the caller to surface (usually as a 409).
ConcurrencyError is exported from @heximon/runtime/errors, not @heximon/cqrs. Your
repositories throw it when the optimistic-concurrency check fails; CommandBus catches and retries it.import { ConcurrencyError } from "@heximon/runtime/errors";
Command transaction wrap
When a command handler's owning module binds a Database, the compiler wraps that command's dispatch in
Database.runInTransaction automatically — so the handler's writes and the domain events it emits are atomic
(a throw rolls the whole unit back). You do nothing: the wrap is derived from the module scope.
That automatic resolution needs exactly one Database. When a module binds more than one — a write
primary and a read replica, say — the wrap target is ambiguous, and the compiler errors rather than guessing.
Disambiguate with the optional { database } field on the second type argument:
import type { CommandHandler } from "@heximon/cqrs";
import { PlaceOrderCommand } from "./place-order.command";
import type { WriteDatabase } from "./databases";
// Wrap on the named writing database (the module also binds a read replica).
export class PlaceOrderHandler implements CommandHandler<PlaceOrderCommand, { database: WriteDatabase }> {
handle(command: PlaceOrderCommand): void {
// ...
}
}
Pass { database: false } to opt a command out of the wrap entirely — for a read-only command, or one
that manages its own transactions — even though its module binds a Database:
import type { CommandHandler } from "@heximon/cqrs";
import { RefreshViewCommand } from "./refresh-view.command";
export class RefreshViewHandler implements CommandHandler<RefreshViewCommand, { database: false }> {
handle(command: RefreshViewCommand): void {
// ...
}
}
{ database } | Behavior |
|---|---|
| omitted (default) | Auto: zero Databases in scope → no wrap; exactly one → wrap on it; two or more → a build error. |
a concrete Database class | Wrap on that named database (verified resolvable in the module's scope). |
false | Dispatch unwrapped, even when the module binds a Database. |
The { database } field combines with { override } and { retry } in the same options literal. It is read
only for commands — queries are never wrapped in a transaction.
CommandHandlerOptions ({ override, retry, database }) and QueryHandlerOptions ({ override }).A downstream package can augmentQueryHandlerOptions with its own serializable option; the compiler
forwards any option it does not itself consume onto the query class, where a runtime interceptor reads it. For
example, @heximon/cache adds a cache policy so a query handler declares its caching
inline: implements QueryHandler<GetProductQuery, { cache: { ttlSeconds: 30; tags: ["product"] } }>.Wire the module
CQRS participants are declared under the cqrs namespace — never in providers. The compiler routes
handlers to their buses and interceptors into the around-chain. Plain injectables (the store, the audit
log) stay in providers.
import { Module } from "@heximon/runtime";
import { CreateUserCommandHandler } from "./commands/create-user.handler";
import { UpdateUserCommandHandler } from "./commands/update-user.handler";
import { GetUserQueryHandler } from "./queries/get-user.handler";
import { ListUsersQueryHandler } from "./queries/list-users.handler";
import { AuditCommandInterceptor } from "./interceptors/audit-command.interceptor";
import { CacheGetUserInterceptor } from "./interceptors/cache-get-user.interceptor";
import { UsersController } from "./users.controller";
import { AuditLog } from "./audit-log";
import { UserCache } from "./user-cache";
import { UserStore } from "./user.store";
export class UsersModule extends Module({
providers: [UserStore, AuditLog, UserCache],
http: { controllers: [UsersController] },
cqrs: {
commandHandlers: [CreateUserCommandHandler, UpdateUserCommandHandler],
queryHandlers: [GetUserQueryHandler, ListUsersQueryHandler],
commandInterceptors: [AuditCommandInterceptor],
queryInterceptors: [CacheGetUserInterceptor],
},
exports: [UserStore, AuditLog],
}) {}
See also
- Domain-Driven Design — drive your command handlers against aggregates that enforce invariants and record domain events.
- Events — flush an aggregate's events after a command saves it, then react in-process or across services.
- Repository Pattern — the persistence contract behind a command handler, with the optimistic concurrency that feeds the retry.
- L05 — CQRS commands & queries
— runnable commands and queries, an app-wide audit interceptor, and a test that proves the 3-attempt
ConcurrencyErrorretry.
Domain-Driven Design
Model your domain with the Entity, ValueObject, and AggregateRoot bases — value equality, dirty tracking, optimistic concurrency, and domain events.
Repository Pattern
A collection-like persistence boundary with save, getById, getAll, and a zero-codegen EntityQuery, swappable behind an abstract Repository token, with optimistic-concurrency ConcurrencyError.