diff --git a/docs/adr/ADR-006-kysely-contained-inside-db-adapter.md b/docs/adr/ADR-006-kysely-contained-inside-db-adapter.md new file mode 100644 index 0000000..336327b --- /dev/null +++ b/docs/adr/ADR-006-kysely-contained-inside-db-adapter.md @@ -0,0 +1,120 @@ +# 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 `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 (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 + +- `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 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-imports` gate are relaxed or + re-scoped.