Files
PersonalBlog/docs/adr/ADR-006-kysely-contained-inside-db-adapter.md
implementer 693976c289
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (pull_request) Successful in 44s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (pull_request) Successful in 1m9s
CI / Stage 4 — Unit tests (E00-S05-T01) (pull_request) Successful in 1m34s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (pull_request) Successful in 1m2s
CI / Stage 2 — Typecheck (E00-S05-T01) (pull_request) Successful in 1m9s
CI / Stage 5 — Architecture tests (E00-S05-T01) (pull_request) Successful in 3m8s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (pull_request) Successful in 45s
docs(adr): record Kysely containment decision as ADR-006
2026-08-31 00:55:38 +00:00

6.5 KiB

ADR-006: Kysely contained inside DB adapter

  • Status: Accepted
  • Date: 2026-08-31
  • Deciders: platform stream
  • References: ADR-001, ADR-005, Architecture wiki (sections 1, 4, 9), Engineering-Standards wiki (sections 24, 25, 57)

Context

EPPP is a modular monolith (ADR-001) that keeps every module boundary explicit, versioned and CI-enforced. ADR-005 already records the database choice — PostgreSQL 18 as the sole canonical database — and with it the rule that the pg driver and the Kysely query builder are isolated to packages/database-postgres, the adapter boundary every database access passes through. This ADR is the focused record of where the query builder itself may live: the containment decision.

Kysely is a type-safe SQL query builder, not an ORM. It gives typed queries and composable expressions on top of pg, but it still speaks SQL and a dialect, and every package that imports it is coupled to that dialect and to the adapter's implementation choices. The workspace already depends on the containment: packages/database-postgres declares kysely 0.29.4 and pg 8.22.0 as its exact-pinned runtime dependencies (its only ones), imports them in its source and re-exports the pieces the adapter is built on (E00-S03-T02), and is the single workspace package whose source may import the driver — a rule locked in by tests/database-postgres-imports.test.mjs in the database-postgres-imports CI job and by the architecture fitness tests FIT-010 (browser bundles cannot import server/DB packages) and FIT-011 (UI cannot import Kysely/pg).

The dependency direction is already fixed: database-postgres → core ports → Kysely/pg (Architecture wiki §9.1, Engineering-Standards §24). Domain and extension packages talk to the database through core repository/port interfaces, never through the query builder. What remains to be recorded is that this is a standing architectural decision, not a temporary arrangement: Kysely stays inside the adapter, and no other package may grow a Kysely or pg import surface.

Decision

EPPP keeps Kysely contained inside DB adapter. Kysely is an implementation detail of packages/database-postgres — the single workspace package allowed to import pg/Kysely (E00-S03-T02) — and the rest of the workspace reaches the database exclusively through the adapter's and core ports' APIs. No other workspace package may declare kysely or pg in its manifest or import them directly in its source; the database-postgres-imports CI gate plus FIT-010/FIT-011 enforce this on every PR. Kysely exists only where the adapter lives — it is contained, not distributed, and the containment is a package-level rule, not a type-level one.

Alternatives

  • Kysely imported directly by domain/extension packages — rejected: it would leak SQL-dialect and query-builder concerns into domain and extension code, defeat the single-owner boundary (E00-S03-T02), make FIT-011 impossible to honour, and couple business logic to the adapter's implementation.
  • A dedicated shared query-layer package — rejected: it would create a second import surface for the driver stack, split ownership of the dialect, and add a package whose only purpose is to widen the boundary; the adapter already owns the typed surface callers need.
  • Full ORM (e.g. Prisma/Drizzle) instead of a query builder — rejected: schema is owned by the adapter's migration chains (ADR-005, Engineering-Standards §25), and an ORM adds code generation and a schema file that would couple domain models to storage; Kysely's typed builder is sufficient and stays contained.
  • Raw pg everywhere / no query builder — rejected: hand-written SQL loses type safety and composability while still requiring the driver; containing a query builder is strictly better than containing SQL strings.
  • Adapter re-exports Kysely for other packages to build queries — rejected (variant of the first alternative): even when routed through the adapter, letting other packages compose Kysely queries would leak the dialect and bypass the port/repository API that keeps domain code storage-agnostic.

Consequences

  • Positive: one owner for the whole driver/query-builder stack — upgrade, dialect and typing decisions live in packages/database-postgres and are exact-pinned single-manifest changes, so replacing or upgrading Kysely and pg touches exactly one manifest; domain and extension code stays driver-free, so FIT-010/FIT-011 hold; replacing Kysely or the dialect later is a contained change behind the adapter boundary (consistent with the ADR-005 encapsulation).
  • Negative: the adapter's port/repository API must be designed well enough that domain code never needs the query builder — the port API is mandatory, not optional; Kysely conveniences (expression builders, plugin features) are not available to callers outside the adapter; the adapter package grows as the query surface grows.
  • Neutral: Kysely remains a dependency of the adapter package only; contracts between core ports and the adapter are plain TypeScript interfaces, so the containment stays a package-level rule and never becomes a type-level one.

Operational impact

  • kysely and pg appear in exactly one manifest (packages/database-postgres/package.json) and in exactly one package's source; a static scan in tests/database-postgres-imports.test.mjs (database-postgres-imports CI job) proves it on every PR, including a mutation probe that fails when a driver import is injected elsewhere.
  • Upgrading Kysely or pg is a single-manifest change inside the adapter, reviewed under the workspace's dependency/upgrade lane; no other package's manifest or code moves.
  • No new runtime services, no schema changes, no deployment or configuration impact: this ADR is documentation of an already-enforced boundary.

Revisit trigger

  • Revisit this ADR when a non-adapter package demonstrably needs query-builder features that cannot be expressed through the adapter/port API, and there is evidence (per the architecture review gates) that the boundary costs more than containment saves.
  • Revisit if the platform grows additional data stores or engines (shared with the ADR-005 revisit trigger): a second engine may need its own contained adapter, and the query-builder containment rule would extend per adapter.
  • Revisit if the dependency-boundary enforcement changes — for example if FIT-010/FIT-011 or the database-postgres-imports gate are relaxed or re-scoped.