Build a Production CRUD API
The simple CRUD recipe is a thin controller over a plain repository — exactly
right until the feature earns more. This one is the production shape: a resource served behind a shared
contract (so the frontend calls it with zero hand-written types), reads split from writes with CQRS,
validated pagination, and an aggregate persisted through a Drizzle repository. Every block below is
lifted from the runnable flagship catalog context.
What you get
The end state is one class — the contract — that the backend serves and the frontend is. Here is the whole API surface:
import { Contract, Route } from "@heximon/contract";
import {
BrowseEventsQuerySchema,
EventCreatedResponse,
EventIdParam,
EventPageResponse,
EventResponse,
PublishEventBody,
} from "./catalog.dto";
// The shared, type-safe route table. The backend controller binds to it and the frontend client IS it
// (`new CatalogApi(transport)`), so request and response types cross the wire exactly once.
export class CatalogApi extends Contract({
prefix: "/catalog",
routes: {
browse: Route.get("/events").query(BrowseEventsQuerySchema).responses({ 200: EventPageResponse }),
publish: Route.post("/events").body(PublishEventBody).responses({ 201: EventCreatedResponse }),
get: Route.get("/events/:id").pathParams(EventIdParam).responses({ 200: EventResponse }),
},
}) {}
A frontend imports that same class, hands it a transport, and calls it — the argument and the returned body are inferred from the route schemas, with no request/response types written by hand:
import { ClientTransport } from "@heximon/client";
import { CatalogApi } from "../catalog/contract/catalog.api";
const api = new CatalogApi(ClientTransport.fetch({ baseUrl: "https://api.example.com" }));
// `page` is `EventPageResponse`'s output — `{ data: EventResponse[], metadata: { queryCount } }`.
const page = await api.client.browse({
query: { filter: { name: { like: "%Gala%" }, priceAmount: { lte: 20_000 } }, page: 1, size: 20 },
});
Everything below is the server that makes that call type-check and return real, paginated data.
Define the contract and DTOs
The contract's schemas are the boundary. @heximon/schema ships two framework builders that carry the
whole list-endpoint story — QuerySchema for the request, PaginatedResponseSchema for the response — over
any Standard Schema (zod here; valibot/arktype work identically):
import { PaginatedResponseSchema, QuerySchema, Schema, SchemaObject } from "@heximon/schema";
import { z } from "zod";
// The publish-event request body (price in minor units + an ISO-4217 currency).
export class PublishEventBody extends SchemaObject({
name: z.string().min(1),
capacity: z.number().int().min(1),
priceAmount: z.number().int().min(0),
currency: z.string().length(3),
}) {}
// The event the API returns — the aggregate projected to a flat wire shape.
export class EventResponse extends SchemaObject({
id: z.string(),
name: z.string(),
slug: z.string(),
capacity: z.number(),
priceAmount: z.number(),
currency: z.string(),
}) {}
// A URL query is always string-valued, so a numeric filter operand needs a coercing leaf: `z.coerce.number()`
// validates AND writes the coerced number back, so the repository reads a real `number`, not `"2000"`.
const numericOperand = z.coerce.number();
// The browse query DTO — validates the bracket-expanded query string against the RESPONSE field names, and
// bakes in validated, capped, defaulted page/size pagination (`maxPageSize: 100`).
export class BrowseEventsQuerySchema extends QuerySchema.create(
{ name: z.string(), capacity: numericOperand, priceAmount: numericOperand },
{ maxPageSize: 100 },
) {}
// A page of `EventResponse` read DTOs plus its total-count `metadata` — the `browse` route's success body.
export class EventPageResponse extends Schema(
PaginatedResponseSchema.create(EventResponse.schema),
) {}
// The 201 publish response (only the new event's id) and the `:id` path parameter.
export class EventCreatedResponse extends SchemaObject({ id: z.string() }) {}
export class EventIdParam extends SchemaObject({ id: z.string() }) {}
QuerySchema.create(shape, options) returns a class that is both the validated query type
({ filter?, order?, page, size }) and the StandardSchemaV1 value the browse route's .query()
references — page mode is the default, both fields always present, defaulted and capped, so no handler-side
fallback.
PaginatedResponseSchema.create(itemSchema) wraps a per-item schema in { data: item[], metadata: { queryCount } } — the total count alongside the current page, so a client renders "1,204 results" without a
second request. Both are covered in depth in Paginate a list.
Model the aggregate and its repository
The write side owns an aggregate root — the consistency boundary where invariants live. Event holds
its state as value objects (a Slug derived from the name, a Money price), validated on construction, so
a malformed event can never exist:
import type { Branded } from "@heximon/primitives";
import { Id } from "@heximon/runtime";
import { AggregateRoot } from "@heximon/domain";
import { Money, Slug } from "../shared/value-objects";
// A nominal brand: `EventId.generate()` mints a fresh v7 id, `EventId.from(raw)` adopts an inbound string.
export type EventId = Branded<string, "EventId">;
export const EventId = Id<EventId>();
// The scalars the publish command carries — no value objects, so the message stays transport-agnostic.
export interface PublishEventInput {
name: string;
capacity: number;
priceAmount: number;
currency: string;
}
// Domain props hold value objects, not raw scalars.
interface EventProps extends Record<string, unknown> {
name: string;
slug: Slug;
capacity: number;
price: Money;
}
export class Event extends AggregateRoot<EventProps, EventId> {
// Built through the factory, never the raw constructor — the slug is derived and the price validated here.
public static create(id: EventId, props: PublishEventInput): Event {
return new Event(id, {
name: props.name,
slug: Slug.of(props.name),
capacity: props.capacity,
price: Money.of(props.priceAmount, props.currency),
});
}
public get name(): string {
return this.props.name;
}
public get price(): Money {
return this.props.price;
}
// …`slug` and `capacity` getters — one line each, same shape.
}
Money and Slug are ordinary value objects — factories that enforce their invariant on construction;
Model a DDD aggregate builds that exact Money step by step.
A Drizzle table backs the aggregate. DrizzleSQLiteEntity.define binds the table to the entity constructor
so the mapper rehydrates rows into Event instances, and valueObject(...) flattens each value object
across its columns — slug into one, price into two (price_amount / price_currency):
import { DrizzleSQLiteEntity, dateObject, valueObject } from "@heximon/drizzle/sqlite-core";
import { defineRelations } from "drizzle-orm";
import { integer, text } from "drizzle-orm/sqlite-core";
import { Event, type EventId } from "../domain/event.entity";
import { Money, Slug } from "../shared/value-objects";
// The framework-owned identity columns (`id`, `created_at`, `updated_at`, `version`) are required by the
// entity base; `version` drives optimistic concurrency on `save`.
export const Events = DrizzleSQLiteEntity.define(Event, "events", {
id: text("id").notNull().primaryKey().$type<EventId>(),
createdAt: dateObject("created_at").notNull(),
updatedAt: dateObject("updated_at").notNull(),
version: integer("version").notNull().default(1),
name: text("name").notNull(),
slug: valueObject(Slug, { value: text("slug").notNull().unique() }),
capacity: integer("capacity").notNull(),
price: valueObject(Money, {
amount: integer("price_amount").notNull(),
currency: text("price_currency").notNull(),
}),
});
export const schema = { events: Events } as const;
export const relations = defineRelations(schema, () => ({}));
The repository is a port (an abstract class, so it is both a DI token and a nominal type) plus a
dialect concrete. The query and command handlers inject the port; the concrete extends the framework's
entity-repository base, inheriting the full CRUD surface — save (optimistic-concurrency), getById,
getPage, counting — with no method bodies to write:
import { Repository } from "@heximon/domain";
import type { Event } from "../domain/event.entity";
export abstract class EventRepository extends Repository<Event> {}
import { DrizzleLibSQLDatabase, DrizzleLibSQLEntityRepository } from "@heximon/drizzle/libsql";
import { AppDatabase } from "../../database/app-database";
import { Event } from "../domain/event.entity";
import { EventRepository } from "../ports/event.repository";
// Binds the `"events"` schema key + the entity constructor and `implements EventRepository` so the module
// binds it under the port; its one dependency is the app's `AppDatabase`. Swap for the MySQL twin, no
// handler changes.
export class SqliteEventRepository
extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, Event>
implements EventRepository
{
public constructor(database: AppDatabase) {
super(database, "events", Event);
}
}
AppDatabase is a named DrizzleLibSQLDatabase<Schema, Relations> subclass — the one concrete DI token
every repository injects. Its DrizzleLibSQLConfig and the DatabaseModule that provides it are wired
exactly as Build a CRUD API shows; point its schema at the
{ events } table above. Drizzle persistence goes deep on the mapper,
valueObject columns, and the three dialects.
Handle the reads
A query mapper is the read side's single source of truth. One declarative fields map drives BOTH
directions: toResponse projects an Event aggregate OUT to a flat DTO, and the inherited toQuery
translates a response-vocabulary filter/order back IN to an EntityQuery the repository accepts — so a
rename like priceAmount → price.amount stays in lockstep across reads and filters:
import { type FieldMap, QueryMapper } from "@heximon/domain";
import type { EventResponse } from "../contract/catalog.dto";
import type { Event } from "../domain/event.entity";
export class EventQueryMapper extends QueryMapper<EventResponse, Event> {
// The response-field → entity-path correspondence (the renamed `price` sub-fields are dotted paths).
protected readonly fields = {
name: "name",
capacity: "capacity",
priceAmount: "price.amount",
currency: "price.currency",
} satisfies FieldMap<EventResponse, Event>;
public toResponse(event: Event): EventResponse {
return {
id: event.id,
name: event.name,
slug: event.slug.toString(),
capacity: event.capacity,
priceAmount: event.price.amount,
currency: event.price.currency,
};
}
}
The browse handler is the whole read path in one method: map the validated query to an EntityQuery, page
the repository, and project each aggregate back to a DTO — so the query resolves to plain, cache-safe data,
never a domain aggregate:
import { Query, type QueryHandler } from "@heximon/cqrs";
import { BrowseEventsQuerySchema, type EventResponse } from "../contract/catalog.dto";
import { EventRepository } from "../ports/event.repository";
import { EventQueryMapper } from "./event.query-mapper";
export type BrowseEventsInput = InstanceType<typeof BrowseEventsQuerySchema>;
// A page of read DTOs plus its total-count metadata — the query's result type, carried by `Query<EventPage>`.
export interface EventPage {
readonly data: readonly EventResponse[];
readonly metadata: { readonly queryCount: number };
}
export class BrowseEventsQuery extends Query<EventPage> {
public constructor(public readonly input: BrowseEventsInput) {
super();
}
}
export class BrowseEventsQueryHandler implements QueryHandler<BrowseEventsQuery> {
public constructor(
private readonly events: EventRepository,
private readonly mapper: EventQueryMapper,
) {}
public async handle(query: BrowseEventsQuery): Promise<EventPage> {
// Response vocabulary → entity vocabulary; `page`/`size` pass straight through to `getPage`.
const entityQuery = this.mapper.toQuery(query.input);
const { rows, total } = await this.events.getPage(
{ page: query.input.page, size: query.input.size },
entityQuery,
);
return {
data: rows.map((event) => this.mapper.toResponse(event)),
metadata: { queryCount: total },
};
}
}
Reading one event by id is the same shape minus the pagination — a GetEventQuery / GetEventQueryHandler
pair (imported by the controller below) that calls events.getById(id) and reuses the mapper's
toResponse, so browse and get return the same EventResponse.
Handle the write
A command states intent and returns void — the client mints the id up front (so the write is idempotent on it) and reads the result back with a query. The handler builds the aggregate through its factory and saves it:
import { Command, type CommandHandler } from "@heximon/cqrs";
import { Event, type EventId, type PublishEventInput } from "../domain/event.entity";
import { EventRepository } from "../ports/event.repository";
// A command is `(id, data)`: the caller-minted id plus the create input — the same shape the entity factory
// consumes, so the handler passes it straight through.
export class PublishEventCommand extends Command {
public constructor(
public readonly id: EventId,
public readonly data: PublishEventInput,
) {
super();
}
}
// `{ database: false }` documents the single-write, non-transactional intent — one INSERT is already atomic,
// so the handler injects a repository but no `Database`, and the compiler's likely-non-transactional warning
// is suppressed. A command that writes AND publishes would instead own a `Database.runInTransaction`.
export class PublishEventCommandHandler implements CommandHandler<PublishEventCommand, { database: false }> {
public constructor(private readonly events: EventRepository) {}
public async handle(command: PublishEventCommand): Promise<void> {
const event = Event.create(command.id, command.data);
await this.events.save(event);
}
}
Bind the controller
In contract mode the controller declares implements Controller<CatalogApi> — each handler name matches a
route key and Action<CatalogApi, key> types the action off that route (a missing or misnamed handler is a
TypeScript error). It holds no domain logic: every handler translates HTTP into a CQRS message through the
injected buses, and the read handlers pass the already-projected EventResponse straight out:
import { NotFoundError } from "@heximon/runtime/errors";
import { CommandBus, QueryBus } from "@heximon/cqrs";
import type { Action, Controller } from "@heximon/http";
import { PublishEventCommand } from "../command/publish-event";
import { CatalogApi } from "../contract/catalog.api";
import { EventId } from "../domain/event.entity";
import { BrowseEventsQuery } from "../query/browse-events";
import { GetEventQuery } from "../query/get-event";
export class CatalogController implements Controller<CatalogApi> {
public constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
public async browse(action: Action<CatalogApi, "browse">) {
const query = await action.request.getValidatedQuery();
return this.queryBus.execute(new BrowseEventsQuery(query));
}
public async publish(action: Action<CatalogApi, "publish">) {
const body = await action.request.readValidatedBody();
const id = EventId.generate();
await this.commandBus.execute(new PublishEventCommand(id, body));
return action.respond(201, { id });
}
public async get(action: Action<CatalogApi, "get">) {
const { id } = await action.request.getValidatedPathParams();
const event = await this.queryBus.execute(new GetEventQuery(EventId.from(id)));
if (event === undefined) {
throw new NotFoundError(`Event ${id} not found`);
}
return event;
}
}
The CommandBus / QueryBus are core providers the CQRS plugin contributes — you inject them, you never
declare them. A thrown NotFoundError renders an RFC 9457 problem response with no try/catch here.
Wire the module
One module binds every piece. It binds the EventRepository port to its SQLite concrete (the dialect swap
lives on this one line), provides the read-side mapper, and lists the handlers under cqrs: and the
controller under http: — those namespaces are how the compiler dispatches them, so a handler or controller
never goes in providers:
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { PublishEventCommandHandler } from "./command/publish-event";
import { CatalogController } from "./http/catalog.controller";
import { SqliteEventRepository } from "./persistence/event.repository";
import { EventRepository } from "./ports/event.repository";
import { BrowseEventsQueryHandler } from "./query/browse-events";
import { EventQueryMapper } from "./query/event.query-mapper";
import { GetEventQueryHandler } from "./query/get-event";
export class CatalogModule extends Module({
imports: [DatabaseModule],
providers: [{ provide: EventRepository, useClass: SqliteEventRepository }, EventQueryMapper],
http: { controllers: [CatalogController] },
cqrs: {
commandHandlers: [PublishEventCommandHandler],
queryHandlers: [BrowseEventsQueryHandler, GetEventQueryHandler],
},
}) {}
Two compiler plugins back it — the HTTP plugin for the contract controller, the CQRS plugin for the handlers. The Drizzle repository needs none; it's an ordinary provider:
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()] });
required and each app binding the dialect
concrete + database across the boundary — see Publish a feature package.Call it from a typed client
The frontend imports the CatalogApi class, constructs it with a transport, and calls it. A .client
caller returns the route's 2xx body and throws a ContractError on any non-2xx — so a 404 from get
is an exception you narrow on, not a status you check by hand:
import { ClientTransport } from "@heximon/client";
import { ContractError } from "@heximon/contract";
import { CatalogApi } from "../catalog/contract/catalog.api";
const api = new CatalogApi(ClientTransport.fetch({ baseUrl: "https://api.example.com" }));
// Publish, then read back by the returned id — both calls typed off the SAME contract the server binds.
const { id } = await api.client.publish({
body: { name: "Spring Gala", capacity: 100, priceAmount: 5000, currency: "EUR" },
});
try {
const event = await api.client.get({ params: { id } });
console.log(event.name); // typed as `EventResponse`
} catch (error) {
if (error instanceof ContractError && error.status === 404) {
console.warn("event not found");
}
}
ClientTransport.fetch is the network driver; ClientTransport.internal (in-process, for SSR) and
ClientTransport.mock (canned responses, for tests) fill the same seam with no other change. Prefer
api.raw("get", { params: { id } }) over the try/catch when you want the full { status, body } union
without throwing — see the client for both forms and the vue-query bindings.
See also
- Build a CRUD API — the simpler starting point: a thin controller over a plain
repository, no contract or CQRS, and where the
AppDatabaseconnection wiring is built up step by step. - Paginate a list —
QuerySchema.createandPaginatedResponseSchemaon their own, including offset mode and the page-size ceiling. - Model a DDD aggregate — the aggregate root, value objects, invariants, and
domain events behind the
Eventon this page. - Repository pattern — filtering and paging an
EntityQuery, and theQueryMapperthat maps response-vocabulary field names to entity fields. - CQRS — commands, queries, the buses, and when splitting reads from writes earns its keep.
- Contracts — the full
Routebuilder; the client — every transport and the typed self-client end to end. - the flagship
catalogcontext — this exact chain as a runnable, dual-dialect (SQLite + MySQL) bounded-context package, with an end-to-end test that drivesbrowsethrough the renamed-field filter and multi-page pagination.