Orpc Conventions
oRPC Conventions
Section titled “oRPC Conventions”Applies to @packages/contract (contract definition) and apps/web/src/server/
(contract implementation). These two layers are tightly coupled — changes to one
must be reflected in the other.
Contract-first
Section titled “Contract-first”Define the contract in @packages/contract first. The server in @apps/web
implements against it — never invent shapes ad-hoc in route handlers.
Two-tier contract layout
Section titled “Two-tier contract layout”packages/contract/src/ service/<domain>/*.schema.ts ← pure Zod base schemas (types only, no oRPC) orpc/<domain>/*.contract.ts ← oRPC `oc` procedures (.route() attached) orpc/contract.ts ← root aggregationTier 1 — base schemas (src/service/<domain>/*.schema.ts): pure Zod.
No oRPC imports. Exports types (ListSubjectsRequest, SubjectItem, etc.) that
the service layer (@packages/service) imports directly.
Tier 2 — oRPC procedures (src/orpc/<domain>/*.contract.ts): import and
extend/narrow the base schemas. Attach .route({ method, path }). Never
re-define a shape that already exists in tier 1.
// Good — extend the base schemaimport { ListSubjectsRequest, ListSubjectsResponse } from "../../service/subjects/list.schema";
export const list = oc .input(ListSubjectsRequest) .output(ListSubjectsResponse) .route({ method: "GET", path: "/api/v1/subjects" });Root contract aggregation
Section titled “Root contract aggregation”src/orpc/contract.ts exports a single contract object that aggregates all
procedures into a nested router shape. This single aggregation point is the
expected pattern — not a drill-down barrel.
export const contract = oc.router({ subjects: { list, create, update, archive },});No other barrel index.ts re-exports in packages/contract/.
Contract paths are fully qualified
Section titled “Contract paths are fully qualified”Every .route({ path }) value must include the full path: /api/v1/subjects,
/api/v1/subjects/{id}, etc.
Do NOT pass a prefix option to OpenAPIHandler — the paths are already
qualified in the contract. Passing a prefix would double-prefix all routes.
One-file-one-handler
Section titled “One-file-one-handler”Each route lives in its own file under apps/web/src/server/routes/<domain>/:
apps/web/src/server/routes/subjects/ list.ts create.ts update.ts archive.tsNever co-locate multiple handlers in one file.
Explicit per-route middleware chain
Section titled “Explicit per-route middleware chain”Every handler file chains middleware explicitly — no global middleware applied silently. The canonical chain:
export const listSubjectsRoute = os.subjects.list .use(configLoad) .use(withWhoami) .use(requirePerm("subjects:read")) .handler(async function listSubjectsHandler({ context, input }) { // context.db, context.whoami, context.auditCtx available here });Auth/permission/validation belong here in the entrypoint route file — the
service layer (@packages/service) never does authZ.
No any in the middleware chain
Section titled “No any in the middleware chain”Every link in the chain must be fully typed. Use separate builder instances per context level:
os(base) — afterimplement(contract).$context<InitialContext>()- After
configLoad—InitialContext & { db: DB }narrowed - After
withWhoami—... & { whoami: WhoAmI; auditCtx: AuditCtx }narrowed
Never cast context to any to satisfy a middleware signature.
Named handler functions (not arrow functions)
Section titled “Named handler functions (not arrow functions)”// Good.handler(async function listSubjectsHandler({ context, input }) { ... })
// Bad.handler(async ({ context, input }) => { ... })Named functions produce better OTel span names and stack traces.
lazy() router index
Section titled “lazy() router index”apps/web/src/server/router.ts uses lazy() for every route — no imports that
instantiate handlers at module load time:
import { lazy } from "@orpc/server";
export const router = os.router({ subjects: { list: lazy(() => import("./routes/subjects/list").then(m => m.listSubjectsRoute)), create: lazy(() => import("./routes/subjects/create").then(m => m.createSubjectRoute)), ... },});Service imports — LEAF subpath only (the lazy() payoff)
Section titled “Service imports — LEAF subpath only (the lazy() payoff)”lazy() only shrinks an isolate’s module graph if the lazily-imported route file
keeps its own imports tiny. The route handler MUST import its service by leaf
subpath so the service rides that route’s code-split chunk:
// Good — leaf subpath, service rides this route's chunkimport { subjectList } from "@packages/service/subjects/subject-list";-
NEVER import the
@packages/serviceroot barrel in a route handler. Importing the barrel pulls the entire service graph (every domain) into the route’s chunk —lazy()then saves nothing. (The formerWebServicemixin class was the worst offender and has been fully removed — seeservice-conventions.md; do not reintroduce it.) -
The handler stays thin: wire
depsfrom per-request context, call the service:.handler(async function listSubjectsHandler({ context, input }) {return subjectList({ db: (context as AuthContext).db }, input);});
No-barrel imports
Section titled “No-barrel imports”Do not create index.ts re-export barrels inside packages/contract/src/.
Import from the specific file. The root contract.ts aggregation is the sole
exception (and it is an aggregation, not a re-export chain).
OTEL token is server-only
Section titled “OTEL token is server-only”The OTEL_EXPORTER_OTLP_HEADERS CF secret (which carries the SigNoz token)
is injected server-side in the /v1/traces proxy handler only. It must never
appear in the client bundle. The browser telemetry SDK posts to /v1/traces
(same-origin) and the proxy forwards with the secret header attached.