Service Conventions
Service Layer Conventions
Section titled “Service Layer 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>:
import type { ListSubjectsRequest, ListSubjectsResponse,} from "@packages/contract/service/subjects/list-subjects.schema"; // type-only → erasedimport type { DB } from "@packages/db"; // type-only → erasedimport { 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>.depsfirst (dependency injection),requestsecond. depslists 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), seeschema.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 (seeorpc-conventions.md). requestis already zod-validated/coerced at the handler boundary — the service does not re-parse it. Any identity/scoping arrives as plain resolved values onrequest/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/WebServiceclass. All new ops are plain(deps, request)functions inpackages/service/src/<domain>/<op>.ts. - The root barrel (
@packages/service) is type/test/FE only — never import it on a route’s hot path.
Schemas live in @packages/contract
Section titled “Schemas live in @packages/contract”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 subpath —
import { 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.jsonexportsalready 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.
Telemetry
Section titled “Telemetry”-
Wrap the whole body in
withTraceusing a NAMED inner function (auto span namewithTrace(async function subjectList() { ... })). Plain functions cannot use the old@traced()decorator —withTracereplaces 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.
Query style
Section titled “Query style”Prefer Drizzle RQB over the .select() builder:
// preferdeps.db.query.subjects.findMany({ where: ..., with: { ... } })
// allowed: complex aggregates, metrics queries, read-before-write inside txtx.select().from(table).where(...)Query shape — minimize round trips
Section titled “Query shape — minimize round trips”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.