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):
| Layer | Must do | Must NOT do |
|---|---|---|
| Routes | Map HTTP verb + path to handler; apply middleware | Contain any logic |
| Middleware | Check auth / rate limit; return null to pass through | Return data to the client |
| Controller | Parse + validate request; return Response | Contain business logic or DB queries |
| Service | Business logic; return ErrorOr<T> | Create Response objects; query DB |
| Repository | Drizzle queries; return raw rows | Contain 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 viaserviceErrorResponse()- 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
withMiddlewarecomposition 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