11 KiB
11 KiB
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:
- Repository services (
*.db.ts): Direct database access - Business services (
*Service.ts): Business logic orchestration - External services (
*Service.server.ts): Integration with external APIs - 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
// 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<LoginResult> {
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
// 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<AnalysisResult> {
log.info({ imagePath }, 'Starting image analysis');
// ... implementation
}
}
export const aiService = new AiService();
Route Handler Pattern
// 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:
// Production: use singleton
export const authService = createAuthService();
// Testing: inject mocks
export function createAuthService(deps?: Partial<AuthServiceDeps>) {
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 loggingsrc/services/logger.client.ts- Client-side loggingsrc/services/redis.server.ts- Redis connection managementsrc/services/queueService.server.ts- BullMQ queue managementsrc/services/cacheService.server.ts- Caching abstraction
Business Services
src/services/authService.ts- Authentication flowssrc/services/userService.ts- User managementsrc/services/gamificationService.ts- Achievements, leaderboardssrc/services/flyerProcessingService.server.ts- Flyer pipeline
External Integration Services
src/services/aiService.server.ts- Gemini AI integrationsrc/services/emailService.server.ts- Email sendingsrc/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
withTransactionfor multi-repository operations - Log business events (user registered, order placed, etc.)