Skip to content

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.

Define the contract in @packages/contract first. The server in @apps/web implements against it — never invent shapes ad-hoc in route handlers.

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 aggregation

Tier 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 schema
import { ListSubjectsRequest, ListSubjectsResponse } from
"../../service/subjects/list.schema";
export const list = oc
.input(ListSubjectsRequest)
.output(ListSubjectsResponse)
.route({ method: "GET", path: "/api/v1/subjects" });

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/.

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.

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.ts

Never co-locate multiple handlers in one file.

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.

Every link in the chain must be fully typed. Use separate builder instances per context level:

  • os (base) — after implement(contract).$context<InitialContext>()
  • After configLoadInitialContext & { 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.

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 chunk
import { subjectList } from "@packages/service/subjects/subject-list";
  • NEVER import the @packages/service root 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 former WebService mixin class was the worst offender and has been fully removed — see service-conventions.md; do not reintroduce it.)

  • The handler stays thin: wire deps from per-request context, call the service:

    .handler(async function listSubjectsHandler({ context, input }) {
    return subjectList({ db: (context as AuthContext).db }, input);
    });

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).

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.