Heximon Logo
Recipes

Build a Production CRUD API

The full best-practice CRUD chain — a shared Contract + typed self-client, QuerySchema/PaginatedResponseSchema pagination, CQRS command/query handlers, and a Drizzle aggregate repository behind an EntityQuery mapper.

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:

src/catalog/contract/catalog.api.ts
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:

src/client/catalog.client.ts
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):

src/catalog/contract/catalog.dto.ts
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:

src/catalog/domain/event.entity.ts
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):

src/catalog/persistence/events.schema.ts
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:

src/catalog/ports/event.repository.ts
import { Repository } from "@heximon/domain";
import type { Event } from "../domain/event.entity";

export abstract class EventRepository extends Repository<Event> {}
src/catalog/persistence/event.repository.ts
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 priceAmountprice.amount stays in lockstep across reads and filters:

src/catalog/query/event.query-mapper.ts
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:

src/catalog/query/browse-events.ts
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:

src/catalog/command/publish-event.ts
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:

src/catalog/http/catalog.controller.ts
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:

src/catalog/catalog.module.ts
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:

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()] });
To ship this whole feature as a reusable package — the port 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:

src/client/catalog.client.ts
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 AppDatabase connection wiring is built up step by step.
  • Paginate a listQuerySchema.create and PaginatedResponseSchema on 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 Event on this page.
  • Repository pattern — filtering and paging an EntityQuery, and the QueryMapper that 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 Route builder; the client — every transport and the typed self-client end to end.
  • the flagship catalog context — this exact chain as a runnable, dual-dialect (SQLite + MySQL) bounded-context package, with an end-to-end test that drives browse through the renamed-field filter and multi-page pagination.
Copyright © 2026