Heximon Logo
Architecture

CQRS

Split writes from reads with a typed CommandBus and QueryBus, one handler per message, around-chain interceptors, and automatic ConcurrencyError retry.

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
heximon.config.ts
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.

create-user.command.ts
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();
  }
}
A command returns nothing, so how do you get the new id? Mint it before you dispatch: 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.)

create-user.handler.ts
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.

get-user.query.ts
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.

users.controller.ts
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;
  }
}
Prefer a base class? 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.

audit-command.interceptor.ts
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:

cache-get-user.interceptor.ts
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:

create-user.handler.ts
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.

SettingDefault
Max attempts3
Delay between attempts100 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:

place-order.handler.ts
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:

refresh-view.handler.ts
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 classWrap on that named database (verified resolvable in the module's scope).
falseDispatch 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.

Query handler options are an extension point. The options interface is split per family — 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.

users.module.ts
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 ConcurrencyError retry.
Copyright © 2026