ADR-0024: Service Layer Architecture
Last Updated: 2026-07-14 Status: Active Context: Decksmith
Context
Fastify route handlers currently call Prisma and Supabase directly. For simple reads this works, but multi-step use cases already exist (POST /register calls supabase.auth.signUp then prisma.user.create with nested preferences) and more are coming: Phase 3 (Scryfall sync), Phase 6 (collection management), Phase 9 (PDF generation).
Without a defined service layer, business logic accumulates in HTTP handlers. This creates three concrete problems:
- Untestable orchestration — testing
POST /registerrequires a full Fastify server; the "create profile + preferences atomically" logic cannot be tested in isolation. - Inconsistent discoverability — logic is split between routes (simple cases) and some future abstraction (complex cases). A new contributor does not know where to look.
- Unreusable logic —
apps/worker(PDF generation, Scryfall sync) will need to read and write the same domain data asapps/api. Without a shared service layer, orchestration logic would be duplicated across apps.
Current Decision
1. Layer responsibilities
Route handlers are pure HTTP glue. Their only responsibilities are:
- Parsing and validating the HTTP request (enforced by Zod + Fastify type provider)
- Calling the relevant service function
- Converting business errors to HTTP errors via
createHttpError - Mapping the service result to a DTO via mapper functions
- Setting cookies, status codes, and sending the response
Route handlers contain no business logic, no Prisma calls, no Supabase calls.
Services own all orchestration. A service function:
- Executes one use case end-to-end
- Calls Prisma and/or Supabase and/or external APIs in the correct order
- Throws typed business errors (not
HttpError) when the use case cannot complete - Returns a plain domain value (not a DTO, not a raw Prisma model)
packages/domain is unchanged: pure functions, zero infrastructure, no Prisma, no Supabase.
2. The rule
All orchestration lives in
packages/services. Routes do HTTP only.
No exceptions based on complexity or number of infrastructure calls. A service function that wraps a single prisma.findUnique is valid — it will absorb future growth (RLS checks, caching, conditional includes) without requiring a structural change at that point.
3. Package location
Services live in packages/services — a new server-only package in the monorepo.
Rationale for a shared package over app-local folders:
- Phase 9 (PDF generation) requires the worker to read full deck and collection data from Prisma. The API serves the same data. A shared package avoids duplication across
apps/apiandapps/worker. - Declaring the boundary now (empty package, clear import rules) costs nearly nothing. Extracting services from
apps/apito a shared package after the fact requires touching every import across both apps — real friction at a busy phase of development. - The precedent already exists:
packages/domainwas created before it had content specifically to declare the architectural boundary.
packages/services depends on packages/db and packages/domain. It is server-only — it must never be imported by apps/web or packages/web-ui.
4. Prisma dependency
Services import the Prisma singleton directly from @decksmith/db:
import { prisma } from '@decksmith/db';
export async function registerUser(input: RegisterInput): Promise<AuthUser> {
const { data, error } = await supabase.auth.signUp({ ... });
// ...
await prisma.user.create({ ... });
}Dependency injection (passing prisma as a constructor argument) was considered and rejected. The existing test infrastructure already mocks @decksmith/db at module level via Vitest. Switching to DI would require rewriting all existing tests for no concrete gain on a single-server deployment.
5. No repository pattern
Repository classes are not used. The pattern is appropriate when the data store may be replaced or when tests require injecting a fake store. Neither applies here — Supabase Postgres is an ADR'd infrastructure choice, and module-level Prisma mocks provide sufficient isolation.
If a complex Prisma query is reused across ≥ 2 service functions within the same module, it may be extracted as a named function in a queries/ subfolder. This is a plain function, internal to its module, not exported from packages/services.
Rationale
- Separation of concerns: routes know HTTP; services know use cases; domain knows rules. Each layer has one reason to change.
- Consistent discoverability: logic is always in services. A new contributor never has to guess whether a given piece of logic lives in the route or somewhere else.
- Deterministic behavior: every use case has a single, named, testable entry point.
- Clarity over cleverness: named service functions (
registerUser,addCardToCollection) read as use-case descriptions. No abstract classes, no DI containers, no factory patterns.
Trade-offs
Benefits:
- All business logic is testable without a running Fastify server.
- Business errors are decoupled from HTTP — a service can be called from a route, a worker job, or a CLI script without modification.
apps/workerimports frompackages/servicesdirectly — no duplication when Phase 9 reads deck data.- The rule is unconditional — no debate about whether a given case is "complex enough".
Costs:
- Service functions that wrap a single infrastructure call add one indirection with no immediate payoff. This is accepted as the cost of consistency.
packages/servicesis a new package to maintain:package.json,tsconfig, pnpm workspace entry.
Risks:
- Over-granularity: one service function per route may produce too many files in early phases. Grouping by domain module (e.g.
auth-service.tsexposing multiple functions) is the mitigation. - Concurrency between apps: if a service function is called by both
apps/api(per-request) andapps/worker(background job), transaction semantics must be considered explicitly per function. Prisma handles the DB-level isolation, but application-level invariants must be documented.
Evolution History
2026-07-14: Initial decision
- Service layer introduced: routes are pure HTTP glue, all orchestration lives in services.
packages/servicescreated as a shared server-only package (anticipating Phase 9 worker overlap).- Rule: unconditional — every use case in a service, regardless of complexity.
- Repository pattern rejected in favour of direct Prisma singleton usage.
- Complex reused queries may be extracted as named functions in a module-local
queries/folder.
References
- ADR-0005: Package Boundaries and Dependency Graph
- ADR-0012: Prisma Database Package
- ADR-0014: Auth API-proxied (current route structure this layer sits above)
apps/api/src/modules/auth/auth-routes.ts— primary motivating exampleapps/docs/roadmap.md— Phase 3 (Scryfall), Phase 6 (Collection), Phase 9 (PDF)