Build a CRUD API
A Task resource with list, find, create, update, and delete — typed from the request body to the
database row, and copy-paste ready. You validate bodies with zod, persist through a plain repository over
Drizzle, and prove the round-trip with a test — no decorators, the compiler reads each class from the
interface it implements and each dependency from a constructor parameter. It's the runnable
database-zod starter (heximon create --template database --validator zod).
Wire the database
The table is stock Drizzle, and one config object is read by two consumers — the Heximon runtime, which
builds the database from it, and the stock drizzle-kit CLI, which generates migrations against the same
instance. Database builds this shape up one step at a time; here it is
end to end:
import { defineRelations } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// The `done` flag is stored as a SQLite integer (0/1) — SQLite has no native boolean; the repository
// maps it to/from JS booleans.
export const tasks = sqliteTable("tasks", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
done: integer("done").notNull().default(0),
});
// One canonical schema map both consumers read: the runtime ORM and the stock drizzle-kit CLI.
export const schema = { tasks };
// No relations yet (a single standalone table) — declared so the relational-query surface is well-formed.
export const relations = defineRelations(schema);
export type TaskRow = typeof tasks.$inferSelect;
import { DrizzleLibSQLConfig } from "@heximon/drizzle/libsql/config";
import { relations, schema } from "./schema";
// `url: ":memory:"` is an in-process SQLite database (fresh on every boot). Swap it for `file:./app.db`
// to persist between runs, or a `libsql://…` Turso URL (plus an authToken) for a hosted database.
export const databaseConfig = new DrizzleLibSQLConfig(schema, relations, {
dialect: "sqlite",
schema: "./src/database/schema.ts",
out: "./migrations",
url: ":memory:",
});
export type DatabaseSchema = typeof schema;
export type DatabaseRelations = typeof relations;
Dependency injection resolves by class identity, and generic type arguments are erased once resolution
happens — so the generic DrizzleLibSQLDatabase<Schema, Relations> can't be a token: every
parameterization would collapse onto the same class. A named, otherwise-empty subclass fixes the schema and
relations types once, giving you one concrete class every consumer can inject:
import { DrizzleLibSQLDatabase } from "@heximon/drizzle/libsql";
import type { DatabaseRelations, DatabaseSchema } from "./database.config";
// The app's concrete database — a NAMED subclass typed against this app's schema, and the DI token every
// consumer injects (class identity is the only token; the generic base's type arguments are erased). The
// body is empty: it inherits the `(config, context)` constructor unchanged.
export class AppDatabase extends DrizzleLibSQLDatabase<DatabaseSchema, DatabaseRelations> {}
import { type Context, Module } from "@heximon/runtime";
import { AppDatabase } from "./app-database";
import { databaseConfig } from "./database.config";
// Owns the single AppDatabase connection and exports it so any importing module can inject it by class
// identity. There is no Drizzle compiler plugin — the database is an ordinary `useFactory` provider whose
// one parameter (`Context`) resolves by its type, like any constructor dependency. The config is a
// closed-over value, never a DI token.
export class DatabaseModule extends Module({
providers: [
{
provide: AppDatabase,
useFactory: (context: Context) => new AppDatabase(databaseConfig, context),
},
],
exports: [AppDatabase],
}) {}
Author the request shapes with zod
A route's body can be any Standard Schema — zod, valibot, or a hand-rolled
validator all work unchanged. Wrapping the fields in @heximon/schema's SchemaObject gives you a class
that is both the runtime validator and the static type, so a route references it bare (no typeof):
import { SchemaObject } from "@heximon/schema";
import { z } from "zod";
// A `SchemaObject` class is both a value (the validator wired into the route) and a type (the handler's
// parsed body), so it drops straight into a `{ body: … }` slot. A declared body that fails validation is
// rejected with an RFC 9457 `400` before the handler runs.
export class CreateTask extends SchemaObject({
title: z.string().trim().min(1),
}) {}
export class SetDone extends SchemaObject({
done: z.boolean(),
}) {}
zod is the docs default; the fields can be any validator. See Validation for
SchemaObject, partial/pick/omit/extend, and bringing your own validator.
Write the repository
A repository is a plain class that injects AppDatabase by constructor parameter, exactly like injecting
any other provider. It runs typed queries through getOrm(), and — because the in-memory database starts
empty on every boot — creates its own table in onInit:
import { type OnInit } from "@heximon/runtime";
import { eq, sql } from "drizzle-orm";
import { AppDatabase } from "../database/app-database";
import { tasks, type TaskRow } from "../database/schema";
/** A task as the rest of the app sees it — `done` is a real boolean (the row stores it as 0/1). */
export interface Task {
readonly id: number;
readonly title: string;
readonly done: boolean;
}
// The data-access service: depends on the single shared AppDatabase (resolved by class identity) and runs
// all persistence through the typed Drizzle ORM. The in-memory database starts empty on every boot, so the
// table is created in onInit — a file-backed or remote database would use a migration instead.
export class TasksRepository implements OnInit {
public constructor(private readonly database: AppDatabase) {}
public async onInit(): Promise<void> {
await this.database.getOrm().run(sql`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)
`);
}
public async list(): Promise<Task[]> {
const rows = await this.database.getOrm().select().from(tasks).orderBy(tasks.id);
return rows.map((row) => TasksRepository.toTask(row));
}
public async findById(id: number): Promise<Task | undefined> {
const rows = await this.database.getOrm().select().from(tasks).where(eq(tasks.id, id)).limit(1);
const row = rows[0];
return row === undefined ? undefined : TasksRepository.toTask(row);
}
public async create(title: string): Promise<Task> {
const [row] = await this.database.getOrm().insert(tasks).values({ title, done: 0 }).returning();
if (row === undefined) {
throw new Error("Insert returned no row.");
}
return TasksRepository.toTask(row);
}
public async setDone(id: number, done: boolean): Promise<Task | undefined> {
const [row] = await this.database
.getOrm()
.update(tasks)
.set({ done: done ? 1 : 0 })
.where(eq(tasks.id, id))
.returning();
return row === undefined ? undefined : TasksRepository.toTask(row);
}
public async remove(id: number): Promise<boolean> {
const deleted = await this.database.getOrm().delete(tasks).where(eq(tasks.id, id)).returning();
return deleted.length > 0;
}
private static toTask(row: TaskRow): Task {
return { id: row.id, title: row.title, done: row.done === 1 };
}
}
onInit runs once, in dependency order, the first time something resolves TasksRepository — in
practice, the first request that hits /tasks — and completes before any repository method runs. A
file-backed or hosted database wouldn't boot-create a table this way; see
Migrations once you're off :memory:.
Bind the controller
Routes are declared by each handler's action parameter type — no route config, no decorators.
Declared bodies are validated before the handler runs; readValidatedBody() returns the parsed, typed
result, so a handler only ever sees a well-formed body.
import type { Controller, Delete, Get, Patch, Post } from "@heximon/http";
import { CreateTask, SetDone } from "./task.schema";
import { type Task, TasksRepository } from "./tasks.repository";
interface ErrorBody {
readonly error: string;
}
// Routes are declared by each handler's action parameter type — the compiler reads `Get<"/">`,
// `Post<"/", { body }>`, … off the signatures and builds the route table. Declared bodies are validated
// before the handler runs; `readValidatedBody()` returns the parsed, typed result.
export class TasksController implements Controller<"/tasks"> {
public constructor(private readonly tasks: TasksRepository) {}
public async list(_action: Get<"/">): Promise<Task[]> {
return this.tasks.list();
}
public async get(action: Get<"/:id">): Promise<Task | ErrorBody> {
const id = Number(action.request.pathParams.id);
const task = await this.tasks.findById(id);
if (task === undefined) {
action.response.status = 404;
return { error: `No task with id ${id}.` };
}
return task;
}
public async create(action: Post<"/", { body: CreateTask }>): Promise<Task> {
const body = await action.request.readValidatedBody();
action.response.status = 201;
return this.tasks.create(body.title);
}
public async setDone(action: Patch<"/:id", { body: SetDone }>): Promise<Task | ErrorBody> {
const id = Number(action.request.pathParams.id);
const body = await action.request.readValidatedBody();
const updated = await this.tasks.setDone(id, body.done);
if (updated === undefined) {
action.response.status = 404;
return { error: `No task with id ${id}.` };
}
return updated;
}
public async remove(action: Delete<"/:id">): Promise<{ deleted: number } | ErrorBody> {
const id = Number(action.request.pathParams.id);
const removed = await this.tasks.remove(id);
if (!removed) {
action.response.status = 404;
return { error: `No task with id ${id}.` };
}
return { deleted: id };
}
}
The controller stays thin on purpose: read the validated input, call the repository, shape the response.
Wire the module
The feature module imports DatabaseModule so the repository can inject AppDatabase, lists the
repository as a plain provider, and declares the controller under the http namespace. That config is the
only place the compiler looks — a typo'd sub-key like controllerz is a native TypeScript excess-property
error, not a silent miss.
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { TasksController } from "./tasks.controller";
import { TasksRepository } from "./tasks.repository";
export class TasksModule extends Module({
imports: [DatabaseModule],
providers: [TasksRepository],
http: { controllers: [TasksController] },
exports: [TasksRepository],
}) {}
The only compiler plugin this app registers is the HTTP one — the database needs none:
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";
// The capability manifest: one compiler plugin per capability. The entire HTTP layer is the one plugin
// below; everything you add later (events, CQRS, queues, …) is one more entry in the same list.
export default defineHeximonConfig({ plugins: [new HttpPlugin()] });
Test the round-trip
Compile and boot the generated server in-process with createTestApp, then drive the endpoints through
createTestClient — the exact pipeline pnpm dev runs, with no network and a fresh isolated app per call.
import { createTestClient } from "@heximon/http/testing";
import { createTestApp, type TestApp } from "@heximon/testing";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, expect, test } from "vitest";
// An in-process end-to-end test: createTestApp compiles src/ (the exact pipeline `pnpm dev` runs) and
// boots a fresh isolated app; createTestClient drives real requests through the generated routes.
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
let app: TestApp;
beforeAll(async () => {
app = await createTestApp({ root });
});
afterAll(() => app[Symbol.asyncDispose]());
test("creates a task and reads it back", async () => {
const client = createTestClient(app);
const created = await client.request(
new Request("http://localhost/tasks", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Write docs" }),
}),
);
expect(created.status).toBe(201);
const task = (await created.json()) as { id: number; title: string; done: boolean };
expect(task.title).toBe("Write docs");
expect(task.done).toBe(false);
const list = await client.request(new Request("http://localhost/tasks"));
expect(await list.json()).toEqual([task]);
});
test("rejects a create without a title", async () => {
const client = createTestClient(app);
const response = await client.request(
new Request("http://localhost/tasks", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
}),
);
expect(response.status).toBe(400);
});
the database-zod starter's
test/tasks.test.ts runs this exact flow, plus the missing-title 400 case above.
Pitfalls
Putting a CRUD handler in providers. Concept classes are discovered under their plugin's namespace: a
controller goes under http: { controllers }, while only plain injectables — the repository — belong in
providers. List a controller in providers and it's never wired as a route.
Booting CREATE TABLE IF NOT EXISTS against a persistent database. That's fine for the :memory:
database this recipe uses, which starts empty every boot. Once you swap in a file-backed or hosted URL, a
second boot runs against a database that already has the table — and a real schema change needs a real
migration, not an onInit side effect. See Migrations.
Forgetting readValidatedBody(). Reading action.request.json() directly skips the route's declared
body schema — an invalid payload reaches your handler instead of failing with a 400 before it runs.
Why this works
Every piece you wrote is a plain class, and the compiler reads three things from your source: the interface
each class implements (so TasksController is a controller), the constructor parameter types (so the
controller gets a TasksRepository and the repository an AppDatabase), and the module config. From that
it generates plain JavaScript that constructs everything directly — roughly
new TasksController(new TasksRepository(database)), no runtime container, no reflection. Startup is instant.
The full feature is a runnable starter:
database-zod
(heximon create --template database --validator zod).
Domain rules accumulating — an invariant that has to hold no matter who calls in, or a value object like a validated email? The DDD variant of this recipe is Model a DDD aggregate.
When this feature grows up — a shared contract with a typed client, CQRS command/query handlers, an aggregate repository over Drizzle, and validated pagination — Build a production CRUD API walks the full best-practice chain over the same kind of resource.
See also
- Controllers — both controller modes (inline, and contract mode — where the contract
binds via
Controller's type argument), middleware, responses, and how routes are declared by parameter type, in depth. - Database — this same schema → config → named database →
useFactoryshape, built up one step at a time. - Validation —
SchemaObject/SchemaArray, thepartial/pick/omit/extendderivations, and bringing your own validator. - Drizzle Persistence — repositories, value-object columns, transactions, and the three dialects.
- Model a DDD aggregate — when domain rules earn an aggregate root, value objects, and optimistic-concurrency saves instead of a plain repository.
- Build a production CRUD API — the same resource as a shared contract
- typed client, CQRS handlers, a Drizzle aggregate repository, and
QuerySchema/PaginatedResponseSchemapagination.
- typed client, CQRS handlers, a Drizzle aggregate repository, and
- the
database-zodstarter — this exact feature as a runnable app (heximon create --template database --validator zod), plus a test that drives the endpoints and proves the validation400.
Health Probes and a Nightly Job
Expose liveness and readiness over an app-owned controller with an injected HealthRegistry, and run a nightly report on a build-time-validated cron with ScheduledHandler.
Add Authentication
Protect routes end-to-end with IdentityKeyPair, JWTFactory, JWTVerifier, PasswordHashingAlgorithm, AuthContext, JWTAuthMiddleware, and scope guards — register, login, and a scope-guarded endpoint.