Status: accepted · Date: 2026-10-17
Enforcement: in pnpm test, scripts/leaf-modules.test.mjs (LEAF imports), scripts/feature-boundaries.test.mjs (declared cross-feature edges), scripts/import-cycles.test.mjs (no cycles in packages/api/src and packages/db/src) and packages/api/src/exports.test.ts (export targets exist, mobile aliases resolve to the canonical file, the web app does not use them); in pnpm lint, the no-restricted-imports overrides of .oxlintrc.json for the import directions.
packages/api grew by accretion. Routers lived in src/routers/, domain code in one folder per topic, and shared helpers in src/lib/. The same kind of file had different names in different places (uploads/router.ts next to routers/todo.ts), and the main example feature kept its queries inside a 635-line router. A reader could not tell from a file name what the file was allowed to import, and nothing stopped a domain module from importing a router or a job.
Decision
One folder per feature, one fixed name per role. Every feature lives in packages/api/src/features/<feature>/. Files are named <feature>.<role>.ts:
| Role | File | Imports |
|---|---|---|
| Contract | <f>.contract.ts, a LEAF MODULE | zod, other leaves, db/schema/*.enums, any import type |
| Procedures | <f>.procedures.ts | its contract, service and errors, platform/rpc/* |
| Service | <f>.service.ts | db as an argument, Drizzle, Effect, leaves, other features' services; never @orpc/* |
| Errors | <f>.errors.ts | Schema.TaggedError, and ORPCError for the single mapper (ADR-0010) |
| Rules, enums, policy | <f>.rules.ts, <f>.enums.ts, <f>.policy.ts | leaves only; no I/O |
| Jobs | <f>.jobs.ts | imported only by platform/jobs/run.ts |
| Raw HTTP edge | <f>.http.ts | imported only by apps/web, through exports |
| Internals | <f>.<topic>.ts | private to the feature unless declared |
A feature grows by prefix in the same folder (billing.stripe.ts, billing.reconcile.ts), never by sub-folder. Cross-cutting code lives in packages/api/src/platform/ (rpc, effect, jobs, http, data-table). src/client.ts, src/rpc-scope.ts and src/testing/ stay where they are.
No barrel. The public surface of the package is its enumerated exports map, as ADR-0002 requires. Inside the package, files import each other directly, limited by direction rules:
- only the app router and the agent tool catalog import
*.procedures; - only the job runner imports
*.jobs; platform/**does not import features, except the app router, the job runner and two declared edges: the procedure builders readfeatures/admin/admin.rolesfor the admin guard, and the request context buildsfeatures/admin/admin.api;- a feature imports another feature's contract, enums, service, errors, policy or rules, plus internals it declares in the boundary test;
- a migrated feature's procedures import no Drizzle and no table module; queries live in its service (the lint rule covers
drizzle-orm, the schema barrel and the per-table modules; a relational query throughcontext.db.query.*needs no import and is left to review); - no import cycles. This check runs as a test, not as a lint rule: oxlint's
importplugin, whichno-cycleneeds, made the whole lint about 3.7 times slower.
No repository layer. The service uses Drizzle through the db it receives, which is context.db running as app_tenant. Isolation is guaranteed by row-level security (ADR-0009), not by a layer of code. A repository would forward calls to the same Drizzle handle, and our tests run against real PostgreSQL rather than mocked data access. The organizationId filters stay in the service as defence in depth.
The contract is a leaf, because clients import it. Web and mobile import contracts as @template-cloudflare/api/<feature>/contract. The four specifiers the mobile app already uses, @template-cloudflare/api/routers/{todo,integrations,billing,notifications}.schema, stay in exports as aliases of the canonical files, so the native app keeps building without a change. The web app imports only canonical specifiers; a test, not a lint rule, enforces that, because an oxlint override replaces a rule's options instead of merging them and would have had to repeat the whole client boundary.
Why not the alternatives
- A
modules/<feature>/index.tsbarrel as the only import surface. That is how server code reached the browser before ADR-0002. - A
<feature>.repo.tsthat alone touches the database. Under RLS it adds a forwarding layer and a second place for every query, without adding isolation. - Layer-first folders (
routers/,services/). One feature is spread across the tree and cannot be read or deleted as a unit. - Sub-folders per feature. They make paths deeper without making boundaries clearer; the role suffix already says what a file is.
Consequences
- File paths changed in one move. ADRs, docs and skills were updated editorially. Historical measurement reports and plans keep the paths of the revision they describe.
- Path-anchored tooling (
optional-features.json, theremove-*scripts,packages/db/src/system-call-sites.txt,.oxlintrc.jsonoverrides) follows the new paths, and is verified by the optional-removal runs and the system call site inventory. - New features follow the shape through the
domain-featureskill. Unmigrated features keep topic files and Promise services; when someone migrates one, it getsserviceanderrors, and its files join the two lint overrides that refuse@orpc/*in services and queries in procedures. Those overrides list the migrated features by path, so the transition list only shrinks. - Procedure tests are named
<f>.procedures.test.ts; client-visible errors of the pilots are pinned by characterization tests inpackages/api/src/testing/characterization/. - The mobile aliases stay until a mobile change moves the app to the canonical specifiers.