# Backend conventions

The rules for code in packages/api, each with its reason, an example, the anti-pattern and what enforces it.

These rules apply to `packages/api`. Each one has the same shape: **rule**, **why**, **example**, **anti-pattern** and **enforcement**. The enforcement level is one of `[type]`, `[lint]`, `[test]`, `[build]`, `[deploy]` or `[convention]`. A `[convention]` rule depends on review alone, so it is the first candidate for a stronger check. The reasons behind the larger rules are recorded in [ADR-0011](/docs/adr/0011-backend-feature-shape) (feature shape) and [ADR-0010](/docs/adr/0010-procedures-run-one-effect-program) (Effect in procedures).

## One folder per feature, one name per role [#one-folder-per-feature-one-name-per-role]

**Rule.** A feature lives in `packages/api/src/features/<feature>/`. Its files are named `<feature>.<role>.ts`:

| Role                 | File                                                                | Required                                 |
| -------------------- | ------------------------------------------------------------------- | ---------------------------------------- |
| Contract             | `<f>.contract.ts`                                                   | when the feature has procedures          |
| Procedures           | `<f>.procedures.ts`                                                 | when the feature is exposed through oRPC |
| Service              | `<f>.service.ts`                                                    | when the feature has procedures          |
| Errors               | `<f>.errors.ts`                                                     | when the feature has domain failures     |
| Rules, enums, policy | `<f>.rules.ts`, `<f>.enums.ts`, `<f>.policy.ts`                     | optional, no I/O                         |
| Jobs                 | `<f>.jobs.ts`                                                       | when the feature runs in the cron tick   |
| Raw HTTP edge        | `<f>.http.ts`                                                       | when a Worker route sits outside oRPC    |
| Internals            | `<f>.<topic>.ts`                                                    | as needed                                |
| Tests                | `<f>.<topic>.test.ts`; procedure tests are `<f>.procedures.test.ts` | as needed                                |

Code that belongs to no feature lives in `packages/api/src/platform/` (`rpc`, `effect`, `jobs`, `http`, `data-table`).

**Why.** The name of a file tells you what it may import and who may import it. A feature can be read, moved or deleted as one folder.

**Example.**

```text
features/documents/
  documents.contract.ts      documents.procedures.ts   documents.service.ts
  documents.errors.ts        documents.policy.ts       documents.jobs.ts
  documents.queue.ts         documents.chunk.ts        documents.lifecycle-plugin.ts
```

**Anti-pattern.** A sub-folder per layer (`documents/services/`), a second name for the same role (`router.ts` next to `procedures.ts`), or queries written inside the procedures file of a migrated feature.

**Enforcement.** `[test]` `scripts/feature-boundaries.test.mjs` (in `pnpm test`) · `[lint]` direction overrides in `.oxlintrc.json`.

## The contract is a leaf [#the-contract-is-a-leaf]

**Rule.** `<f>.contract.ts` starts with the `LEAF MODULE` header. It imports only `zod`, other leaves (`db/schema/*.enums`, `*.enums.ts`, other contracts) and `import type` from anywhere.

**Why.** Web and mobile import the contract. One value import of a server module puts server code in a browser bundle or an app binary ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)).

**Example.**

```ts
// LEAF MODULE: imported by web and mobile. Only zod, leaves and `import type`.
import { PRIORITIES } from "@template-cloudflare/db/schema/todo.enums";
import { z } from "zod";

export const todoCreateInput = z.object({ text: z.string().min(1), priority: z.enum(PRIORITIES) });
```

**Anti-pattern.** Importing a constant from a Drizzle table module, or from a service, "because it is already there".

**Enforcement.** `[test]` `scripts/leaf-modules.test.mjs`, which also fails when a `features/*/*.contract.ts` lacks the header · `[lint]` `packages/config/oxlint-client-boundary.json` · `[build]` `assert-client-bundle-clean.mjs`.

## No barrel; the public surface is `exports` [#no-barrel-the-public-surface-is-exports]

**Rule.** No `index.ts` re-exports a feature. Other packages and apps import an enumerated subpath of `@template-cloudflare/api`. Canonical subpaths are `./<feature>/<role>`, for example `./todo/contract` and `./billing/http`. Web and mobile use the canonical subpaths only; the earlier `./routers/*.schema` aliases were removed once the mobile app moved to them.

**Why.** A barrel is how server code reached the browser. An enumerated map makes every new cross-package import a reviewed decision.

**Example.** `import { todoListInput } from "@template-cloudflare/api/todo/contract";`

**Anti-pattern.** A wildcard `"./*"` export, or a `./routers/*` subpath brought back.

**Enforcement.** `[test]` `packages/api/src/exports.test.ts`: every target exists, no `./routers/*` subpath comes back, nothing exports a procedure, job or service beyond the listed server entries, and every subpath web and mobile import exists. A removed subpath also fails `tsc` in the app that imports it.

## Imports flow one way [#imports-flow-one-way]

**Rule.**

- Only `platform/rpc/app-router.ts` and the agent tool catalog import `*.procedures`.
- Only `platform/jobs/run.ts` imports `*.jobs`.
- `platform/**` does not import features, except those two files and two declared edges: `platform/rpc/procedures.ts` reads `features/admin/admin.roles` (the admin guard) and `platform/rpc/context.ts` builds `features/admin/admin.api` (the `adminApi` capability).
- A feature imports another feature's contract, enums, service, errors, policy or rules. Any other cross-feature import is declared in the boundary test.
- In a migrated feature, `*.procedures.ts` imports no `drizzle-orm` and no table module (enums are fine): queries live in the service. The lint rule refuses value imports of `drizzle-orm`, the schema barrel `@template-cloudflare/db/schema` and the per-table modules `@template-cloudflare/db/schema/<table>` (enum leaves and `import type` pass). A relational query through `context.db.query.*` needs no import, so the rule cannot see it; that one is left to review.
- There are no import cycles in `packages/api/src` and `packages/db/src`.

**Why.** Each rule keeps a dependency the reader can predict. A domain module that imports a router or a job is the first sign the layering has rotted.

**Example.** `todo.service.ts` imports `enqueueUserWebhooks` from `webhooks.events.ts`, a declared edge, to queue deliveries inside its transaction.

**Anti-pattern.** A service that imports another feature's queue or lease module to "reuse a query".

**Enforcement.** `[lint]` `no-restricted-imports` overrides in `.oxlintrc.json`: D3 (procedures), D4 (jobs), D5 (platform) and D8 (queries in migrated procedures, listed per feature) · `[test]` `scripts/feature-boundaries.test.mjs` (D6; a declared edge that disappears also fails, so the list only shrinks) · `[test]` `scripts/import-cycles.test.mjs` (D7). The cycle check runs in `pnpm test`, not in `pnpm lint`, because oxlint's `import` plugin made the whole lint about 3.7 times slower.

## Tenant data goes through `context.db` [#tenant-data-goes-through-contextdb]

**Rule.** A procedure, tool or gateway reads and writes tenant data through `context.db`, which runs as `app_tenant` for the session's User and Active Organization. The `organizationId` filter stays in every query. Crossing tenants is possible only through `asSystem(db, reason, fn)` with a listed `SystemReason`, and the work happens inside `fn`.

**Why.** Row-level security enforces isolation in the database; the filters are defence in depth ([ADR-0009](/docs/adr/0009-row-level-security)).

**Example.**

```ts
export const listTodos = Effect.fn("todo.list")(function* (
  db: Database,
  organizationId: string,
  input: TodoListInput,
) {
  return yield* query(() => db.select().from(todo).where(eq(todo.organizationId, organizationId)));
});
```

**Anti-pattern.** Opening a pool with `createDb` in a feature. Returning the handle from `asSystem`. Network I/O inside `withTenant`.

**Enforcement.** `[lint]` the pool-import restriction and `check-system-call-sites.mjs` · `[test]` RLS isolation, catalog and matrix suites · `[convention]` no network I/O inside `withTenant`.

## Org roles are checked only where a product rule needs them [#org-roles-are-checked-only-where-a-product-rule-needs-them]

**Rule.** Membership in the Active Organization is the default authorization for Organization data (`orgProcedure`). When a product rule limits an operation to some Org roles, chain `requireOrgRole` from `platform/rpc/require-org-role.ts` after `orgProcedure`. Do not compare role strings inside a service.

**Why.** `orgProcedure` already reads the Member's role in its membership statement, so the check costs no extra round trip, and a missing or unknown role fails closed with `FORBIDDEN`. Row-level security checks membership, not roles, so the role check belongs in the procedure ([ADR-0009](/docs/adr/0009-row-level-security)).

**Example.**

```ts
remove: todoProcedure
  .use(requireOrgRole("owner", "admin"))
  .input(z.object({ id: z.number() }))
  .handler(({ db, organizationId }, input) => deleteTodo(db, organizationId, input.id)),
```

Roles are not hierarchical: `requireOrgRole("admin")` refuses an `owner`, so list every role that may act. Chain it after `orgProcedure`, which reads the role: the middleware requires `organizationRole` in its input context, so `protectedProcedure.use(requireOrgRole(...))` is a type error. The middleware exists and is tested (owner, admin and `admin,member` pass; member and unknown roles are refused), but no procedure applies it yet: today every Member may do everything in their Organization.

**Anti-pattern.** Reading `member.role` again in a service, or treating the platform role (`user`/`admin`) as an Org role.

**Enforcement.** `[test]` `platform/rpc/require-org-role.test.ts` · `[convention]` where it is applied.

## Domain failures are tagged errors, mapped once [#domain-failures-are-tagged-errors-mapped-once]

**Rule.** A failure a caller can act on is a `Schema.TaggedError` in `<f>.errors.ts`. The feature exports one mapper, a `switch` on `_tag` ending in `satisfies never`, that turns each failure into an `ORPCError` with an `APP_ERRORS` code. Services never import `@orpc/*`.

**Why.** The failures of an operation become a closed type, the mapper is exhaustive by construction, and the client keeps seeing the stable codes it localizes ([error handling](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/error-handling.md)).

**Example.**

```ts
// abridged from packages/api/src/features/documents/documents.errors.ts
export class DocumentNotFound extends Schema.TaggedError<DocumentNotFound>()(
  "DocumentNotFound",
  {},
) {}
export type DocumentError = DocumentNotFound | DocumentSourceChanged;

export function documentRpcError(error: DocumentError) {
  switch (error._tag) {
    case "DocumentNotFound":
      return new ORPCError("NOT_FOUND");
    case "DocumentSourceChanged":
      return new ORPCError("CONFLICT", { message: "The original document changed" });
    default:
      return error satisfies never;
  }
}
```

Keep the code and message the client saw before a migration. `documents.errors.ts` repeats the messages the service used to throw; `todo.errors.ts` passes `APP_ERRORS.NOT_FOUND.message`, because the handler used to throw `errors.NOT_FOUND()`. A `switch` over a union with one member ends in `return error._tag satisfies never`.

**Anti-pattern.** `throw new ORPCError("CONFLICT")` inside a service. A string error. A new code that is missing from a locale.

**Enforcement.** `[type]` the exhaustive mapper · `[lint]` D2 in `.oxlintrc.json`: no `@orpc/*` in the services, rules and policies of migrated features, listed by path, with the features still to migrate named in the override's comment · `[test]` characterization tests in `packages/api/src/testing/characterization/`, locale tests.

## A procedure runs one program [#a-procedure-runs-one-program]

**Rule.** A feature builds its procedures with one `effectProcedure(orgProcedure, <feature>RpcError)` from `platform/rpc/effect-procedure.ts`. Each handler returns the service program; the builder's `handler` is the only place that runs it with the feature mapper, the request `signal`, the request ID and the request's tracing. There are two tiers ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), amendment):

- **Tier 1 (every feature).** The service is a Promise that throws the feature's tagged errors. The feature passes its guard as the builder's third argument, `effectProcedure(orgProcedure, importRpcError, isImportError)`, and the builder runs the Promise through `runService`: a recognised rejection is mapped once, anything else is rethrown as a defect (500). `imports`, `proposals`, `notifications`, `flags`, `tenancy`, `assistant`, `uploads` and the billing procedures are Tier 1.
- **Tier 2 (when the program composes).** The service is an `Effect.fn` because it needs a timeout, `idempotentRead`, `Effect.all`, a race against the caller's abort or interruption that drains a transaction. The handler returns the Effect and the builder runs it through `runProcedure`; the program's error type must fit the mapper's union, or `tsc` fails. `todo`, `documents`, `diagnostics` and the billing reconciliation are Tier 2. Promotion is local: return an Effect instead of a Promise; the errors file and the procedures do not change.

A handler may be `async` to read configuration before calling the service; what it returns is still the one program. The `signal` is honoured only before the program starts: an already aborted request is refused, but once the program runs, a client that goes away does not interrupt it, so the write and its Audit Event commit and the response goes unread. A service is an `Effect.fn` that receives `db`, `organizationId` and, when it reaches the bucket, the request's `storage` or `direct` handle as arguments; it never builds a dependency itself or as a default parameter. Database work uses `query` and `transaction` from `platform/effect/db.ts`. Both drain: if the program itself is interrupted (a timeout or deadline inside it), the statement or transaction settles before the program ends. `query(run)` hands `run` that interruption signal, not the client's, which a service may pass to an external read (the documents pilot passes it to the storage `HEAD`).

**Why.** There is one place where typed failures become transport errors, where cancellation is handled and where request capabilities are threaded, instead of one copy per handler. An audited procedure runs inside the transaction `withAudit` opens around the handler, so a handler that rejected because the client left would roll back the write and its audit; finishing the started program prevents that (`todo.abort.test.ts`, `documents.abort.test.ts`). A running transaction is drained instead of abandoned ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), [ADR-0005](/docs/adr/0005-effect-boundary)).

**Example.**

```ts
const documentsProcedure = effectProcedure(orgProcedure, documentRpcError);

export const documentsRouter = {
  source: documentsProcedure
    .input(documentIdentityInput)
    .handler(({ db, organizationId }, input) => documentSource(db, organizationId, input.fileId)),
  confirmText: documentsProcedure
    .input(documentIdentityInput)
    .handler(({ db, organizationId, storage }, input) =>
      confirmDocumentText(db, organizationId, input.fileId, storage),
    ),
};
```

The handler's context is the chain's context as oRPC narrowed it: after `orgProcedure`, `organizationId` is a `string` and `session` is non-null, so there is no `!`. `userId` and `signal` are spelled out next to it.

**Anti-pattern.**

- `context.organizationId!`, or a hand-written `runProcedure(..., { signal, requestId })` in a handler.
- `createStorage()` or `createDirectStorage()` inside a service or as a default parameter.
- `Effect.tryPromise` around `db.transaction` only to `catchTag` one failure into a result; a Promise with `try/catch` says the same with less (Tier 1).
- `Effect.runPromise` inside a service, or a Promise facade called from inside another program (two runtimes per call).
- A second `run*` in the same call.
- A `Layer`, `Context.Service` or `ManagedRuntime` that holds a pool, whether at module scope or per request.
- Handling interruption by hand.

**Enforcement.** `[type]` the program's error type against the mapper (Tier 2) · `[test]` `platform/rpc/procedure-edge.test.ts` (`runProcedure`, `runService`) and `platform/effect/db.test.ts` · `[lint]` D8 keeps queries out of migrated procedures · `[convention]` one program per handler, no `Layer` or runtime; `grep "Effect.runPromise("` outside `platform/effect` and `platform/rpc/procedure-edge.ts` is empty.

## The Context carries capabilities, not options [#the-context-carries-capabilities-not-options]

**Rule.** `Context` (`platform/rpc/context.ts`) is what every procedure, tool and gateway may count on: `db`, `systemDb`, `session`, `authoritativeSession`, `rpcScopeHeader`, `ip`, `userAgent`, `requestOrigin`, `requestId`, `storage` and `direct` are always present. Only `createContext`, `createMcpContext` and the test adapter `src/testing/make-context.ts` assemble one. A capability is optional only when a real request can lack it, and the type says when: `tracing` (outside workerd), `waitUntil` (inside a tool transaction), `adminApi` (MCP-originated contexts), `organizationId`/`organizationRole` (before `orgProcedure`) and `queuedAudits` (outside an open transaction).

**Why.** A fallback such as `context.systemDb ?? context.db` or `authoritativeSession?.()` is a second code path that only tests exercised, and a `throw INTERNAL_SERVER_ERROR` for a missing capability is a runtime check of what the type should have said. Storage built once per request shares the request's abort signal and is handed down like `db` ([ADR-0010](/docs/adr/0010-procedures-run-one-effect-program), amendment).

**Example.** A test context is `makeContext({ db, user, organizationId, storage: memoryStorage().files })`; a tool scope is a spread of the Context with `db` and `organizationId` replaced.

**Anti-pattern.** A test that writes `{ db, session }` and calls it a `Context`. A `vi.mock("@template-cloudflare/storage")` where `makeContext({ storage })` or `openStorage` in the job options would do.

**Enforcement.** `[type]` the `Context` type · `[convention]` one factory per entry point.

## External I/O has a deadline; only reads retry [#external-io-has-a-deadline-only-reads-retry]

**Rule.** A service that calls storage or a provider sets a timeout. It retries only idempotent reads, with `idempotentRead`. A PUT, a provider POST, a webhook delivery or an email send makes one attempt.

**Why.** A repeated write can duplicate a side effect. The outbox and lease policy decides when to try again.

**Example.** `idempotentRead` wraps the RevenueCat subscriber `GET`, Expo `getReceipts` and the import source reads. The storage `HEAD` that confirms Document Text (`documents.service.ts`) has a 10 s timeout, `retries: 0` and the program's abort signal, and runs before any lock or transaction; the documents pilot kept that behavior instead of adding a retry. The signed `PUT` the browser performs is never retried by the server.

**Anti-pattern.** `Effect.retry` around a POST.

**Enforcement.** `[convention]` · `[test]` retry tests with `TestClock`.

## Configuration is declared once [#configuration-is-declared-once]

**Rule.** Every environment variable is declared in `packages/env` and read through it, including runtime switches such as `DB_TENANT_PIPELINE` and `TRUSTED_PROXIES`.

**Why.** The schema is the list of what the deploy must provide. A missing or malformed server secret fails validation when the server environment loads. The runtime switches in `@template-cloudflare/env/runtime-flags` are read on every call instead: an invalid value never becomes a different setting, but falls back to the safe behavior and is logged once per isolate ([runtime switches](/docs/development/environments#runtime-switches)).

**Anti-pattern.** `process.env.SOMETHING` in a feature.

**Enforcement.** `[lint]` `node/no-process-env` for `packages/**` in `.oxlintrc.json` and for `apps/web/src` in its nested config. `NODE_ENV` and `EXPO_OS` are allowed because bundlers replace them. Named exceptions: `packages/env/**`, tests and `packages/api/src/testing/**`, CLIs (`packages/*/scripts/**`, `project-setup-cli`, `db/src/seed.ts`) and the test-only provider switch in `assistant.provider.ts`. Runtime switches go through `@template-cloudflare/env/runtime-flags`, which reads them on every call.

## Logs separate expected from unexpected [#logs-separate-expected-from-unexpected]

**Rule.** An expected failure (a mapped 4xx) is not logged as an error; the server error handler writes it at debug level. An unexpected failure (a defect, a 500) is logged once by the server error handler, with the request ID that the response also returns in `x-request-id`. Logs carry identifiers, never credentials, prompts or private payloads.

**Why.** Error logs then mean something needs attention, and a user can quote one ID that leads to the log line.

**Anti-pattern.** `console.error` in a service for a `NOT_FOUND`.

**Enforcement.** `[test]` the server error handler tests · `[convention]` for payload content.

## Lists page one way per endpoint [#lists-page-one-way-per-endpoint]

**Rule.** Use the pagination rule in [oRPC conventions](/docs/features/api#pagination). Use a cursor for lists that grow without bound. Use offset only where the interface needs a total or page numbers. Every query has a total order with an `id` tie-breaker.

**Enforcement.** `[test]` list tests · `[convention]` the choice of strategy.

## Key files [#key-files]

- [`packages/api/src/platform/rpc/procedures.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/procedures.ts)
- [`packages/api/src/platform/rpc/effect-procedure.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/effect-procedure.ts)
- [`packages/api/src/platform/rpc/procedure-edge.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/procedure-edge.ts)
- [`packages/api/src/platform/rpc/context.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/context.ts)
- [`packages/api/src/platform/effect/db.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/effect/db.ts)
- [`packages/api/src/features/todo/todo.service.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/todo/todo.service.ts)
- [`packages/api/src/features/documents/documents.errors.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/features/documents/documents.errors.ts)
- [`.oxlintrc.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.oxlintrc.json)

