ADR-004: Service and Repository Layer Architecture
ADR-004: Service and Repository Layer Architecture
Status
Accepted
Context
The API currently has database logic embedded directly in route handlers:
// Current: handlers.ts
import { content, getDb } from '@toast/db';
export async function listContent(c: Context) {
const db = getDb();
const items = await db.select().from(content);
return c.json(items.map(toContentResponse), 200);
}This creates several problems:
- Tight coupling - Handlers know about Drizzle, database schemas, and query construction
- Hard to test - Unit tests must mock the entire
@toast/dbmodule - Business logic scattered - No clear home for domain rules (e.g., "can this content be published?")
- Difficult to reason about - HTTP concerns mixed with data access
As we add features like publishing workflows, staff users, and email sending, this coupling will compound. We need clear boundaries before the codebase grows.
Decision
Adopt a layered architecture with clear separation of concerns:
HTTP Request
↓
Controller (HTTP concerns only)
↓
Service (business logic)
↓
Repository (data access)
↓
@toast/db (infrastructure)
↓
PostgreSQLLayer Definitions
| Layer | Location | Responsibility |
|---|---|---|
| Controllers | apps/api/src/routes/*/{entity}.controller.ts | HTTP concerns: extract validated input, call service, format response |
| Services | apps/api/src/services/{entity}.service.ts | Business logic, orchestration, domain rules, multi-entity operations |
| Repositories | apps/api/src/repositories/{entity}.repository.ts | Data access abstraction, single-entity CRUD, query construction |
| @toast/db | packages/db/ | Infrastructure: Drizzle client, schema definitions, migrations |
Why Keep @toast/db Thin?
Following hexagonal architecture principles, @toast/db is a port to the database infrastructure. It should contain:
- Drizzle client and connection management
- Schema definitions (tables, enums, indexes)
- Migrations
- Type exports inferred from schema
It should NOT contain:
- Business logic
- Repository implementations
- Query patterns specific to use cases
This keeps the infrastructure layer reusable. If we add a CLI app or background workers, they can import @toast/db and build their own repositories appropriate to their needs.
Why Repositories in apps/api?
Repositories are application concerns, not infrastructure:
- They encode how THIS application accesses data
- They can be mocked for unit testing services
- Different apps might have different repository implementations
- They translate between domain types and database types
Implementation Pattern
Repository - Pure data access, no business logic:
// apps/api/src/repositories/content.repository.ts
import { content, getDb } from '@toast/db';
import { eq } from 'drizzle-orm';
export type ContentRow = typeof content.$inferSelect;
export type CreateContentData = typeof content.$inferInsert;
export async function findAll(): Promise<ContentRow[]> {
const db = getDb();
return db.select().from(content);
}
export async function findById(id: string): Promise<ContentRow | null> {
const db = getDb();
const results = await db.select().from(content).where(eq(content.id, id));
return results[0] ?? null;
}
export async function create(data: CreateContentData): Promise<ContentRow> {
const db = getDb();
const results = await db.insert(content).values(data).returning();
return results[0]!;
}Service - Business logic and orchestration:
// apps/api/src/services/content.service.ts
import * as contentRepo from '../repositories/content.repository.js';
export interface ContentResponse {
id: string;
siteId: string;
title: string;
// ... API response shape
}
function toResponse(row: contentRepo.ContentRow): ContentResponse {
return {
id: row.id,
siteId: row.siteId,
title: row.title,
body: row.body as Record<string, unknown> | null,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function listContent(): Promise<ContentResponse[]> {
const items = await contentRepo.findAll();
return items.map(toResponse);
}
export async function createContent(input: CreateContentInput): Promise<ContentResponse> {
// Business logic could go here:
// - Validate site exists
// - Set default values
// - Emit events
const row = await contentRepo.create({
siteId: input.siteId,
title: input.title,
body: input.body ?? null,
status: input.status,
});
return toResponse(row);
}Controller - Thin HTTP layer:
// apps/api/src/routes/content/content.controller.ts
import * as contentService from '../../services/content.service.js';
import type { Context } from 'hono';
import type { CreateContent } from './schemas.js';
export async function listContent(c: Context) {
const items = await contentService.listContent();
return c.json(items, 200);
}
export async function createContent(c: Context, body: CreateContent) {
const created = await contentService.createContent(body);
return c.json(created, 201);
}Testing Strategy
Each layer has a natural mock boundary:
| Testing | Mock At | Why |
|---|---|---|
| Controller unit tests | Service layer | Test HTTP concerns in isolation |
| Service unit tests | Repository layer | Test business logic without database |
| Repository integration tests | Real database | Verify queries work correctly |
| End-to-end tests | Nothing | Full stack verification |
// Service unit test example
vi.mock('../repositories/content.repository.js', () => ({
findAll: vi.fn().mockResolvedValue([mockContentRow]),
create: vi.fn().mockResolvedValue(mockContentRow),
}));
// Test business logic without touching databaseDependency Direction
Dependencies flow inward:
Controllers → Services → Repositories → @toast/db- Controllers import services
- Services import repositories
- Repositories import @toast/db
- @toast/db imports nothing from the app
This means:
- Changing a controller never affects services
- Changing service logic doesn't require repository changes
- Swapping database implementation only affects repositories
Consequences
Positive
- Clear boundaries - Each layer has one job
- Testable - Mock at layer boundaries for fast, focused tests
- Scalable patterns - Adding new entities follows the same structure
- Business logic has a home - Services are where domain rules live
- Consistent - "If you've seen one endpoint, you've seen them all"
Negative
- More files - Each entity needs repository + service + controller
- Indirection - Simple CRUD has more layers than strictly necessary
- Boilerplate - Some repetitive patterns (mitigated by consistency)
Trade-offs Accepted
For a codebase intended to grow significantly and be maintained by multiple contributors (human and AI), the benefits of clear structure outweigh the cost of additional files. The patterns are simple enough that the boilerplate is low.
File Structure
apps/api/src/
├── repositories/
│ ├── index.ts # Re-exports all repositories
│ ├── content.repository.ts
│ └── site.repository.ts
├── services/
│ ├── index.ts # Re-exports all services
│ ├── content.service.ts
│ └── site.service.ts
├── routes/
│ ├── content/
│ │ ├── index.ts # Route definitions (OpenAPI)
│ │ ├── content.controller.ts # HTTP controller
│ │ └── schemas.ts # Zod schemas for validation
│ └── health/
│ └── index.ts
└── app.tsMigration Path
- Create
repositories/andservices/directories - Implement ContentRepository with existing query logic
- Implement ContentService wrapping repository
- Update controller to use service
- Update tests to mock at service boundary
- Remove direct
@toast/dbimports from controllers
References
- Hexagonal Architecture
- ADR-002: Monorepo Structure
- PRD: "Easy to Change" principle