Layering rules

The rule

HTTP            app/api/campaigns/[id]/broadcast/route.ts
  │  authenticate → validate → delegate → serialise
  ▼
Service         lib/services/phone-number-calling-service.ts
  │  decide: eligibility, selection, caps, state transitions
  ▼
Repository      lib/repositories/call-repository.ts
  │  the only layer that imports Prisma
  ▼
PostgreSQL

Never put business logic in a route handler, and never import Prisma outside lib/repositories. Two exceptions exist in the current codebase and are deliberate:

  • Webhook receivers upsert the raw WebhookEvent row directly, because that write is part of accepting the request rather than part of the domain.

  • A small number of routes read a single lookup row (for example resolving a campaign’s subscriptionId before delegating). New code should prefer a repository method.

What belongs in each layer

Layer Belongs here

Route handler

  • resolveSubscription() or a Supabase getUser() check

  • Zod parsing of body and query parameters

  • Mapping domain results to snake_case JSON

  • HTTP status selection and the { error: string } envelope

  • Nothing that would need to be re-implemented for a non-HTTP caller

Service

  • Eligibility and guard evaluation (DNC, call windows, caps, suppression)

  • Selection algorithms (which phone number, which agent, which variation)

  • State-machine transitions and next-attempt computation

  • Calls to third-party clients (Retell, Resend, HubSpot, SQS)

  • Emitting Inngest events

Repository

  • Every prisma.* call

  • where builders shared between a list query and its count query

  • Transactions, including the atomic "select and lease" patterns

  • Aggregations (groupBy, count) used for stats

Canonical route handler

import { NextResponse } from "next/server"
import { resolveSubscription } from "@/lib/auth/subscription"

export async function GET(request: Request) {
  const result = await resolveSubscription()
  if (!result.success) {
    return NextResponse.json({ error: result.error }, { status: result.status })
  }
  const { subscriptionId } = result.context

  try {
    // delegate to a service or repository, then map camelCase → snake_case
    return NextResponse.json(payload)
  } catch (error) {
    console.error("Operation failed", error)
    return NextResponse.json({ error: "Unable to complete operation" }, { status: 500 })
  }
}

Status codes: 200 success, 201 created, 400 validation, 401 unauthenticated, 403 not a member of the workspace, 404 not found, 500 unexpected.

Serialisation boundary

Prisma models are camelCase; the HTTP API is snake_case. The conversion happens in the route handler or in a dedicated serialiser under lib/serializers/ (email-domain.ts, email-variation.ts). Client-side TypeScript is camelCase again, derived from the Zod schema that parses the response — so the snake_case shape exists only on the wire.

Error taxonomy

Calling errors are typed in lib/utils/calling-errors.ts so that a failure can be attributed to a layer without string matching:

Error Raised when

CallInitiationError

Base class; carries code, targetId, originalError

PhoneNumberSelectionError

No eligible source number, or validation of one failed

RetellCallError

The Retell API rejected or failed the call creation

DatabaseError

A persistence step failed after the external side effect

Skip reasons — the non-error outcomes of a dial attempt — are enumerated by CallSkipReason in lib/enums.ts: prior_disposition, warm_lead, lifecycle_stage, dnc, campaign_not_running, invalid_e164, target_not_found, no_phone_number_available, outside_call_window, retell_error. Workflows return the reason rather than throwing, so an ineligible target does not consume a retry.

Validation and types

// lib/schema/reports.ts — the schema is the source of truth
export const DailyStatSchema = z.object({ /* … */ })

// lib/types.ts — the type is derived, never hand-written
export type DailyStat = z.infer<typeof DailyStatSchema>

Every React Query hook parses its response through the schema. A backend change that drops or renames a field therefore fails loudly in the client instead of silently rendering undefined.