Skip to content

ADR-0021: EntityRepository — Persistence Abstraction for Worker Services ​

  • Status: Accepted
  • Date: 2026-07-04
  • Deciders: User + agent grilling session
  • Issues: #352

Context ​

Worker services currently receive D1Database directly and call db.prepare(sql).bind(...params).first<T>()/.all<T>()/.run() inline. This creates three problems:

  1. Untestable without SQL-string mocking — tests use mockD1.ts which matches exact SQL strings. A single whitespace change breaks every mock. This is brittle and makes refactoring SQL painful.
  2. No scope abstraction — ADR-0016 introduced ScopeFilter for RBAC-scoped queries, but there's no standard place to inject it. Each service would need to check scope.type and append WHERE clauses individually — easy to forget.
  3. Boilerplate duplication — every service writes the same SELECT cols FROM table WHERE id = ?1 pattern. A repository eliminates this.

The frontend already solved these same problems via StorageBackend interface (query/exec/transaction/onChange/close) with SQLiteStore and createMockStore/createEmptyMockStore test helpers.

Decision ​

Introduce EntityRepository<T> — a generic persistence interface for CRUD entities in the worker layer. Two implementations: D1EntityRepo (production, D1) and MemoryEntityRepo (tests, in-memory Map).

Interface ​

typescript
interface EntityRepository<T extends Record<string, unknown>> {
  findById(id: string): Promise<T | null>;
  list(filter?: QueryFilter, options?: ListOptions): Promise<{ items: T[]; cursor: string | null }>;
  create(id: string, input: Partial<T>): Promise<T>;
  update(id: string, changes: Partial<T>): Promise<T>;
  softDelete(id: string): Promise<void>;
}

Key design rules ​

DecisionChoiceRationale
Not-foundfindById returns T | null. update/softDelete throw EntityNotFoundErrorfindById = query (optional). Update/delete = command (must exist). Matches first<T>() return.
ID generationCaller supplies ID via crypto.randomUUID()Event-sourced systems need ID before write. Service retains control.
Create returnReturns the full entity (INSERT ... RETURNING *)D1 supports RETURNING natively. Gives back server-set fields (createdAt).
Update stylePATCH — Partial<T>, only changed fieldsMatches HTTP PATCH. Existing studentToUpdateParams() already does this.
Soft deletesoftDelete(id) — sets status to 'DELETED'. Justification is entity-specific (handled by service).Keeps interface clean. Justification columns vary per entity.
ScopeConstructor-injected (new D1EntityRepo(db, "students", cols, scope?))Baked into the instance — can't be forgotten. Route handler creates repo once per request.
Row mappingThin repo — returns raw Record<string, unknown>. Service calls rowToStudent() etc.Existing mappers (studentMapper.ts) are already well-tested. Minimizes migration change.
PaginationCursor-based (cursor?: string, returns { items, cursor })Follows ADR-0013 (ISO 8601 updated_at cursor, descending).
Error handlingEntityNotFoundError extends Error. Validation stays in service layer.Simple. Matches existing HttpError 404 pattern in handlers.

D1EntityRepo adapter ​

typescript
class D1EntityRepo<T extends Record<string, unknown>> implements EntityRepository<T> {
  constructor(
    private db: D1Database,
    private tableName: string,
    private selectColumns: string[],
    private options?: { scope?: ScopeFilter; idColumn?: string; statusColumn?: string },
  ) {}

  // SQL generation is internal:
  // - SELECT {selectColumns} FROM {tableName} WHERE {idColumn} = ?1
  // - scope WHERE clause appended to list() automatically
  // - softDelete uses {statusColumn} = 'DELETED'
  // - update generates dynamic SET clause from Partial<T> keys
}

MemoryEntityRepo adapter ​

typescript
class MemoryEntityRepo<T extends { id: string }> implements EntityRepository<T> {
  private store = new Map<string, T>();

  // findAll() applies scope filter in-memory
  // create/update/softDelete operate on the Map
  // Useful for unit tests — no D1 mock needed
}

Consequences ​

  • Positive: Service tests switch from mockD1.ts (brittle SQL-string keyed) to MemoryEntityRepo (in-memory, deterministic, fast).
  • Positive: Scope is enforced at the repository level — a developer can't accidentally return unscoped results.
  • Positive: Boilerplate for CRUD operations drops to ~10 lines per entity (column list + constructor call).
  • Negative: Every service that wants to use the repo needs its signature changed from (db: D1Database, ...) to (repo: EntityRepository<T>, ...) — migration per I6/I7.
  • Negative: Dynamic SQL generation in D1EntityRepo.update() adds complexity (handling undefined vs null, column name safety).

Migration Plan ​

  1. Phase 1 (I5): Define interface + both adapters in workers/src/storage/repository.ts. Wire studentService as first consumer. Student tests switch to MemoryEntityRepo.
  2. Phase 2 (I6): Migrate remaining 4 services (user, class, role, classSlot) to EntityRepository<T>.
  3. Phase 3 (I7): Extract inline D1 queries from route handlers into service modules using the repo.

Alternatives Considered ​

AlternativeRejected because
Keep D1Database everywhereMockD1 is brittle. No scope abstraction. Duplicate boilerplate.
Thick repo with mapper injectionExisting studentMapper.ts works fine — no need to reabstract what's already abstracted.
Result type (RepoResult<T>)Over-engineered for this codebase. T | null + EntityNotFoundError covers all cases.
Scope as list() parameterEasy to forget — constructor injection makes it mandatory.

References ​

  • ADR-0013 — Pagination strategy (cursor-based)
  • ADR-0016 — RBAC Scope System (ScopeFilter type)
  • Issue #352 (I5: Design EntityRepository interface)
  • Frontend StorageBackend interface at app/src/storage/types.ts
  • EventStore interface at workers/src/events/eventStore.ts (existing abstraction pattern)

Distribuído sob licença MIT.