# ADR-035: Service Layer Architecture **Date**: 2026-01-09 **Status**: Accepted **Implemented**: 2026-01-09 ## Context The application has evolved to include multiple service types: 1. **Repository services** (`*.db.ts`): Direct database access 2. **Business services** (`*Service.ts`): Business logic orchestration 3. **External services** (`*Service.server.ts`): Integration with external APIs 4. **Infrastructure services** (`logger`, `redis`, `queues`): Cross-cutting concerns Without clear boundaries, business logic can leak into routes, repositories can contain business rules, and services can become tightly coupled. ## Decision We will establish a clear layered architecture with defined responsibilities for each layer: ### Layer Responsibilities ``` ┌─────────────────────────────────────────────────────────────────┐ │ Routes Layer │ │ - Request/response handling │ │ - Input validation (via middleware) │ │ - Authentication/authorization │ │ - Rate limiting │ │ - Response formatting │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Services Layer │ │ - Business logic orchestration │ │ - Transaction coordination │ │ - External API integration │ │ - Cross-repository operations │ │ - Event publishing │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Repository Layer │ │ - Direct database access │ │ - Query construction │ │ - Entity mapping │ │ - Error translation │ └─────────────────────────────────────────────────────────────────┘ ``` ### Service Types and Naming | Type | Pattern | Suffix | Example | | ------------------- | ------------------------------- | ------------- | --------------------- | | Business Service | Orchestrates business logic | `*Service.ts` | `authService.ts` | | Server-Only Service | External APIs, server-side only | `*.server.ts` | `aiService.server.ts` | | Database Repository | Direct DB access | `*.db.ts` | `user.db.ts` | | Infrastructure | Cross-cutting concerns | Descriptive | `logger.server.ts` | ### Service Dependencies ``` Routes → Business Services → Repositories ↓ External Services ↓ Infrastructure (logger, redis, queues) ``` **Rules**: - Routes MUST NOT directly access repositories (except simple CRUD) - Repositories MUST NOT call other repositories (use services) - Services MAY call other services - Infrastructure services MAY be called from any layer ## Implementation Details ### Business Service Pattern ```typescript // src/services/authService.ts import { withTransaction } from './db/connection.db'; import * as userRepo from './db/user.db'; import * as profileRepo from './db/personalization.db'; import { emailService } from './emailService.server'; import { logger } from './logger.server'; const log = logger.child({ service: 'auth' }); interface LoginResult { user: UserProfile; accessToken: string; refreshToken: string; } export const authService = { /** * Registers a new user and sends welcome email. * Orchestrates multiple repositories in a transaction. */ async registerAndLoginUser( email: string, password: string, fullName?: string, avatarUrl?: string, reqLog?: Logger, ): Promise { const log = reqLog || logger; return withTransaction(async (client) => { // 1. Create user (repository) const user = await userRepo.createUser({ email, password }, client); // 2. Create profile (repository) await profileRepo.createProfile( { userId: user.user_id, fullName, avatarUrl, }, client, ); // 3. Generate tokens (business logic) const { accessToken, refreshToken } = this.generateTokens(user); // 4. Send welcome email (external service, non-blocking) emailService.sendWelcomeEmail(email, fullName).catch((err) => { log.warn({ err, email }, 'Failed to send welcome email'); }); log.info({ userId: user.user_id }, 'User registered successfully'); return { user: await this.buildUserProfile(user.user_id, client), accessToken, refreshToken, }; }); }, // ... other methods }; ``` ### Server-Only Service Pattern ```typescript // src/services/aiService.server.ts // This file MUST only be imported by server-side code import { GenAI } from '@google/genai'; import { config } from '../config/env'; import { logger } from './logger.server'; const log = logger.child({ service: 'ai' }); class AiService { private client: GenAI; constructor() { this.client = new GenAI({ apiKey: config.ai.geminiApiKey }); } async analyzeImage(imagePath: string): Promise { log.info({ imagePath }, 'Starting image analysis'); // ... implementation } } export const aiService = new AiService(); ``` ### Route Handler Pattern ```typescript // src/routes/auth.routes.ts import { Router } from 'express'; import { validateRequest } from '../middleware/validation.middleware'; import { loginLimiter } from '../config/rateLimiters'; import { authService } from '../services/authService'; const router = Router(); // Route is thin - delegates to service router.post( '/register', registerLimiter, validateRequest(registerSchema), async (req, res, next) => { try { const { email, password, full_name } = req.body; // Delegate to service const result = await authService.registerAndLoginUser( email, password, full_name, undefined, req.log, // Pass request-scoped logger ); // Format response res.status(201).json({ message: 'Registration successful', user: result.user, accessToken: result.accessToken, }); } catch (error) { next(error); // Let error handler deal with it } }, ); ``` ### Service File Organization ``` src/services/ ├── db/ # Repository layer │ ├── connection.db.ts # Pool, transactions │ ├── errors.db.ts # DB error types │ ├── user.db.ts # User repository │ ├── flyer.db.ts # Flyer repository │ └── index.db.ts # Barrel exports ├── authService.ts # Authentication business logic ├── userService.ts # User management business logic ├── gamificationService.ts # Gamification business logic ├── aiService.server.ts # AI API integration (server-only) ├── emailService.server.ts # Email sending (server-only) ├── geocodingService.server.ts # Geocoding API (server-only) ├── cacheService.server.ts # Redis caching (server-only) ├── queueService.server.ts # BullMQ queues (server-only) ├── logger.server.ts # Pino logger (server-only) └── logger.client.ts # Client-side logger ``` ### Dependency Injection for Testing Services should support dependency injection for easier testing: ```typescript // Production: use singleton export const authService = createAuthService(); // Testing: inject mocks export function createAuthService(deps?: Partial) { const userRepo = deps?.userRepo || defaultUserRepo; const emailService = deps?.emailService || defaultEmailService; return { async registerAndLoginUser(...) { /* ... */ }, }; } ``` ## Key Files ### Infrastructure Services - `src/services/logger.server.ts` - Server-side structured logging - `src/services/logger.client.ts` - Client-side logging - `src/services/redis.server.ts` - Redis connection management - `src/services/queueService.server.ts` - BullMQ queue management - `src/services/cacheService.server.ts` - Caching abstraction ### Business Services - `src/services/authService.ts` - Authentication flows - `src/services/userService.ts` - User management - `src/services/gamificationService.ts` - Achievements, leaderboards - `src/services/flyerProcessingService.server.ts` - Flyer pipeline ### External Integration Services - `src/services/aiService.server.ts` - Gemini AI integration - `src/services/emailService.server.ts` - Email sending - `src/services/geocodingService.server.ts` - Address geocoding ## Consequences ### Positive - **Separation of Concerns**: Clear boundaries between layers - **Testability**: Services can be tested in isolation with mocked dependencies - **Reusability**: Business logic in services can be used by multiple routes - **Maintainability**: Changes to one layer don't ripple through others - **Transaction Safety**: Services coordinate transactions across repositories ### Negative - **Indirection**: More layers mean more code to navigate - **Potential Over-Engineering**: Simple CRUD operations don't need full service layer - **Coordination Overhead**: Team must agree on layer boundaries ## Guidelines ### When to Create a Service Create a business service when: - Logic spans multiple repositories - External APIs need to be called - Complex business rules exist - The same logic is needed by multiple routes - Transaction coordination is required ### When Direct Repository Access is OK Routes can directly use repositories for: - Simple single-entity CRUD operations - Read-only queries with no business logic - Operations that don't need transaction coordination ### Service Method Guidelines - Accept a request-scoped logger as an optional parameter - Return domain objects, not HTTP-specific responses - Throw domain errors, let routes handle HTTP status codes - Use `withTransaction` for multi-repository operations - Log business events (user registered, order placed, etc.)