Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Backend features have a fixed file shape

The repository's architectural decision, rationale and consequences.

Markdown

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:

RoleFileImports
Contract<f>.contract.ts, a LEAF MODULEzod, other leaves, db/schema/*.enums, any import type
Procedures<f>.procedures.tsits contract, service and errors, platform/rpc/*
Service<f>.service.tsdb as an argument, Drizzle, Effect, leaves, other features' services; never @orpc/*
Errors<f>.errors.tsSchema.TaggedError, and ORPCError for the single mapper (ADR-0010)
Rules, enums, policy<f>.rules.ts, <f>.enums.ts, <f>.policy.tsleaves only; no I/O
Jobs<f>.jobs.tsimported only by platform/jobs/run.ts
Raw HTTP edge<f>.http.tsimported only by apps/web, through exports
Internals<f>.<topic>.tsprivate 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 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), 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.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

  • 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.
Edit on GitHubDocumentation is in English.