Files
flyer-crawler.projectium.com/docs/adr/0035-service-layer-architecture.md

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:

  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

// 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 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.)