Toast
ContributorDecisions

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:

  1. Tight coupling - Handlers know about Drizzle, database schemas, and query construction
  2. Hard to test - Unit tests must mock the entire @toast/db module
  3. Business logic scattered - No clear home for domain rules (e.g., "can this content be published?")
  4. 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)

PostgreSQL

Layer Definitions

LayerLocationResponsibility
Controllersapps/api/src/routes/*/{entity}.controller.tsHTTP concerns: extract validated input, call service, format response
Servicesapps/api/src/services/{entity}.service.tsBusiness logic, orchestration, domain rules, multi-entity operations
Repositoriesapps/api/src/repositories/{entity}.repository.tsData access abstraction, single-entity CRUD, query construction
@toast/dbpackages/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:

TestingMock AtWhy
Controller unit testsService layerTest HTTP concerns in isolation
Service unit testsRepository layerTest business logic without database
Repository integration testsReal databaseVerify queries work correctly
End-to-end testsNothingFull 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 database

Dependency 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.ts

Migration Path

  1. Create repositories/ and services/ directories
  2. Implement ContentRepository with existing query logic
  3. Implement ContentService wrapping repository
  4. Update controller to use service
  5. Update tests to mock at service boundary
  6. Remove direct @toast/db imports from controllers

References

On this page