Heximon Logo
Architecture

Repository Pattern

A collection-like persistence boundary with save, getById, getAll, and a zero-codegen EntityQuery, swappable behind an abstract Repository token, with optimistic-concurrency ConcurrencyError.

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.

src/users/user.controller.ts
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.

src/users/user.controller.ts
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.

src/users/user.controller.ts
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.

src/users/user.controller.ts
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.

src/users/user.repository.ts
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.

src/users/user.repository.ts
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> {}
src/users/user.module.ts
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 save insert from update.
  • CQRS — command handlers that inject a repository and rely on its ConcurrencyError to 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, runInTransaction transactions, and the optimistic-concurrency contract every dialect implements.
  • L06 — DDD — a User aggregate behind a Drizzle repository, proven by a test that races two writers into a ConcurrencyError.
Copyright © 2026