Repository Pattern
A repository is the collection-like boundary between your domain and the database. Your handlers treat
it like an in-memory collection of aggregates — users.save(user), users.getById(id) — and it turns
those calls into inserts, version-guarded updates, and queries, so no domain or HTTP code touches SQL.
Inject a repository as a dependency
A repository is a plain provider: list it in the module's providers, type a constructor parameter with
it, and the compiler injects it. Code that uses a repository needs no special base — it's just a class.
import type { Controller, Get } from "@heximon/http";
import { type UserId } from "./user.entity";
import { UserRepository } from "./user.repository";
export class UserController implements Controller<"/users"> {
// Injected by class identity from the parameter type — no token to declare.
constructor(private readonly users: UserRepository) {}
public async find(action: Get<"/:id">): Promise<UserResponse | undefined> {
const user = await this.users.getById(action.request.pathParams.id as UserId);
return user && UserController.toResponse(user);
}
}
Save an aggregate
save(aggregate) is one call for both insert and update — the repository decides which from the
entity's lifecycle state. A freshly built aggregate inserts; one you loaded and mutated updates. You
never write that branch at the call site.
export class UserController implements Controller<"/users"> {
public async create(action: Post<"/", { body: typeof createUserSchema }>): Promise<TypedResponse> {
const body = await action.request.readValidatedBody();
const user = User.create(uuid.v7<UserId>(), body); // a new, never-persisted aggregate
await this.users.save(user); // → INSERT, at version 1
return action.respond(201, UserController.toResponse(user));
}
}
Updates read the same way — load, call domain methods, save. You mutate real fields in plain TypeScript;
at save time the repository diffs the aggregate against the snapshot it took at load (no Proxy) and emits
an UPDATE for only the changed columns. An unchanged save is a no-op.
export class UserController implements Controller<"/users"> {
public async update(action: Patch<"/:id", { body: typeof updateUserSchema }>): Promise<TypedResponse> {
const id = action.request.pathParams.id as UserId;
const body = await action.request.readValidatedBody();
const user = await this.users.getById(id);
if (user === undefined) return action.respond(404, { error: `User ${id} not found` });
if (body.name !== undefined) user.rename(body.name); // mutate through domain methods
if (body.email !== undefined) user.changeEmail(body.email);
await this.users.save(user); // → version-guarded UPDATE of the changed columns only
return UserController.toResponse(user);
}
}
Read with getById, getMany, and getAll
The read surface mirrors a collection. getById(id) returns one aggregate or undefined,
getMany(ids) batch-fetches, getAll(query?) returns every aggregate matching an optional query, and
count(query?) counts without materializing rows.
export class UserController implements Controller<"/users"> {
public async list(_action: Get<"/">): Promise<UserResponse[]> {
const users = await this.users.getAll();
return users.map((user) => UserController.toResponse(user));
}
}
Every aggregate reads back clean — carrying its persisted version and counting as unmodified until you mutate it, which is what makes the load-mutate-save loop safe without any bookkeeping.
Filter with EntityQuery
getAll(query?) and count(query?) take an EntityQuery<TEntity> — a typed filter, ordering, and
pagination object keyed against your entity's own fields. It's a pure TypeScript mapped type with no
per-entity codegen, so a query is just data you build inline.
import type { EntityQuery } from "@heximon/domain";
// Active users whose name starts with "ali", newest first, first page of 20.
const query: EntityQuery<User> = {
filter: { status: { eq: "active" }, name: { ilike: "ali%" } },
order: { createdAt: "desc" },
limit: 20,
offset: 0,
};
Each field accepts a FieldFilter: eq / ne / gt / gte / lt / lte / in / notIn /
like / ilike / notLike / notIlike / isNull / isNotNull, combined with AND / OR / NOT.
The filter descends: a value object filters by its sub-fields (filter: { name: { fullName: { eq } } })
when it is stored across columns, or as a whole value when serialized into one; a relation recurses into
its own fields.
The query is trusted, never re-coerced — validating untrusted input is the HTTP DTO's job, done at the
boundary with a Standard Schema (e.g. @heximon/schema's QuerySchema.create).
When the field names a consumer filters in differ from the entity's — a renamed or reshaped response —
a QueryMapper (from @heximon/domain) translates that response-shaped query into the EntityQuery via one
declarative field map, so the controller carries no hand-written mapping. See examples/flagship/packages/catalog.
Define the repository contract
A repository is a class extending a dialect base. For a Drizzle-backed aggregate,
DrizzleLibSQLEntityRepository supplies the whole surface — save, getById, getAll, getMany,
count, delete, transaction — so the body is just a constructor passing the table key and entity
constructor.
import {
DrizzleLibSQLDatabase,
DrizzleLibSQLEntityRepository,
} from "@heximon/drizzle/libsql";
import { User } from "./user.entity";
export class UserRepository extends DrizzleLibSQLEntityRepository<DrizzleLibSQLDatabase, User> {
// The shared Database is the only DI-resolved dependency — resolved by its class type.
public constructor(database: DrizzleLibSQLDatabase) {
super(database, "Users", User);
}
}
Every repository shares the one Database, so a transaction opened on one is visible to all within the
same request.
Swap implementations behind an abstract token
To make a repository swappable — an in-memory double in tests, a different dialect per environment — type your consumers against an abstract repository and bind the concrete one with a provider. The abstract class is the DI token; the class extending it is what gets constructed.
import { Repository } from "@heximon/domain";
import type { User, UserId } from "./user.entity";
// The token consumers inject — both the interface and the DI key.
export abstract class UserRepository extends Repository<User, UserId> {}
import { Module } from "@heximon/runtime";
import { DatabaseModule } from "../database/database.module";
import { DrizzleUserRepository } from "./drizzle-user.repository";
import { UserController } from "./user.controller";
import { UserRepository } from "./user.repository";
export class UserModule extends Module({
imports: [DatabaseModule],
providers: [{ provide: UserRepository, useClass: DrizzleUserRepository }],
http: { controllers: [UserController] },
exports: [UserRepository],
}) {}
Every class that injects UserRepository gets the bound implementation, and swapping the dialect is a
one-line change to useClass — nothing downstream moves. Don't need the seam? Provide the concrete
repository directly, as the first example does.
Deep aggregate persistence
When a Drizzle-backed repository is configured with an eager-load relation graph, save(entity) persists
deeply — the root and every eager-loaded child in one transaction. Child rows that were present at
load but removed from the collection (orphans) are deleted; new rows are inserted; mutated rows are
updated with column-level diffs.
Optimistic concurrency is checked at every level: a stale version in any row throws ConcurrencyError. An
in-memory savepoint reverts the entity's dirty-state and version if the transaction rolls back, so the
aggregate is always consistent with the database after a failed save.
This is handled entirely by the Drizzle dialect — see Drizzle ORM for how to declare the relation graph and the value-object column type.
Detect a conflict with optimistic concurrency
When two requests load the same aggregate and both save, the second would silently overwrite the first.
A version column closes that race: save guards its UPDATE with WHERE version = <loaded>, so if
another writer already bumped the version, zero rows update — which the repository reports as a
ConcurrencyError.
import { ConcurrencyError } from "@heximon/runtime/errors";
try {
await this.users.save(user); // UPDATE ... WHERE version = 3
} catch (error) {
if (error instanceof ConcurrencyError) {
// Someone else won the race; reload and retry against fresh state.
}
throw error;
}
ConcurrencyError comes from @heximon/runtime/errors, not the repository package. Anything
that catches it — including the CommandBus —
keys on that one error type.Inside a command handler you usually skip that try/catch: the repository's job is to detect and throw,
and the CommandBus retries against fresh state. Only contention triggers a retry — every other error
propagates, which keeps retries scoped to real races.
See also
- Domain-Driven Design — the entities, value objects, and aggregates a repository
loads and saves, including the lifecycle that tells
saveinsert from update. - CQRS — command handlers that inject a repository and rely on its
ConcurrencyErrorto drive the automatic retry. - Drizzle ORM — the concrete dialect behind the contract: value-object columns, nested transactions, and auto-wired entity mappers.
- Database Wiring — the single shared
Database,runInTransactiontransactions, and the optimistic-concurrency contract every dialect implements. - L06 — DDD
— a
Useraggregate behind a Drizzle repository, proven by a test that races two writers into aConcurrencyError.
CQRS
Split writes from reads with a typed CommandBus and QueryBus, one handler per message, around-chain interceptors, and automatic ConcurrencyError retry.
Integration Events
Cross-context and cross-service events with IntegrationEvent producers, IntegrationEventHandler consumers, and the transactional outbox (OutboxStore, OutboxRelay, OutboxIntegrationEventTransport).