You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

17 KiB

ADR-005 : Patterns de Conception Appliqués

Statut : Accepté
Date : Octobre 2025
Décideurs : Équipe Architecture
Tags : patterns, design-patterns, clean-code


Contexte

Pour garantir la maintenabilité, la testabilité, et le découplage du système, nous devons appliquer des Design Patterns éprouvés.

L'objectif est de démontrer la maîtrise de patterns architecturaux modernes (au-delà de la simple implémentation fonctionnelle).

Besoins identifiés

  • Découplage : Indépendance entre couches
  • Testabilité : Code facilement testable unitairement
  • Réutilisabilité : Éviter duplication
  • Maintenabilité : Code compréhensible et évolutif

Décision

Nous appliquons 5 patterns principaux :

  1. Repository Pattern (Accès aux données)
  2. Service Layer Pattern (Logique métier)
  3. Dependency Injection (DI) (Découplage)
  4. Factory Pattern (Création d'objets)
  5. Observer Pattern via Event Bus (Communication asynchrone)

Patterns Détaillés

1 Repository Pattern

Objectif : Abstraire l'accès aux données, découpler le domaine de l'infrastructure.

Principe

Domain Layer → Interface Repository
Infrastructure Layer → Implementation (Supabase)

Le domaine ne connaît PAS Supabase

Exemple : Reservation Service

Interface (Domain Layer)

// packages/microservices/reservation/src/domain/repositories/IReservationRepository.ts

export interface IReservationRepository {
  findById(id: string): Promise<Reservation | null>
  findByUserId(userId: string): Promise<Reservation[]>
  findByOutilId(outilId: string): Promise<Reservation[]>
  create(data: CreateReservationDTO): Promise<Reservation>
  update(id: string, data: UpdateReservationDTO): Promise<Reservation>
  delete(id: string): Promise<void>
  findOverlapping(params: OverlapParams): Promise<Reservation[]>
}

Implémentation (Infrastructure Layer)

// packages/microservices/reservation/src/infrastructure/repositories/SupabaseReservationRepository.ts

import { SupabaseClient } from '@supabase/supabase-js'
import { IReservationRepository } from '@/domain/repositories/IReservationRepository'

export class SupabaseReservationRepository implements IReservationRepository {
  constructor(private supabase: SupabaseClient) {}

  async findById(id: string): Promise<Reservation | null> {
    const { data, error } = await this.supabase
      .from('reservations')
      .select('*')
      .eq('id', id)
      .single()
    
    if (error) throw new RepositoryError(error.message)
    return data ? ReservationMapper.toDomain(data) : null
  }

  // ... autres méthodes
}

Avantages

Testabilité : Mock facile du repository

// Test
const mockRepo: IReservationRepository = {
  findById: jest.fn().mockResolvedValue(mockReservation),
  // ...
}

Changement de DB : Remplacer Supabase par Prisma/MongoDB sans toucher au domaine

Séparation des responsabilités : Le domaine ne gère pas les requêtes SQL


2 Service Layer Pattern

Objectif : Centraliser la logique métier, orchestrer les use cases.

Principe

API Routes → Services → Repositories

Les services contiennent la logique métier complexe et orchestrent les appels.

Exemple : Reservation Service

Service Métier

// packages/microservices/reservation/src/application/services/ReservationService.ts

export class ReservationService {
  constructor(
    private reservationRepo: IReservationRepository,
    private inventoryService: InventoryService, // Dépendance autre microservice
    private notificationService: NotificationService,
    private eventBus: EventBus
  ) {}

  async createReservation(
    userId: string,
    data: CreateReservationDTO
  ): Promise<Reservation> {
    // 1. Valider les données
    this.validateReservationDates(data)

    // 2. Vérifier disponibilité (appel Inventory Service)
    const isAvailable = await this.inventoryService.checkAvailability({
      outilId: data.outilId,
      entrepotId: data.entrepotId,
      dateDebut: data.dateDebut,
      dateFin: data.dateFin
    })

    if (!isAvailable) {
      throw new BusinessError('OUTIL_NON_DISPONIBLE')
    }

    // 3. Créer la réservation
    const reservation = await this.reservationRepo.create({
      ...data,
      userId,
      status: 'PENDING'
    })

    // 4. Émettre un event (asynchrone)
    await this.eventBus.publish('reservation.created', {
      reservationId: reservation.id,
      outilId: data.outilId,
      entrepotId: data.entrepotId,
      userId
    })

    // 5. Envoyer notification
    await this.notificationService.sendReservationConfirmation(userId, reservation)

    return reservation
  }

  private validateReservationDates(data: CreateReservationDTO): void {
    if (data.dateDebut >= data.dateFin) {
      throw new ValidationError('Date de fin doit être après date de début')
    }
    if (data.dateDebut < new Date()) {
      throw new ValidationError('Date de début ne peut pas être dans le passé')
    }
  }
}

Avantages

Logique métier centralisée : Pas dispersée dans les API routes

Réutilisabilité : Le service peut être appelé depuis n'importe où

Testabilité : Tester la logique sans API HTTP

Orchestration : Coordonne plusieurs repositories et services


3 Dependency Injection (DI)

Objectif : Inverser les dépendances, faciliter les tests et le découplage.

Principe (IoC - Inversion of Control)

Avant (Bad)

class ReservationService {
  private repo = new SupabaseReservationRepository() // Hard-coded !
}

Après (Good)

class ReservationService {
  constructor(private repo: IReservationRepository) {} // Interface !
}

Implémentation avec Container

Container DI

// packages/shared/di/src/container.ts

import { Container } from 'inversify'

const container = new Container()

// Repositories
container.bind<IReservationRepository>('IReservationRepository')
  .to(SupabaseReservationRepository)
  .inSingletonScope()

// Services
container.bind<ReservationService>('ReservationService')
  .to(ReservationService)
  .inSingletonScope()

export { container }

Injection dans API Route

// apps/moderne/app/api/reservations/route.ts

import { container } from '@bricoloc/shared-di'
import { ReservationService } from '@bricoloc/reservation-service'

export async function POST(req: Request) {
  const service = container.get<ReservationService>('ReservationService')
  const data = await req.json()
  
  const reservation = await service.createReservation(userId, data)
  return Response.json(reservation)
}

Avantages

Testabilité : Injecter des mocks facilement

// Test
container.bind<IReservationRepository>('IReservationRepository')
  .toConstantValue(mockRepository)

Découplage : Le service ne connaît que l'interface

Configuration centralisée : Toutes les bindings au même endroit

Lifetime management : Singleton, Transient, Scoped


4 Factory Pattern

Objectif : Centraliser la création d'objets complexes.

Exemple : Entity Factory

Use Case : Créer une entité Reservation avec validation et business rules

Factory

// packages/microservices/reservation/src/domain/factories/ReservationFactory.ts

export class ReservationFactory {
  static create(data: CreateReservationDTO, userId: string): Reservation {
    // 1. Validation
    this.validateData(data)

    // 2. Calculer le prix
    const prix = this.calculatePrice(data.outilId, data.dateDebut, data.dateFin)

    // 3. Déterminer le status initial
    const status = this.determineInitialStatus(data)

    // 4. Créer l'entité
    return new Reservation({
      id: generateId(),
      userId,
      outilId: data.outilId,
      entrepotId: data.entrepotId,
      dateDebut: data.dateDebut,
      dateFin: data.dateFin,
      prix,
      status,
      createdAt: new Date()
    })
  }

  private static validateData(data: CreateReservationDTO): void {
    if (!data.outilId) throw new ValidationError('outilId requis')
    if (!data.dateDebut) throw new ValidationError('dateDebut requis')
    // ...
  }

  private static calculatePrice(
    outilId: string,
    dateDebut: Date,
    dateFin: Date
  ): number {
    // Logique de pricing
    const days = differenceInDays(dateFin, dateDebut)
    const dailyRate = 15 // €/jour
    return days * dailyRate
  }

  private static determineInitialStatus(data: CreateReservationDTO): string {
    // Si réservation immédiate → PENDING
    // Si réservation future → SCHEDULED
    return data.dateDebut <= new Date() ? 'PENDING' : 'SCHEDULED'
  }
}

Usage dans Service

async createReservation(userId: string, data: CreateReservationDTO) {
  // Factory s'occupe de toute la complexité
  const reservation = ReservationFactory.create(data, userId)
  
  return await this.reservationRepo.create(reservation)
}

Avantages

Encapsulation : Logique de création centralisée

Validation : Garantit que l'objet est valide

Business Rules : Applique les règles métier (pricing, status)

Testabilité : Tester la création isolément


5 Observer Pattern via Event Bus

Objectif : Communication asynchrone entre microservices.

Principe

Service A → Émet Event → Event Bus → Service B écoute

Découplage : Service A ne connaît PAS Service B

Implémentation avec Supabase Realtime

Event Bus Abstraction

// packages/shared/events/src/EventBus.ts

export interface IEventBus {
  publish(eventName: string, data: any): Promise<void>
  subscribe(eventName: string, handler: EventHandler): void
  unsubscribe(eventName: string, handler: EventHandler): void
}

export class SupabaseEventBus implements IEventBus {
  constructor(private supabase: SupabaseClient) {}

  async publish(eventName: string, data: any): Promise<void> {
    const channel = this.supabase.channel('events')
    await channel.send({
      type: 'broadcast',
      event: eventName,
      payload: data
    })
  }

  subscribe(eventName: string, handler: EventHandler): void {
    const channel = this.supabase.channel('events')
    channel.on('broadcast', { event: eventName }, handler)
    channel.subscribe()
  }

  // ...
}

Publisher (Reservation Service)

async createReservation(...) {
  const reservation = await this.reservationRepo.create(data)

  // Émet un event
  await this.eventBus.publish('reservation.confirmed', {
    reservationId: reservation.id,
    outilId: data.outilId,
    entrepotId: data.entrepotId,
    dateDebut: data.dateDebut,
    dateFin: data.dateFin
  })

  return reservation
}

Subscriber (Inventory Service)

// packages/microservices/inventory/src/application/listeners/ReservationListener.ts

export class ReservationListener {
  constructor(
    private inventoryService: InventoryService,
    private eventBus: IEventBus
  ) {
    this.setupListeners()
  }

  private setupListeners(): void {
    this.eventBus.subscribe('reservation.confirmed', this.handleReservationConfirmed.bind(this))
    this.eventBus.subscribe('reservation.cancelled', this.handleReservationCancelled.bind(this))
  }

  private async handleReservationConfirmed(event: ReservationConfirmedEvent): Promise<void> {
    // Mettre à jour le stock
    await this.inventoryService.reserveStock({
      outilId: event.outilId,
      entrepotId: event.entrepotId,
      quantity: 1,
      reservationId: event.reservationId
    })
  }

  private async handleReservationCancelled(event: ReservationCancelledEvent): Promise<void> {
    // Libérer le stock
    await this.inventoryService.releaseStock({
      reservationId: event.reservationId
    })
  }
}

Avantages

Découplage : Services ne se connaissent pas directement

Asynchrone : Pas de blocage (fire-and-forget)

Scalabilité : Plusieurs subscribers possibles

Résilience : Si un subscriber échoue, les autres continuent

Audit Trail : Tous les events sont tracés


Récapitulatif des Patterns

Pattern Objectif Couche Exemple
Repository Abstraction accès données Infrastructure SupabaseReservationRepository
Service Layer Logique métier Application ReservationService
Dependency Injection Découplage Tous Container IoC (inversify)
Factory Création objets complexes Domain ReservationFactory
Observer (Event Bus) Communication async Infrastructure Supabase Realtime Channels

Architecture Complète avec Patterns

┌─────────────────────────────────────────────────┐
│          API Routes (Next.js API)               │
│              (Thin Controllers)                 │
└──────────────────┬──────────────────────────────┘
                   │ Injection DI
        ┌──────────▼──────────┐
        │   Service Layer     │ ← Logique métier
        │ (ReservationService)│
        └──────────┬──────────┘
                   │
        ┌──────────▼──────────┐
        │  Repository Pattern │ ← Abstraction
        │ (IReservationRepo)  │
        └──────────┬──────────┘
                   │
        ┌──────────▼──────────┐
        │  Infrastructure     │ ← Implémentation
        │ (SupabaseRepo)      │
        └─────────────────────┘

        ┌─────────────────────┐
        │   Factory Pattern   │ ← Création entités
        └─────────────────────┘

        ┌─────────────────────┐
        │  Observer Pattern   │ ← Events async
        │   (Event Bus)       │
        └─────────────────────┘

Conséquences

Avantages

  1. Testabilité maximale : Chaque composant testable isolément
  2. Maintenabilité : Code organisé et découplé
  3. Évolutivité : Facile d'ajouter features sans casser existant
  4. Réutilisabilité : Services et repositories réutilisables
  5. Démonstration : Montre maîtrise des patterns modernes

⚠️ Complexités

  1. Boilerplate : Plus de code (interfaces, implémentations)
  2. Courbe d'apprentissage : Patterns à comprendre
  3. Over-engineering : Risque si mal appliqué

🛠️ Mitigations

  • Documentation : Bien expliquer chaque pattern
  • Exemples : Fournir des templates pour chaque pattern
  • Progressif : Appliquer patterns seulement où c'est utile

Alternatives Rejetées

Pas de Patterns (Code procédural)

Raisons du rejet :

  • Pas de découplage
  • Difficile à tester
  • Logique métier dispersée
  • Maintenance cauchemar

Patterns complexes (CQRS, Event Sourcing, DDD complet)

Raisons du rejet :

  • Trop complexe pour projet académique
  • Overhead important
  • Courbe d'apprentissage élevée
  • Pas nécessaire pour cette échelle

Références


Validations

  • Repository interfaces définies
  • Service Layer implémenté
  • DI Container configuré (inversify)
  • Factory patterns identifiés
  • Event Bus testé avec Supabase Realtime
  • Tests unitaires avec mocks validés

Prochaine révision : Après 1 sprint de développement (feedback sur complexité)