Drizzle ORM
Storing plain rows with a hand-written repository? Start at Database —
this page is the deep end. Persist a modeled domain to a real database without writing a row mapper.
Declare which columns a User maps to, extend a repository base, and you get save / getById /
getAll / delete — value objects packed into columns and back, optimistic concurrency guarding every
update.
It runs on Drizzle ORM, one repository contract across seven dialects — four SQLite drivers (libsql, better-sqlite3, Cloudflare D1, and Durable Object SQLite) plus node-postgres/PostgreSQL, mysql2/MySQL, and mysql2 over Cloudflare Hyperdrive (a real-atomicity MySQL write path on Cloudflare Workers).
import { DrizzleLibSQLDatabase, DrizzleLibSQLEntityRepository } from "@heximon/drizzle/libsql";
import { User } from "./user.entity";
export class UserRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, User> {
public constructor(database: DrizzleLibSQLDatabase) {
super(database, "Users", User); // the database is the only injected dependency
}
}
A Database is an ordinary useFactory provider and a repository is found by the base it extends — no
compiler plugin, no decorator.
Install the dialect
Add the package, Drizzle, and the driver for your dialect. The drivers are optional peers, so you pull in only the one you need.
pnpm add @heximon/drizzle drizzle-orm
pnpm add @libsql/client # libsql / SQLite (Node, Turso)
pnpm add better-sqlite3 # synchronous native Node SQLite
pnpm add @cloudflare/workers-types # Cloudflare D1 + Durable Object SQLite (types only)
pnpm add pg # PostgreSQL
pnpm add mysql2 # MySQL (Node) + Cloudflare Hyperdrive
pnpm add -D drizzle-kit # typed config + migration generation
npm install @heximon/drizzle drizzle-orm
npm install @libsql/client # libsql / SQLite (Node, Turso)
npm install better-sqlite3 # synchronous native Node SQLite
npm install @cloudflare/workers-types # Cloudflare D1 + Durable Object SQLite (types only)
npm install pg # PostgreSQL
npm install mysql2 # MySQL (Node) + Cloudflare Hyperdrive
npm install -D drizzle-kit # typed config + migration generation
bun add @heximon/drizzle drizzle-orm
bun add @libsql/client # libsql / SQLite (Node, Turso)
bun add better-sqlite3 # synchronous native Node SQLite
bun add @cloudflare/workers-types # Cloudflare D1 + Durable Object SQLite (types only)
bun add pg # PostgreSQL
bun add mysql2 # MySQL (Node) + Cloudflare Hyperdrive
bun add -D drizzle-kit # typed config + migration generation
Choose a SQLite driver
Four drivers share the same sqlite-core schema DSL, repository base, and codecs — they differ only in how
the Database connects and whether it has real transactions:
| Subpath | Database class | Connection | Transactions |
|---|---|---|---|
@heximon/drizzle/libsql | DrizzleLibSQLDatabase | url (file: / :memory: / Turso) | real SAVEPOINT |
@heximon/drizzle/better-sqlite3 | DrizzleBetterSQLite3Database | filename (path / :memory:), synchronous | real SAVEPOINT |
@heximon/drizzle/d1 | DrizzleD1Database | Cloudflare D1 binding | non-atomic |
@heximon/drizzle/durable-sqlite | DrizzleDurableSQLiteDatabase | Durable Object ctx.storage | non-atomic |
libsql and better-sqlite3 are URL/file connections — wire them exactly like the rest of this guide, just
with their own config (the better-sqlite3 connection field is filename):
import { DrizzleBetterSQLite3Config } from "@heximon/drizzle/better-sqlite3/config";
import { relations, schema } from "./schema";
export const databaseConfig = new DrizzleBetterSQLite3Config(schema, relations, {
dialect: "sqlite",
schema: "./src/database/schema.ts",
out: "./migrations",
filename: ":memory:",
});
libsql / better-sqlite3 database is single-writer (database.isSingleWriter()) and serializes its
top-level transactions automatically — so the durable tiers' background poll loops (the @heximon/saga
timers, the @heximon/queue consumer, the transactional outbox relay) coexist with foreground request writes
with no app config and no "stop the poller in tests" workaround. better-sqlite3 also opens the file in WAL
mode by default (readers run concurrently with the writer), and both drivers accept a busyTimeoutMs for
cross-process lock contention. For multi-process write concurrency, reach for Turso (remote libsql),
Postgres, or MySQL — there the database server arbitrates and the in-process serialization is a no-op.The two Cloudflare drivers connect by a platform binding, not a URL. D1 carries the binding name in
its config; the database resolves it lazily per request via Platform.binding, so a singleton built at boot
still sees the per-request env:
import { DrizzleD1Config } from "@heximon/drizzle/d1/config";
import { relations, schema } from "./schema";
export const databaseConfig = new DrizzleD1Config(schema, relations, {
dialect: "sqlite",
schema: "./src/database/schema.ts",
out: "./migrations",
binding: "DB", // the D1 binding name, resolved at runtime via Platform.binding("DB")
});
Durable Object SQLite is built from the DO's own ctx.storage, injected as DurableContext. The cast to
the Cloudflare DurableObjectStorage lives in a small helper, not inline in the useFactory — the compiler
emits a factory body verbatim into the generated JS wiring, so the body must hold no TypeScript-only
syntax (a type assertion would become invalid JavaScript there):
import { type Context, Module } from "@heximon/runtime";
import type { DurableContext } from "@heximon/durable";
import type { DurableObjectStorage } from "@cloudflare/workers-types";
import { AppDatabase } from "./app-database";
import { relations, schema } from "./database.config";
// Defined outside the Module() config, so it is compiled as ordinary source (not inlined verbatim):
function resolveStorage(durable: DurableContext): DurableObjectStorage {
return durable.storage as unknown as DurableObjectStorage;
}
export class DatabaseModule extends Module({
providers: [
{
provide: AppDatabase,
useFactory: (durable: DurableContext, context: Context) =>
new AppDatabase(resolveStorage(durable), context, schema, relations),
},
],
exports: [AppDatabase],
}) {}
.transaction(), and the DO storage API forbids BEGIN/SAVEPOINT (its only transactionSync is
synchronous and cannot wrap async work). So on these two drivers runInTransaction is best-effort and
non-atomic — every statement runs immediately on the bare connection, commit/rollback are no-ops, and a
mid-flight failure leaves already-issued writes in place (the in-memory onRollback hooks still fire). Reach
for libsql or better-sqlite3 when you need real atomicity in SQLite.The flagship Cloudflare app
runs its whole domain on the D1 dialect and hosts its real-time layer on a Durable Object; the
durable-sqlite dialect itself is exercised by @heximon/drizzle's own tests
(durable-sqlite-database.test.ts) rather than a standalone example.
The libsql driver is the ladder's
L04 — Database; the flagship
monolith runs its whole domain on the better-sqlite3 dialect.
Real MySQL on Cloudflare — Hyperdrive
@heximon/drizzle/hyperdrive runs mysql2 over Cloudflare Hyperdrive
on Cloudflare Workers — a real-atomicity MySQL write path on the edge, unlike D1 and Durable Object SQLite,
which are non-atomic.
DrizzleHyperdriveDatabase is a thin subclass of the Node DrizzleMySQL2Database: it
inherits the transaction/savepoint engine, the pool-route override, and connection retry unchanged, so real
interactive BEGIN / COMMIT / ROLLBACK and SAVEPOINT nesting work.
Three deltas make it edge-safe: a
small mysql2 pool is opened per request (stored on the request Context and torn down at request end via
waitUntil, so one request can run queries in parallel); every statement uses the text protocol (Hyperdrive
rejects binary prepared statements); and disableEval: true (workerd blocks mysql2's eval-based row parser).
import { Platform } from "@heximon/runtime";
import { DrizzleHyperdriveConfig } from "@heximon/drizzle/hyperdrive/config";
import { relations, schema } from "./schema";
export const databaseConfig = new DrizzleHyperdriveConfig(schema, relations, {
dialect: "mysql",
schema: "./src/database/schema.ts",
out: "./migrations",
binding: "HYPERDRIVE", // the CF Hyperdrive binding, resolved at runtime via Platform.binding("HYPERDRIVE")
localConnectionString: Platform.get("DATABASE_URL"), // dev / CI / migrations — used in preference to the binding
connectionLimit: 5, // per-request pool size (default 5), bounding per-request query parallelism
});
binding names the Cloudflare Hyperdrive binding, resolved lazily via Platform.binding("HYPERDRIVE") at
request time. localConnectionString is the direct database URL used in preference to the binding when set — on
Node (CI, migrations, an in-process test) the dialect connects through it directly, since Node has no Hyperdrive
binding to fall back to.
vp dev runs a CloudflareWorkersStrategy app in real workerd, not Node, so this
Platform.get("DATABASE_URL") read evaluates to undefined there and the dialect falls back to the binding.
Dev reaches the database instead through heximon.config.ts's cloudflare.hyperdrive[].localConnectionString
(a separate field, on the Cloudflare config, not the dialect config), which makes vp dev provision the
HYPERDRIVE binding against that database directly.
DrizzleHyperdriveMigrationRunner is Node/CI-only and runs
migrations direct off the dialect's localConnectionString (Hyperdrive is a pooler, so DDL goes direct); it
never runs at edge boot, where Workers have no filesystem.
The schema, value objects, repository, and codecs are the MySQL ones (DrizzleMySQLEntity.define,
@heximon/drizzle/mysql-core) — Hyperdrive carries only the connection difference. A repository extends the
re-typed DrizzleHyperdriveEntityRepository:
import { DrizzleHyperdriveDatabase, DrizzleHyperdriveEntityRepository } from "@heximon/drizzle/hyperdrive";
import { Task } from "./task.entity";
export class TaskRepository extends DrizzleHyperdriveEntityRepository<DrizzleHyperdriveDatabase, Task> {
public constructor(database: DrizzleHyperdriveDatabase) {
super(database, "Tasks", Task);
}
}
heximon.config.ts declares the binding and its Hyperdrive configuration id so CloudflarePlugin emits the
hyperdrive stanza into wrangler.json; the Worker also needs compatibility_flags: ["nodejs_compat"]. The
runnable example is the flagship cloudflare-mysql app,
which runs its whole domain on the Hyperdrive/MySQL dialect.
Start from a domain entity
A schema needs an entity first. A User extends AggregateRoot, which gives it framework-owned identity
and metadata — id, createdAt, updatedAt, the version counter — plus the dirty tracking the
repository reads at save.
import type { Branded } from "@heximon/primitives";
import { AggregateRoot } from "@heximon/domain";
import { Email } from "./email.value";
export type UserId = Branded<string, "UserId">;
interface UserProps extends Record<string, unknown> {
name: string;
email: Email; // a value object at the domain edge, not a raw string
}
export class User extends AggregateRoot<UserProps, UserId> {
public static create(id: UserId, props: { name: string; email: string }): User {
return new User(id, { name: props.name, email: Email.create(props.email) });
}
public get name(): string {
return this.props.name;
}
public get email(): Email {
return this.props.email;
}
public rename(name: string): void {
this.props.name = name; // mutating props is what flips the entity dirty
}
}
Mutating this.props flips the entity dirty; the next save diffs live props against the load-time
snapshot, marks a real change for UPDATE, and you never call markModified(). The
Domain-Driven Design page covers Entity, AggregateRoot, and ValueObject in full.
Define the table
Drizzle<Dialect>Entity.define(EntityClass, "tableName", columns) bridges a domain class to a real Drizzle
table, with no codegen and no hand-written mapper. Pick the static for your dialect:
import { dateObject, DrizzleSQLiteEntity, valueObject } from "@heximon/drizzle/sqlite-core";
import { integer, text } from "drizzle-orm/sqlite-core";
import { Email } from "./email.value";
import { User, type UserId } from "./user.entity";
export const Users = DrizzleSQLiteEntity.define(User, "users", {
id: text("id").notNull().primaryKey().$type<UserId>(),
createdAt: dateObject("created_at").notNull(),
updatedAt: dateObject("updated_at").notNull(),
version: integer("version").notNull().default(1),
name: text("name").notNull(),
email: valueObject(Email, { address: text("email_address").notNull().unique() }),
});
import { dateObject, DrizzlePgEntity, uuid, valueObject } from "@heximon/drizzle/pg-core";
import { integer, text } from "drizzle-orm/pg-core";
import { Email } from "./email.value";
import { User, type UserId } from "./user.entity";
export const Users = DrizzlePgEntity.define(User, "users", {
id: uuid("id").primaryKey().$type<UserId>(),
createdAt: dateObject("created_at").notNull(),
updatedAt: dateObject("updated_at").notNull(),
version: integer("version").notNull().default(1),
name: text("name").notNull(),
email: valueObject(Email, { address: text("email_address").notNull().unique() }),
});
import { binaryUuid, dateObject, DrizzleMySQLEntity, valueObject } from "@heximon/drizzle/mysql-core";
import { int, varchar } from "drizzle-orm/mysql-core";
import { Email } from "./email.value";
import { User, type UserId } from "./user.entity";
export const Users = DrizzleMySQLEntity.define(User, "users", {
id: binaryUuid("id").primaryKey().$type<UserId>(),
createdAt: dateObject("created_at").notNull(),
updatedAt: dateObject("updated_at").notNull(),
version: int("version").notNull().default(1),
name: varchar("name", { length: 255 }).notNull(),
email: valueObject(Email, { address: varchar("email_address", { length: 255 }).notNull().unique() }),
});
Three columns carry weight. define requires version at the type level — it's the optimistic-concurrency
counter, and without it concurrency control would be silently off.
.$type<UserId>() types a row's id as the branded UserId, not a bare string. And dateObject is a
Heximon codec (one per dialect) that round-trips a JS Date to its native column type; the plain helpers
(text, integer, varchar) come straight from drizzle-orm/<dialect>-core.
Pack a value object into columns
A value object isn't a scalar, so the schema has to say how it lands in the table. Given an Email:
import { ValueObject } from "@heximon/domain";
interface EmailValue extends Record<string, unknown> {
readonly address: string;
}
export class Email extends ValueObject<EmailValue> {
public static create(address: string): Email {
return new Email({ address: address.trim().toLowerCase() });
}
public get address(): string {
return this.value.address;
}
}
The choice between two strategies comes down to whether you ever query on the value's fields.
Flattened — valueObject(). Each field spreads across its own (prefixed) column, so you can filter,
index, and constrain on it — reach for this when the inner fields matter to queries.
import { valueObject } from "@heximon/drizzle/sqlite-core";
import { text } from "drizzle-orm/sqlite-core";
import { Address } from "./address.value";
const addressColumns = valueObject(Address, {
street: text("street").notNull(),
city: text("city").notNull(),
zip: text("zip").notNull(),
});
Serialized — serializedValueObject(). The whole value object goes into one column — simpler, but you
can't filter on individual fields. Pass customTypes codecs to round-trip non-primitive fields like a Date.
import { serializedValueObject } from "@heximon/drizzle/sqlite-core";
import { text } from "drizzle-orm/sqlite-core";
import { Metadata } from "./metadata.value";
const metadataColumn = serializedValueObject(Metadata, text("metadata"));
Define a repository
A repository extends Drizzle<Dialect>EntityRepository<TDatabase, TEntity> (a subclass of the DDD
Repository contract). The base ships the whole CRUD surface — getById, getAll(query?), getMany(ids),
count(query?), save, delete, transaction — so a concrete repository is often just a constructor:
import { DrizzleLibSQLDatabase, DrizzleLibSQLEntityRepository } from "@heximon/drizzle/libsql";
import { User } from "./user.entity";
export class UserRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, User> {
public constructor(database: DrizzleLibSQLDatabase) {
super(database, "Users", User);
}
}
import { DrizzlePgDatabase, DrizzlePgEntityRepository } from "@heximon/drizzle/pg";
import { User } from "./user.entity";
export class UserRepository extends DrizzlePgEntityRepository<DrizzlePgDatabase, User> {
public constructor(database: DrizzlePgDatabase) {
super(database, "Users", User);
}
}
import { DrizzleMySQL2Database, DrizzleMySQL2EntityRepository } from "@heximon/drizzle/mysql2";
import { User } from "./user.entity";
export class UserRepository extends DrizzleMySQL2EntityRepository<DrizzleMySQL2Database, User> {
public constructor(database: DrizzleMySQL2Database) {
super(database, "Users", User);
}
}
The database is the only DI-resolved constructor parameter — the compiler reads its type as the
dependency, since class identity is the only token. The schema key and entity constructor are plain
super(...) arguments.
"Users" is the schema key, not the SQL table name. The base resolves the Drizzle table by this key,
and that key is the export identifier of export const Users = … — not the "users" you passed to
define. Get it wrong and the repository fails to construct with Table "Wrong" not found in the database schema. Use the export name; the SQL table name lives only inside define.Register the repository in the feature module's providers:
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { UserController } from "./user.controller";
import { UserRepository } from "./user.repository";
export class UserModule extends Module({
imports: [DatabaseModule], // brings the exported Database token into scope
providers: [UserRepository],
http: { controllers: [UserController] },
exports: [UserRepository],
}) {}
Save and load from a controller
A controller injects the repository and works in domain terms — create the aggregate, save it, read it
back with getById. The mapper handles row translation in both directions.
import { uuid } from "@heximon/runtime";
import type { Controller, Get, Post, TypedResponse } from "@heximon/http";
import { createUserSchema, type UserResponse } from "./user.schema";
import { User, type UserId } from "./user.entity";
import { UserRepository } from "./user.repository";
export class UserController implements Controller<"/users"> {
public constructor(private readonly users: UserRepository) {}
public async list(_action: Get<"/">): Promise<UserResponse[]> {
const users = await this.users.getAll();
return users.map((user) => UserController.toResponse(user));
}
public async create(action: Post<"/", { body: typeof createUserSchema }>): Promise<TypedResponse> {
const body = await action.request.readValidatedBody(); // invalid → 400 problem+json
const user = User.create(uuid.v7<UserId>(), body); // mint the id up front
await this.users.save(user);
return action.respond(201, UserController.toResponse(user));
}
// `version` is protected on the entity — read it through the public getter when projecting a response.
private static toResponse(user: User): UserResponse {
return { id: user.id, name: user.name, email: user.email.address, version: user.getVersion() };
}
}
The body is validated against any Standard Schema before your handler runs,
and Email re-validates the address when the aggregate is built — a malformed address can't reach the row.
How save decides INSERT vs. UPDATE
You never tell save to insert or update — it reads a DirtyState the entity carries:
| State | How it got there | What save does |
|---|---|---|
Detached | Just created (User.create(...)) | INSERT |
Persistent | Loaded from the database, untouched | Nothing — no change to write |
Transient | A loaded entity whose props were mutated | Version-guarded partial UPDATE |
save snapshot-diffs at save time, and loading a row sets it Persistent (restoring its version) —
all automatically. Multi-row inserts are chunked to stay inside each dialect's bound-parameter limit.
Guard concurrent writes
The version column stops two requests overwriting each other. The base emits its UPDATE guarded by
WHERE version = ?: if another worker bumped the row's version since you loaded it, zero rows match, save
throws ConcurrencyError, and the in-memory entity's version and dirty-state roll back.
import { ConcurrencyError } from "@heximon/runtime/errors";
You rarely catch it yourself. Pair it with CQRS automatic retry and the losing command re-runs against fresh state, so the handler never sees the conflict.
Compose relations across features
Each feature owns its own tables; at the app level you stitch them together so cross-feature relations
resolve against the merged schema. First aggregate the tables into one namespace — the map both the runtime
ORM and drizzle-kit read:
export * from "../users/user.schema";
export * from "../orders/order.schema";
Then declare the relations. Even with no foreign keys the config is required, because getById /
getAll only resolve tables that appear in it. For a flat schema, defineRelations(schema) registers every
table with no relations:
import { defineRelations } from "drizzle-orm";
import * as schema from "./schema";
export const relations = defineRelations(schema);
As foreign keys appear, write per-feature relation builders and merge them. Because schema already holds
every table, an Order → User relation resolves without the domain modules importing each other:
import { DrizzleRelations } from "@heximon/drizzle/core";
import * as schema from "./schema";
const usersRelations = (r) => ({
Users: { orders: r.many.Orders({ from: r.Users.id, to: r.Orders.userId }) },
});
const ordersRelations = (r) => ({
Orders: { user: r.one.Users({ from: r.Orders.userId, to: r.Users.id }) },
});
export const relations = DrizzleRelations.mergeRelations(schema, [usersRelations, ordersRelations]);
To hydrate related rows on every query instead of writing manual with(...) clauses, pass an eager graph as
the final super(...) argument:
export class OrderRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, Order> {
public constructor(database: DrizzleLibSQLDatabase) {
super(database, "Orders", Order, {
user: true, // include the order's user
items: { with: { product: true } }, // include each item and its product
});
}
}
Run multiple writes in one transaction
When two writes must both succeed or both roll back, wrap them in runInTransaction(handler):
await userRepository.runInTransaction(async () => {
await userRepository.save(user); // both writes join the same active transaction
await orderRepository.save(order);
});
Propagation is ambient — save and delete find the active transaction on the request Context, so
neither takes a handle. Transactions nest: a transactional service that calls another opens a savepoint
in the outer transaction instead of deadlocking, committing or rolling back to that point on its own.
Context. The framework opens one per request, but a
test, script, or CLI must do it explicitly — without a context the call throws No active context — open one with Context.run(). Wrap the call:await context.run(async () => {
await userRepository.save(user);
});
Transaction lifecycle hooks
The Drizzle database exposes three hooks you register on the currently active transaction. All three are scoped per transaction handle and are no-ops when no transaction is active.
// onBeforeCommit — run INSIDE the transaction just before the outermost commit.
// A throw aborts the commit and rolls the whole transaction back.
database.onBeforeCommit(async () => {
await outboxStore.flush(); // append outbox rows atomically with the aggregate write
});
// onCommit — run once the outermost transaction has durably committed.
// The commit is already permanent; a throwing hook is logged and absorbed (never re-thrown).
database.onCommit(async () => {
await relay.scheduleNextDrain(); // dispatch integration events after the durable write
});
// onRollback — run when the active transaction rolls back.
// Used internally by the repository to restore in-memory aggregate state.
database.onRollback(() => {
entity.restoreFromSavepoint(); // keep in-memory entity state consistent with the rolled-back row
});
Both onBeforeCommit and onCommit hooks bubble to the outermost transaction: a hook registered
inside a nested savepoint is moved to the parent when the savepoint commits, and fires only when the
outermost transaction commits. A rolled-back transaction never fires its commit hooks. onRollback hooks
work the inverse way — they fire for the innermost failing scope (the savepoint that rolled back), not the
parent.
These hooks are what make the drizzle transactional outbox reliable: the producer registers an
onBeforeCommit hook that appends the outbox rows to the same transaction the aggregate write runs in,
then an onCommit hook that schedules the relay drain after the durable commit.
Deep aggregate persistence
save is deep: it persists the aggregate root and its eager-loaded children in one transaction.
Orphan removal runs automatically — a child that was loaded and then removed from a collection is
DELETEd when the root is saved.
For save to reach a child entity you must declare it in the repository's relatedEntities graph (the
same graph that configures eager loading on reads):
import { DrizzleLibSQLDatabase, DrizzleLibSQLEntityRepository } from "@heximon/drizzle/libsql";
import { Order } from "./order.entity";
export class OrderRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, Order> {
public constructor(database: DrizzleLibSQLDatabase) {
super(database, "Orders", Order, {
items: true, // load and deep-save the order's items
});
}
}
The deep write runs in three phases per save:
- Snapshot orphan pre-image. The child primary keys present at the last load (the entity's snapshot) are captured before any mutation — these are the "known children" the diff runs against.
- Cascade save. Each child entity's dirty state is settled (
commitChanges), then routed:Detached→ batchedINSERT,Transient(mutated since load) → version-guardedUPDATE,Persistent(unchanged) → no query. Optimistic concurrency is enforced at every level. - Orphan removal. The current child keys are compared against the pre-image; any key that was loaded
but is no longer present in the collection gets a cascade
DELETE(deepest relation first, so foreign keys hold).
An in-memory savepoint is captured before the write and restored on rollback (via an onRollback hook),
so the aggregate's dirty state, version, and snapshot all revert with the SQL rows if the transaction fails.
relatedEntities is never loaded and therefore never diffed — removing an item from the collection
without first loading the aggregate leaves orphaned rows in the database.SQL outbox store
DrizzleOutboxStore is the drizzle implementation of the DDD OutboxStore port. It stores outbox rows in
a table you declare in your schema by spreading the dialect-core outboxColumns() helper:
import { outboxColumns } from "@heximon/drizzle/sqlite-core";
import { sqliteTable } from "drizzle-orm/sqlite-core";
export const outbox = sqliteTable("outbox", { ...outboxColumns() });
import { outboxColumns } from "@heximon/drizzle/pg-core";
import { pgTable } from "drizzle-orm/pg-core";
export const outbox = pgTable("outbox", { ...outboxColumns() });
import { outboxColumns } from "@heximon/drizzle/mysql-core";
import { mysqlTable } from "drizzle-orm/mysql-core";
export const outbox = mysqlTable("outbox", { ...outboxColumns() });
Then bind OutboxStore to a DrizzleOutboxStore constructed over your database and the table, with a
useFactory provider — the same shape every other DI-can't-express construction takes:
import { OutboxStore } from "@heximon/integration";
import { DrizzleOutboxStore } from "@heximon/drizzle/core";
import { outbox } from "./order.schema";
// inside the module's providers:
{
provide: OutboxStore,
useFactory: (database: OrderSqliteDatabase): OutboxStore =>
new DrizzleOutboxStore(database, outbox),
}
DrizzleOutboxStore implements the full OutboxStore protocol:
| Method | What it does |
|---|---|
append(records) | INSERTs rows through executeStatement — routes into the active transaction, so rows commit atomically with the aggregate write |
claimPending(limit) | Opens its own fresh transaction, claims up to limit pending rows in id order with per-dialect locking (pg/mysql FOR UPDATE SKIP LOCKED; SQLite serializes via the write transaction itself), marks them claimed |
markProcessed(ids) | DELETEs the delivered rows |
markFailed(id, attempt) | Persists the bumped attempt count and clears the claim; dead-letters the row once attempt reaches the threshold (default 5) |
The full reliable tier — outbox store + producer + relay — is wired as three providers in the feature module; see Flagship — transactional outbox for the complete wiring.
Wire the database once
A Database is one instance per app, bound as a useFactory provider so every repository shares it and
the ambient transaction stays request-wide. The factory's parameter type is its dependency — no token:
import { Context, Module } from "@heximon/runtime";
import { DrizzleLibSQLDatabase } from "@heximon/drizzle/libsql";
import { databaseConfig } from "./database.config";
export class DatabaseModule extends Module({
providers: [
{
provide: DrizzleLibSQLDatabase,
useFactory: (context: Context) => new DrizzleLibSQLDatabase(databaseConfig, context),
},
],
exports: [DrizzleLibSQLDatabase], // feature modules import this to inject the database
}) {}
The databaseConfig is authored once and consumed twice — by the runtime factory and by drizzle-kit for
migration generation. The Database wiring page walks through the unified config and
lifecycle.
Migrations
Heximon ships the migration runner, not a schema-diff CLI: you author .sql with stock drizzle-kit generate (it reads the same config), and the runner applies an already-generated folder at boot.
Application is idempotent and forward-only, since Drizzle emits no down SQL. The
Migrations page covers generating and applying them end-to-end.
Custom column codecs
Each dialect ships codecs that round-trip non-trivial values. Import them from the dialect's *-core
subpath — not the root barrel, where the differently-typed dateObjects would collide:
| Dialect | Subpath | Codecs |
|---|---|---|
| SQLite / libsql | @heximon/drizzle/sqlite-core | dateObject (ISO text ⇄ Date), noCaseText |
| PostgreSQL | @heximon/drizzle/pg-core | dateObject (→ TIMESTAMPTZ(3)), uuid (→ native uuid) |
| MySQL | @heximon/drizzle/mysql-core | dateObject (→ DATETIME(3)), uuid (→ CHAR(36)), binaryUuid (→ BINARY(16)) |
See also
- Database wiring — the
useFactoryprovider, the unified config, and the per-appDatabaselifecycle. - Migrations — generate with
drizzle-kit, apply on boot with the migration runner. - Persistence contracts — the dialect-agnostic
Database/runInTransaction/MigrationRunnersurface Drizzle implements. - Domain-Driven Design — the
Entity/AggregateRoot/ValueObjectbases these schemas persist. - Repository Pattern — the
Repositorycontract behind the Drizzle base, and the optimistic concurrency that feeds CQRS retry. - Example L06 — DDD
— a
Useraggregate over libsql with a flattenedEmailcolumn, plus a test that compiles the app, drives the/usersendpoints, and proves theConcurrencyErroron a stale concurrent save. - Example L04 — database
— the minimal database wiring without the DDD layer, for a feel of the bare
Database+ repository setup. - L06 — DDD
— an
Orderaggregate with value objects and domain events, the pattern these repositories persist. - Flagship — transactional outbox
— the full outbox tier:
DrizzleOutboxStore+OutboxIntegrationEventTransportproducer + relay, wired so integration events are published reliably after commit.
Database
Wire a real SQLite database into a CRUD API with no install and no compiler plugin — the unified DrizzleLibSQLConfig, a named database DI token, a repository over getOrm(), and boot-time table creation.
Persistence & Transactions
A dialect-agnostic persistence boundary — the abstract Database surface, runInTransaction transactions, optimistic concurrency with ConcurrencyConflictError, MigrationRunner contracts, and how a concrete dialect plugs in via a useFactory provider.