Skip to content

Service Conventions

Applies to @packages/service. Service is auth-agnostic (see schema.md “Service layer boundary”): it receives pre-authorized input + AuditContext, never checks permissions.

Canonical pattern — plain function per file (lazy-loadable)

Section titled “Canonical pattern — plain function per file (lazy-loadable)”

Each operation is one exported plain function per file, signature (deps, request) → Promise<Response>:

packages/service/src/subjects/subject-list.ts
import type {
ListSubjectsRequest,
ListSubjectsResponse,
} from "@packages/contract/service/subjects/list-subjects.schema"; // type-only → erased
import type { DB } from "@packages/db"; // type-only → erased
import { withTrace } from "@tigorhutasuhut/telemetry-js/cloudflare";
import { withQueryName } from "@tigorhutasuhut/telemetry-js/db";
export interface SubjectListDeps {
db: DB;
}
export function subjectList(
deps: SubjectListDeps,
req: ListSubjectsRequest, // already validated + authorized upstream
): Promise<ListSubjectsResponse> {
return withTrace(async function subjectList() {
const rows = await withQueryName("listSubjects", () =>
deps.db.query.subjects.findMany({ /* ... */ }),
);
return { /* ... */ };
});
}
  • Signature is always (deps, request) → Promise<Response>. deps first (dependency injection), request second.
  • deps lists ONLY the resources the service touches ({ db }, or { db, payment }, …). The route handler wires them from per-request context. NEVER import a db/auth/provider singleton at module scope — per-request only (createDB(env), see schema.md). DI keeps services unit-testable with fakes.
  • No auth, no permission checks inside services. requirePerm(...) runs in the route handler BEFORE the service is called (see orpc-conventions.md).
  • request is already zod-validated/coerced at the handler boundary — the service does not re-parse it. Any identity/scoping arrives as plain resolved values on request/deps, never a session/auth object to interrogate.
  • Transport-agnostic: services MUST NOT import @orpc/* or anything app/transport. On expected failures throw typed domain errors; the route handler maps them.
  • Services that may run in a transaction accept db: DB | TX.

REMOVED — the WebService mixin chain (do not bring it back)

Section titled “REMOVED — the WebService mixin chain (do not bring it back)”

The old pattern (service/web/<domain>/, ListFoo<T>(Super) mixins composed onto a Base and aggregated into one WebService class) has been fully removed — every domain is now plain functions. It defeated lazy loading: importing WebService from the @packages/service root barrel pulled the entire mixin chain (every domain) into every route’s lazy chunk, so router.ts’s lazy() saved nothing.

  • Never reintroduce a mixin/Base/WebService class. All new ops are plain (deps, request) functions in packages/service/src/<domain>/<op>.ts.
  • The root barrel (@packages/service) is type/test/FE only — never import it on a route’s hot path.

Service request/response Zod schemas are tier-1 base schemas in packages/contract/src/service/<domain>/*.schema.ts (pure zod, no oRPC) — see orpc-conventions.md. The service imports its types type-only from there:

import type { ListSubjectsRequest, ListSubjectsResponse } from
"@packages/contract/service/subjects/list-subjects.schema";

Do NOT co-locate *.schema.ts inside packages/service. (Pre-existing co-located schemas — e.g. ListUsers.schema.ts, list-catalog.schema.ts — are drift to move back to contract during migration.)

Lazy loading — keep each isolate’s module graph tiny

Section titled “Lazy loading — keep each isolate’s module graph tiny”

CF Workers cold-start budget. The route handler is behind lazy(() => import(...)) (see orpc-conventions.md); the service must ride that same code-split chunk.

  • Route handlers import the service by LEAF subpathimport { subjectList } from "@packages/service/subjects/subject-list" — NEVER the root barrel (@packages/service) or a domain barrel on the Worker hot path. export * walks the whole subtree and pulls every service eagerly.
  • packages/service/package.json exports already exposes leaf subpaths ("./*": "./src/*.ts") so direct imports resolve without the barrel.
  • Keep each service file’s imports lean: type-only db import, type-only schema import from contract, the telemetry helpers — nothing that drags in unrelated domains.
  • Barrels (src/index.ts) are for types/tests/FE convenience only — NEVER the Worker hot path.
  • Wrap the whole body in withTrace using a NAMED inner function (auto span name withTrace(async function subjectList() { ... })). Plain functions cannot use the old @traced() decorator — withTrace replaces it.

    import { withTrace } from "@tigorhutasuhut/telemetry-js/cloudflare";
  • Wrap every DB call in withQueryName(name, fn) (per call site, not per method). Parallel calls each get their own name:

    import { withQueryName } from "@tigorhutasuhut/telemetry-js/db";
    const [a, b] = await Promise.all([
    withQueryName("getFoo", () => deps.db.query.foo.findFirst({ ... })),
    withQueryName("getBar", () => deps.db.query.bar.findMany({ ... })),
    ]);

    Note: mutations inside db.transaction() are currently unwrapped — wrapping them is the target state; apply when touching those paths.

Prefer Drizzle RQB over the .select() builder:

// prefer
deps.db.query.subjects.findMany({ where: ..., with: { ... } })
// allowed: complex aggregates, metrics queries, read-before-write inside tx
tx.select().from(table).where(...)

Prefer a single query (nested RQB relations, or a CTE via the sql tag) over multiple sequential round-trips when the data is related. Use Promise.all for genuinely independent calls. Rule of thumb: single query > CTE single query > parallel queries > sequential queries. Drop a tier only when the tier above can’t express it.