# 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` | in migrated features |
| 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`. The mobile app keeps four legacy aliases (`./routers/{todo,integrations,billing,notifications}.schema`) that point at the same files. The web app uses canonical subpaths only.

**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 web import of a legacy `routers/*.schema` alias.

**Enforcement.** `[test]` `packages/api/src/exports.test.ts`: every target exists, each alias points at its canonical file, nothing exports a procedure, job or service beyond the listed server entries, every subpath web and mobile import exists, and the web app uses no alias. The alias ban is a test rather than an oxlint rule because an oxlint override replaces a rule's options instead of merging them: banning the aliases in `apps/web/src` would mean repeating the whole client boundary there.

## 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: orgProcedure
  .use(requireOrgRole("owner", "admin"))
  .input(z.object({ id: z.number() }))
  .handler(({ context, input, signal }) =>
    runProcedure(deleteTodo(context.db, context.organizationId!, input.id), todoRpcError, {
      signal,
      requestId: context.requestId,
    }),
  ),
```

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 unmigrated features 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.** In a migrated feature, the handler calls `runProcedure` once with the service program, the feature mapper, the request `signal` and the request ID. 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` and `organizationId` as arguments. 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 and where cancellation is handled. 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
source: orgProcedure.input(documentIdentityInput).handler(({ context, input, signal }) =>
  runProcedure(documentSource(context.db, context.organizationId!, input.fileId), documentRpcError, {
    signal,
    requestId: context.requestId,
  }),
),
```

**Anti-pattern.**
- `Effect.runPromise` inside a service.
- 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.** `[test]` `platform/rpc/procedure-edge.test.ts` and `platform/effect/db.test.ts` · `[lint]` D8 keeps queries out of migrated procedures · `[convention]` one `runProcedure` per handler, no `Layer` or runtime.

## 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/procedure-edge.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/api/src/platform/rpc/procedure-edge.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)

