Service Layer
The 3-tier layout we use for every backend domain in this repo. The first
example is src/lib/payouts/ — when extracting a new
domain, mirror its shape.
The layers
route.ts ┐
auth │ ≤120 lines, no business logic, no SQL
parse + validate │ parse → authorize → call service → format response
idempotency │
call service │
format response ┘
│
▼
service ┐
business logic │ pure orchestration: validate → query → mutate → notify
transactions │ touches db.* freely, returns plain objects
zod inputs │ throws PayoutServiceError-style errors with status codes
side effects │
│ ┘
▼
db/schema ┐ drizzle tables + enum constants only; no functions
┘ one domain file per concern (split lives in src/db/schema/)Rules
- A
route.tsimports from@/lib/<domain>and@/db/schema. It does NOT import@/dbdirectly. The route's job is to translate between HTTP and the service; SQL belongs in the service. - Services own transactions. Use
await db.transaction(async tx => …)inside the service. Passtxto sub-helpers when you need to share it; never calldb.transaction()from a route or a controller. - Zod schemas live next to the service that owns them. Not in a global
validation/dump. The route callsinputSchema.safeParse(raw)and forwards the parsed object. - Errors are typed, not stringly. The service throws a custom error
class (e.g.
PayoutServiceError) with a status + payload; the route maps it toNextResponse. GenericErrorbubbles up as a 500. - One file per concern. A domain folder typically has six files, each ≤200 lines. Skip files you don't need (no notifications? drop the file).
The shape — folder anatomy
Using lib/payouts/ as the canonical example:
| File | Role | Touches @/db? |
|---|---|---|
index.ts | Barrel — public surface (services + schemas + error class) | no |
types.ts | Zod input schemas, output interfaces, service-error class | no |
eligibility.ts | Pure filtering / validation. No I/O, easy to unit-test. | no |
settlement.ts | DB reads (loadCandidates*) + the write-side transaction (settlePayouts). The only file with import { db } from "@/db". | yes |
notifications.ts | Side-effect — fire-and-forget notification fan-out. Calls the existing createNotificationsBulk helper. | indirectly |
run-batch.ts | Service — period-based orchestrator. Calls queries → eligibility → settlement → notifications. | indirectly |
batch-pay.ts | Service — explicit-id orchestrator. Same shape, different inputs. | indirectly |
Two routes (run-batch, batch-pay) call into the services. Each route is ~100 lines: auth, parse, idempotency, service call, response.
Route template
// src/app/api/<area>/<action>/route.ts
import { NextRequest, NextResponse } from "next/server";
import { resolveCurrentDbUser } from "@/lib/auth/test-cycle-auth";
import { isSuperAdminRoleId } from "@/lib/auth/roles";
import {
beginIdempotentRequest,
failIdempotentRequest,
finishIdempotentRequest,
normalizeIdempotencyKey,
} from "@/lib/idempotency";
import {
YourServiceError,
yourService,
yourInputSchema,
} from "@/lib/<domain>";
const IDEMPOTENCY_SCOPE = "<domain>:<action>";
export async function POST(request: NextRequest) {
// 1. Auth
const user = await resolveCurrentDbUser();
if (!user) return NextResponse.json({ error: "Authentication required" }, { status: 401 });
if (!isSuperAdminRoleId(user.roleId)) {
return NextResponse.json({ error: "<action> requires super-admin" }, { status: 403 });
}
// 2. Parse + validate
const raw = await request.json().catch(() => null);
const parsed = yourInputSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request body" },
{ status: 400 },
);
}
// 3. Idempotency (omit for read-only endpoints)
const idempotencyKey = normalizeIdempotencyKey(request.headers.get("Idempotency-Key"));
if (!idempotencyKey) {
return NextResponse.json({ error: "Idempotency-Key header required" }, { status: 400 });
}
const idem = await beginIdempotentRequest(idempotencyKey, IDEMPOTENCY_SCOPE);
if (idem.kind === "replay") return idem.response;
if (idem.kind === "in_progress") {
return NextResponse.json({ error: "Already in progress" }, { status: 409 });
}
// 4. Service call
try {
const result = await yourService({ input: parsed.data, actorId: user.id });
await finishIdempotentRequest(
idempotencyKey,
result as unknown as Record<string, unknown>,
200,
);
return NextResponse.json(result);
} catch (err) {
await failIdempotentRequest(idempotencyKey);
if (err instanceof YourServiceError) {
return NextResponse.json(err.payload, { status: err.status });
}
console.error(`[<action>] failed user=${user.email}:`, err);
return NextResponse.json({ error: "Operation failed. Safe to retry." }, { status: 500 });
}
}Service template
// src/lib/<domain>/<action>.ts
import { yourQuery, yourTransaction } from "./<settlement>";
import { yourPureFilter } from "./<eligibility>";
import { YourServiceError, type YourInput, type YourResult } from "./types";
export interface YourServiceOptions {
input: YourInput;
actorId: string;
}
export async function yourService(opts: YourServiceOptions): Promise<YourResult> {
const { input, actorId } = opts;
const candidates = await yourQuery(input);
const filtered = yourPureFilter(candidates, actorId);
if (filtered.someBlockedSet.length > 0) {
throw new YourServiceError(403, "<reason>", { blocked: filtered.someBlockedSet });
}
if (filtered.eligible.length === 0) {
throw new YourServiceError(400, "Nothing to do");
}
const { entityId } = await yourTransaction({ ...filtered, actorId, ... });
void someSideEffect(entityId); // fire-and-forget — must not block the response
return { entityId, ...filtered.summary };
}What goes in eligibility.ts (the pure file)
The pure file is the one you can unit-test without a database. Pull anything into it that:
- Takes plain data in and returns plain data out
- Doesn't touch
db,fetch,console.log, or any global state - Has clear edge cases worth covering in tests
Payouts pulls out: self-dealing detection, verification-gate evaluation, per-row amount/currency validation, total aggregation. None of those need a database to test — and that's the point.
What goes in settlement.ts (the I/O file)
Everything that touches db.select(…) or db.insert(…) or db.update(…).
The transaction wrapper lives here too. Services compose this file's exports
but never call db.* themselves.
When the service file is short (≤200 lines), inlining the query is fine. When
the same query has 3+ call sites, hoist it to settlement.ts so the column
projection is shared.
When to skip the split
- The service is <100 lines and has one query. Keep it as a single
lib/<domain>.tsuntil it grows. Premature subfolders cost agents context. - No side effects beyond the transaction. Skip
notifications.ts. - No pure logic worth isolating. Skip
eligibility.ts.
The rule is "one concern per file, not one file per pattern."
What lib/payouts/ deliberately leaves out
- No DI container. Pass
txand dependencies as function arguments. TypeScript's structural types cover what DI normally buys you. - No abstract
BaseService. Each service is a free function. Inheritance hierarchies in services are where this kind of refactor goes to die. - No repository pattern. Drizzle already is one.
settlement.tsis a module of query/mutation helpers, not a class. - No "lib/db" wrapper. Import
dbdirectly from@/dbinsettlement.ts.
Bringing in new patterns
If you apply a structural choice that isn't covered here AND you've now
applied it in 2+ refactors, capture an event in
~/workspace/ops-vault/kos/events/YYYY-MM-DD-hackorda-<slug>.md. After 2-3
events of the same shape it gets promoted to a rule in
~/workspace/ops-vault/kos/playbooks/code-quality.md. See that playbook
for the lifecycle.