02 — Monorepo & Package Design
1. Structure
kavo/
├─ package.json # root: build/check scripts, dev tooling
├─ pnpm-workspace.yaml
├─ tsconfig.base.json # shared strict compiler options
├─ tsconfig.json # solution file: project-reference graph
├─ .dependency-cruiser.cjs # mechanical boundary enforcement
├─ packages/
│ ├─ core/ # @kavo/core
│ │ ├─ src/{types,query,dto,errors,config,operations,
│ │ │ relations,context,serialization,persistence,service}/
│ │ └─ src/index.ts # explicit named barrel
│ ├─ orms/
│ │ ├─ typeorm/ # @kavo/typeorm
│ │ │ └─ src/index.ts
│ │ ├─ prisma/ # @kavo/prisma
│ │ │ └─ src/index.ts
│ │ ├─ mongoose/ # @kavo/mongoose
│ │ │ └─ src/index.ts
│ │ └─ mikroorm/ # @kavo/mikroorm
│ │ └─ src/index.ts
│ ├─ frameworks/
│ │ └─ nest/ # @kavo/nest
│ │ └─ src/index.ts
│ └─ protocols/
│ ├─ graphql/ # @kavo/graphql
│ │ └─ src/index.ts
│ └─ mcp/ # @kavo/mcp
│ └─ src/index.ts
├─ examples/ # reference applications, one per framework+ORM pairing
│ ├─ nest-typeorm/ # @kavo/example-nest-typeorm
│ │ └─ src/index.ts
│ ├─ nest-mongoose/ # @kavo/example-nest-mongoose
│ │ └─ src/index.ts
│ └─ nest-mikroorm/ # @kavo/example-nest-mikroorm
│ └─ src/index.ts
└─ docs/ # this documentationThe orms/, frameworks/, and protocols/ parent folders keep the door open for future adapters, host framework bindings (Express, Fastify, Next.js, …), and wire protocols (gRPC, …) without implying any get built ahead of real work landing (ADR-0002, ADR-0016). @kavo/prisma and @kavo/mongoose are the second and third orms/* adapters, alongside @kavo/typeorm — see ADR-0017 for the one place Prisma's design departs from the TypeORM adapter's shape (marker classes standing in for Prisma's lack of runtime entity classes), and ADR-0018 for Mongoose's two (a model is already the entity identity, and ObjectId converts at the adapter boundary rather than widening core's EntityId). @kavo/mikroorm is the fourth, and needed no ADR of its own: a decorated MikroORM entity class is already the identity, exactly as under TypeORM, so the only decisions it makes are adapter-local and live in doc 17.
2. Responsibility statements
@kavo/coreexists to own every contract and all ORM/framework- independent runtime (engine, config merging, query parsing, DTO resolution, exceptions). It can't depend on anything — not TypeORM, not NestJS, not utility libraries. If core needs a helper, core writes it.@kavo/typeormexists to translate core's persistence contracts to TypeORM (adapter, filter translation, error mapping, transactions). It can't depend on NestJS or@kavo/nest— an adapter must be usable from any future framework binding.@kavo/prismaexists to translate core's persistence contracts to Prisma Client (same shape as@kavo/typeorm: adapter, filter translation, error mapping), and is bound by the same rule — no NestJS, no@kavo/nest. See ADR-0017 for how it substitutes for the runtime entity classes Prisma doesn't generate.@kavo/mongooseexists to translate core's persistence contracts to Mongoose (same shape again: adapter, filter translation, error mapping), under the same no-framework rule. See ADR-0018 and doc 15 for the two places a document store diverges from a relational one.@kavo/mikroormexists to translate core's persistence contracts to MikroORM (same shape again: adapter, filter translation, error mapping), under the same no-framework rule. See doc 17 for the two places it splits the difference between its siblings — TypeORM's decorated-class metadata seam, Prisma's declarative query surface.@kavo/nestexists to bind Kavo to NestJS (module, decorator, route generation, exception filter, Swagger). It can't depend on TypeORM or@kavo/typeorm— it sees persistence only as an injectedRepositoryAdapter. It may depend on aprotocols/*package (@kavo/graphql,@kavo/mcp) to offer that protocol's glue as an add-on — see ADR-0016 — but never anotherframeworks/*package.@kavo/graphql(packages/protocols/graphql, ADR-0016) exists to build aGraphQLSchemaover acreateCrudservice — host-framework- agnostic, same constraint as an ORM adapter: it depends on@kavo/coreand thegraphqlpeer only, never@kavo/nestor any other framework package. Seedocs/internals/architecture/13-graphql-binding.md.@kavo/mcp(packages/protocols/mcp, ADR-0016) exists to expose acreateCrudservice's standard operations as MCP tools — the sameprotocols/*shape and constraint as@kavo/graphql:@kavo/coreonly, plus the@modelcontextprotocol/sdkpeer for types (never imported at runtime by@kavo/mcpitself —@kavo/nest's zero-config default controller is the one place the SDK actually runs, lazily — see doc 16, §5). Seedocs/internals/architecture/16-mcp-binding.md.
Every package earns its place: core is the hub, and every other package adapts exactly one external technology or protocol — an ORM, a host framework, or a wire protocol.
3. Dependency rules — mechanically enforced
Two independent enforcement layers:
TS project references (
tsconfig.jsonsolution + per-packagereferences) make build order correct and make an undeclared cross-package import a compile error.dependency-cruiser (
.dependency-cruiser.cjs, run inpnpm check) forbids: core importing anything, adapter↔framework imports in either direction, cross-package deep imports past a barrel, an adapter or protocol binding importing@nestjs/*, and runtime import cycles (type-only cycles are exempt — core's contracts are mutually referential by design and erase at compile time). One exception to "no cross-edge imports": aframeworks/*package may depend on aprotocols/*package (@kavo/nest→@kavo/graphql/@kavo/mcp), never the reverse — ADR-0016.Three properties of that rule set are load-bearing and easy to lose:
Every workspace edge is checked, and stays checked without edits. That includes the four that were review-only for most of this repo's life: an ORM adapter importing a protocol package, one ORM adapter importing another, one protocol importing another, and one edge's
srcdeep-importing another edge's.core-imports-nothingcovers type-only edges too — core owning its contracts (ADR-0001) does not stop at what survives compilation. The three per-tier rules capture the package directory infromand refer back to it as$1into.pathNot, so they are written against the layout rather than against a list of package names: a new adapter or protocol binding is constrained the day its directory exists. The previous spelling was one rule per package, which meant every new package had to hand-edit every other package's rule, and the prose describing the set drifted out of sync with it twice.Two limits are part of the design rather than oversights.
framework-bindings-import-core-and-protocol-barrelsstill names@kavo/(core|graphql|mcp)explicitly, because it is an allowlist of sanctioned sideways edges — a newprotocols/*package that@kavo/nestglues must be added to it, and that edit is the architectural decision being reviewed. And the rules match@kavo/*andpackages/*only, so npm edges are outside them:only-framework-bindings-import-a-host-frameworkblocks@nestjs/*in an adapter or protocol binding, but a wrong peer (typeorminside@kavo/prisma) is review's job. So is the part no import graph can see — an ORM or Nest type leaking into a core signature, and whether a newly named barrel export was meant to be public.Both spellings are matched. A workspace package specifier does not resolve to a path for dependency-cruiser, so a path-only rule silently misses
from "@kavo/nest"— the spelling anyone would actually write. The rules match the bare specifier as well as the relative path.examples/*is in scope too: those are the reference apps.tests/is cruised, not exempt. Test files were once excluded entirely, which left the boundary convention-only exactly where fixture sharing tempts a shortcut. A test file may import its own package's source and the@kavo/*barrels, never another package'ssrcortests; core's tests additionally may not reach an adapter, a protocol binding, or a framework package, because core's ignorance of all three is what its suite exists to prove.
4. Workspace tooling: pnpm + plain scripts (ADR-0003)
pnpm workspaces with plain root scripts, no task runner. The entire build graph is eight packages and three example apps, whose ordering is already fully expressed by TS project references — tsc -b performs incremental, dependency-ordered, cached builds natively. A task runner (turborepo/nx) would add a second place where the graph is declared, a cache layer duplicating .tsbuildinfo, and config to keep honest, while buying nothing at this scale. Revisit only if the workspace gains many packages or expensive non-tsc pipelines (a future e2e suite is the natural checkpoint).
Root scripts: generate, build (tsc -b), clean, typecheck, depcruise, lint, test, prettify, format:check, docs:build, docs:links, and check — the last runs generate → build → typecheck → depcruise → lint → test and is the verification gate. Three checks sit outside it, each as its own CI job: format:check, docs:build (VitePress, which resolves the links inside the pages it renders), and docs:links (scripts/check-doc-links.sh). The last two are complements, not overlaps — the docs build never sees a docs/**.md reference written from packages/ or extensions/, because those are not pages, and it never reads docs/.vitepress/config.mts, because that is config rather than content. So a renamed doc can still leave a dead link in a package README that ships to npm, or a silent 404 in the published sidebar, and docs:links is what catches both. The /implement, /review, /pr and /merge commands run these alongside pnpm check locally, so none of them is a gate you only hear about from CI.
5. Public vs. internal API surface
Each package's exports map exposes only the barrel:
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }No subpath exports; deep imports are not API and Node will refuse them at runtime once published. Core's barrel is an explicit named list (ADR-0010) so the public surface only changes on purpose — it is the input to a future api-extractor gate. The current build ships ESM only; dual ESM+CJS output is a future deliverable.
6. Build strategy
tsc -b against the solution file: incremental (.tsbuildinfo), project-reference-ordered (core → typeorm/prisma/mongoose/mikroorm/graphql/mcp → nest), each package emitting dist/ with declarations + declaration maps. Consumers inside the workspace resolve @kavo/* via pnpm workspace links to the built dist, exactly as external consumers will.
7. Versioning: lockstep (ADR-0004)
All @kavo/* packages share one version number and release together. The packages form one tightly coupled contract surface — a core contract change almost always touches an edge package, and a single version answers "which adapter works with which core" permanently. Cost: occasional no-op version bumps for an untouched package — accepted as trivially cheap next to cross-package version-matrix support.
Release mechanics live in .github/workflows/publish.yml, triggered by a vX.Y.Z tag. PACKAGE_DIRS there is the explicit list of what gets released, ordered so every package publishes after the packages it depends on (@kavo/nest last), which keeps the registry internally consistent if a run fails partway. Lockstep itself is checked rather than assumed: a gate ahead of packing fails the release unless every listed package is already at the tag's version.
The artifact is checked too, between packing and publishing: no packed tarball may carry a workspace: range in any dependency field a consumer installs, and each must contain the entry point its own manifest declares. Only pnpm pack rewrites workspace:^ into a real semver range and it runs no build, so those are the two ways a tarball reaches the registry uninstallable — and npm does not allow republishing a version to correct it.
8. Dependency classification (decided now, executed later)
| Package | dependencies | peerDependencies |
|---|---|---|
@kavo/core | — (none, ever) | — |
@kavo/typeorm | @kavo/core | typeorm |
@kavo/prisma | @kavo/core | @prisma/client |
@kavo/mongoose | @kavo/core | mongoose |
@kavo/mikroorm | @kavo/core | @mikro-orm/core |
@kavo/graphql | @kavo/core | graphql |
@kavo/mcp | @kavo/core | @modelcontextprotocol/sdk |
@kavo/nest | @kavo/core, @kavo/graphql, @kavo/mcp | @nestjs/common, @nestjs/core, graphql, @modelcontextprotocol/sdk (+ @nestjs/swagger, all three protocol peers, optional) |
Peers, not dependencies, because the consumer's app owns the TypeORM/Prisma/ Mongoose/MikroORM/Nest instance — a second copy via a nested dependency would fracture instanceof checks and DI tokens.