# Backend features have a fixed file shape

The repository's architectural decision, rationale and consequences.

# Backend features have a fixed file shape

**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 [#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](0010-procedures-run-one-effect-program.md)) |
| 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](0002-no-barrels-on-server-capable-modules.md) 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 read `features/admin/admin.roles` for the admin guard, and the request context builds `features/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 through `context.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 `import` plugin, which `no-cycle` needs, 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](0009-row-level-security.md)), 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 [#why-not-the-alternatives]

- **A `modules/<feature>/index.ts` barrel as the only import surface.** That is how server code reached the browser before ADR-0002.
- **A `<feature>.repo.ts` that 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 [#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`, the `remove-*` scripts, `packages/db/src/system-call-sites.txt`, `.oxlintrc.json` overrides) 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-feature` skill. Unmigrated features keep topic files and Promise services; when someone migrates one, it gets `service` and `errors`, 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 in `packages/api/src/testing/characterization/`.
- The mobile aliases stay until a mobile change moves the app to the canonical specifiers.

