Hackorda Docs

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

  1. A route.ts imports from @/lib/<domain> and @/db/schema. It does NOT import @/db directly. The route's job is to translate between HTTP and the service; SQL belongs in the service.
  2. Services own transactions. Use await db.transaction(async tx => …) inside the service. Pass tx to sub-helpers when you need to share it; never call db.transaction() from a route or a controller.
  3. Zod schemas live next to the service that owns them. Not in a global validation/ dump. The route calls inputSchema.safeParse(raw) and forwards the parsed object.
  4. Errors are typed, not stringly. The service throws a custom error class (e.g. PayoutServiceError) with a status + payload; the route maps it to NextResponse. Generic Error bubbles up as a 500.
  5. 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:

FileRoleTouches @/db?
index.tsBarrel — public surface (services + schemas + error class)no
types.tsZod input schemas, output interfaces, service-error classno
eligibility.tsPure filtering / validation. No I/O, easy to unit-test.no
settlement.tsDB reads (loadCandidates*) + the write-side transaction (settlePayouts). The only file with import { db } from "@/db".yes
notifications.tsSide-effect — fire-and-forget notification fan-out. Calls the existing createNotificationsBulk helper.indirectly
run-batch.tsService — period-based orchestrator. Calls queries → eligibility → settlement → notifications.indirectly
batch-pay.tsService — 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>.ts until 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 tx and 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.ts is a module of query/mutation helpers, not a class.
  • No "lib/db" wrapper. Import db directly from @/db in settlement.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.

On this page