ADR-006: Layered Backend Architecture (Controller → Service → Repository)

Status: Accepted

Context

Without an enforced structure, backend code tends to collapse into route handlers that do HTTP parsing, business logic, and database queries all in one function. This makes testing hard (every test requires a live DB and HTTP setup), logic hard to reuse, and the codebase hard to navigate.

The goal was a structure where:

  • HTTP concerns (parsing, validation, status codes) are isolated from business logic
  • Business logic is isolated from database queries
  • Every layer is independently testable
  • The rules are enforced by convention and by the factory pattern, not by a framework

Decision

Enforce a four-layer backend:

HTTP Request
     │
     ▼
┌─────────────┐
│   Routes    │  Map URL + method → handler; apply middleware
└──────┬──────┘
       │
       ▼
┌─────────────────┐
│   Middleware    │  Auth guards, rate limiting, RBAC — pass/reject only
└──────┬──────────┘
       │
       ▼
┌──────────────────┐
│   Controller     │  Parse request, validate input, call service, return Response
└──────┬───────────┘
       │
       ▼
┌──────────────────┐
│    Service       │  Business logic, data transformation, ErrorOr<T> results
└──────┬───────────┘
       │
       ▼
┌──────────────────┐
│   Repository     │  Drizzle queries only — no business logic
└──────┬───────────┘
       │
       ▼
   PostgreSQL

Layer contracts (hard rules, documented in each layer’s README):

LayerMust doMust NOT do
RoutesMap HTTP verb + path to handler; apply middlewareContain any logic
MiddlewareCheck auth / rate limit; return null to pass throughReturn data to the client
ControllerParse + validate request; return ResponseContain business logic or DB queries
ServiceBusiness logic; return ErrorOr<T>Create Response objects; query DB
RepositoryDrizzle queries; return raw rowsContain any business logic

Middleware is composed with withMiddleware(...fns)(handler):

'/api/user/:id': {
  GET: withMiddleware(authMiddleware)((req) => {
    const id = req.params['id'] ?? '';
    return userController.getUserById(id);
  }),
},

Consequences

Positive

  • Services and controllers are unit-testable with injected mocks — no HTTP server or database required
  • Business logic changes are isolated to services; HTTP interface changes are isolated to controllers
  • New endpoints follow a clear template: route → controller method → service method → repository query
  • ErrorOr<T> from services maps predictably to HTTP status codes in controllers via serviceErrorResponse()
  • Clear blame for bugs: if the wrong data is returned, it’s a service or repository issue; if the wrong status code is returned, it’s a controller issue

Negative / Risks

  • More files per feature than a single-file route handler: a simple CRUD endpoint touches routes, controller, service, and repository
  • The withMiddleware composition pattern is less familiar than Express middleware chains; new contributors need to read the middleware README
  • No enforced compile-time boundary between layers — a developer could import a repository directly in a controller; this is a convention violation caught by code review, not the compiler