329 lines
11 KiB
Markdown
329 lines
11 KiB
Markdown
# 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<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
|
|
|
|
```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<AnalysisResult> {
|
|
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<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 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.)
|