6.6 KiB
ADR-006: Kysely contained inside DB adapter
- Status: Accepted
- Date: 2026-08-31
- Deciders: platform stream
- References: ADR index (section 70), 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.
The decision text — Kysely contained inside DB adapter — matches the ADR index entry (ADR-006, section 70).
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
pgeverywhere / 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-postgresand are exact-pinned single-manifest changes (ADR-026 lane); 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
kyselyandpgappear in exactly one manifest (packages/database-postgres/package.json) and in exactly one package's source; a static scan intests/database-postgres-imports.test.mjs(database-postgres-importsCI job) proves it on every PR, including a mutation probe that fails when a driver import is injected elsewhere.- Upgrading Kysely or
pgis a single-manifest change inside the adapter, reviewed under the dependency/upgrade lane (ADR-026); 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, ADR index section 68) 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-importsgate are relaxed or re-scoped.