Context Scope

Pattern Repository : Approche Moderne

📅 Publié le 15 December 2024 • ⏱️ 5 min de lecture

Pattern Repository : Approche Moderne

Le Repository Pattern est l'un des patterns les plus débattus en DDD. Populaire dans les années 2000, il est aujourd'hui remis en question avec l'évolution des ORMs et des architectures cloud-native.

Voici une analyse moderne de quand, comment et pourquoi utiliser le Repository Pattern en 2024.

État des lieux : Pourquoi le débat ?

Arguments contre (école "ORM suffit")

  1. Double abstraction : L'ORM fournit déjà une couche d'abstraction
  2. Complexité ajoutée : Plus de code à maintenir sans bénéfice évident
  3. Performance : Couche supplémentaire = overhead potentiel
  4. Évolution des ORMs : Entity Framework, Prisma, etc. sont très matures

Arguments pour (école DDD puriste)

  1. Isolation du domaine : Protège la logique métier des détails techniques
  2. Testabilité : Facilite les tests unitaires avec des mocks
  3. Flexibilité : Permet de changer de persistance sans impact métier
  4. Expressivité : Méthodes métier plutôt que requêtes génériques

Une position contextuelle

La recommandation est contextuelle :

✅ Utilisez Repository quand :

  • Domaine métier complexe avec besoins spécifiques
  • Équipe répartie avec expertise variable
  • Persistance hétérogène (SQL + NoSQL + API)
  • Tests unitaires intensifs du domaine

❌ Évitez Repository quand :

  • Application CRUD simple
  • Équipe experte en ORM
  • Contraintes de performance critiques
  • Prototype/MVP rapide

Implémentation moderne

1. Repository minimaliste

// Au lieu de Repository générique avec CRUD complet
interface GenericRepository<T> {
  create(entity: T): Promise<T>;
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  update(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

// Préférez des repositories spécialisés par agrégat
interface CustomerRepository {
  // Méthodes métier expressives
  findByEmail(email: Email): Promise<Customer | null>;
  findActiveCustomersInRegion(region: Region): Promise<Customer[]>;
  save(customer: Customer): Promise<void>;
  nextIdentity(): CustomerId;
}

2. Implémentation avec Query Objects

// Domain/Queries/CustomerQueries.ts
export class FindCustomersByRegionQuery {
  constructor(
    public readonly region: Region,
    public readonly includeInactive: boolean = false,
    public readonly pagination?: Pagination
  ) {}
}

// Infrastructure/CustomerRepository.ts
export class SqlCustomerRepository implements CustomerRepository {
  
  async findByQuery(query: FindCustomersByRegionQuery): Promise<Customer[]> {
    let sqlQuery = this.db
      .select()
      .from(customers)
      .where(eq(customers.region, query.region.value));
      
    if (!query.includeInactive) {
      sqlQuery = sqlQuery.where(eq(customers.isActive, true));
    }
    
    if (query.pagination) {
      sqlQuery = sqlQuery
        .limit(query.pagination.pageSize)
        .offset(query.pagination.offset);
    }
    
    const rows = await sqlQuery;
    return rows.map(Customer.fromDatabase);
  }
}

3. Repository avec Event Sourcing

export class EventSourcedOrderRepository implements OrderRepository {
  constructor(
    private eventStore: EventStore,
    private snapshotStore: SnapshotStore
  ) {}

  async findById(orderId: OrderId): Promise<Order | null> {
    // Tentative de récupération depuis snapshot
    const snapshot = await this.snapshotStore.get(orderId.value);
    const fromVersion = snapshot?.version ?? 0;
    
    // Récupération des événements depuis le snapshot
    const events = await this.eventStore.getEvents(
      orderId.value, 
      fromVersion
    );
    
    if (!events.length && !snapshot) {
      return null;
    }
    
    // Reconstruction de l'agrégat
    let order = snapshot ? 
      Order.fromSnapshot(snapshot) : 
      new Order(orderId);
      
    return order.replayEvents(events);
  }

  async save(order: Order): Promise<void> {
    const uncommittedEvents = order.getUncommittedEvents();
    
    await this.eventStore.saveEvents(
      order.id.value,
      uncommittedEvents,
      order.version
    );
    
    // Snapshot si nécessaire
    if (this.shouldCreateSnapshot(order.version)) {
      await this.snapshotStore.save(order.createSnapshot());
    }
    
    order.markEventsAsCommitted();
  }
}

Patterns alternatifs modernes

1. Direct ORM avec encapsulation

// Application/Services/CustomerService.ts
export class CustomerService {
  constructor(private orm: PrismaClient) {}

  async registerCustomer(command: RegisterCustomerCommand): Promise<Customer> {
    // Validation métier
    const existingCustomer = await this.orm.customer.findUnique({
      where: { email: command.email }
    });
    
    if (existingCustomer) {
      throw new CustomerAlreadyExistsError();
    }

    // Création avec logique métier
    const customer = Customer.create(command);
    
    // Persistance directe
    await this.orm.customer.create({
      data: customer.toPersistence()
    });

    return customer;
  }
}

2. CQRS avec séparation Read/Write

// Write side - Repository pour commandes
interface CustomerWriteRepository {
  save(customer: Customer): Promise<void>;
  findByIdForUpdate(id: CustomerId): Promise<Customer | null>;
}

// Read side - Query handlers directs
export class CustomerQueryHandler {
  constructor(private readDb: ReadOnlyDatabase) {}

  async getCustomerProfile(query: GetCustomerProfileQuery): Promise<CustomerProfile> {
    // Requête optimisée pour lecture
    const result = await this.readDb.query(`
      SELECT c.id, c.name, c.email, 
             COUNT(o.id) as order_count,
             SUM(o.total) as total_spent
      FROM customers c
      LEFT JOIN orders o ON c.id = o.customer_id
      WHERE c.id = ?
      GROUP BY c.id
    `, [query.customerId]);

    return CustomerProfile.fromQueryResult(result[0]);
  }
}

3. Data Access Object (DAO) spécialisé

// Plus simple que Repository, plus expressif que ORM direct
export class CustomerDataAccess {
  constructor(private db: Database) {}

  async findActiveCustomersWithRecentOrders(
    daysBack: number = 30
  ): Promise<CustomerWithOrderSummary[]> {
    return this.db.query(`
      SELECT 
        c.*,
        COUNT(o.id) as recent_order_count,
        MAX(o.created_at) as last_order_date
      FROM customers c
      INNER JOIN orders o ON c.id = o.customer_id
      WHERE c.is_active = true 
        AND o.created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)
      GROUP BY c.id
      HAVING recent_order_count > 0
    `, [daysBack]);
  }
}

Guidelines pour 2024

Quand choisir Repository

// ✅ Bon cas d'usage : Domaine complexe
interface OrderRepository {
  // Méthodes métier spécifiques
  findOrdersRequiringApproval(): Promise<Order[]>;
  findOverdueOrders(asOfDate: Date): Promise<Order[]>;
  findOrdersByCustomerCredit(creditLevel: CreditLevel): Promise<Order[]>;
  
  // Pas de méthodes génériques CRUD
}

// ❌ Mauvais cas d'usage : CRUD simple
interface ProductRepository {
  create(product: Product): Promise<Product>;
  findById(id: string): Promise<Product>;
  update(product: Product): Promise<Product>;
  delete(id: string): Promise<void>;
  findAll(): Promise<Product[]>; // Red flag !
}

Testabilité moderne

// Au lieu de mocks complexes
const mockRepository = {
  findByEmail: jest.fn(),
  save: jest.fn()
};

// Préférez des tests d'intégration avec base en mémoire
describe('CustomerService Integration', () => {
  let service: CustomerService;
  let testDb: TestDatabase;

  beforeEach(async () => {
    testDb = await createTestDatabase();
    service = new CustomerService(testDb.orm);
  });

  test('should register new customer', async () => {
    const command = new RegisterCustomerCommand('test@example.com');
    const customer = await service.registerCustomer(command);
    
    // Vérification en base
    const persisted = await testDb.orm.customer.findUnique({
      where: { id: customer.id.value }
    });
    
    expect(persisted).toBeDefined();
  });
});

Architecture hexagonale simplifiée

// Port (interface) minimaliste
interface CustomerPersistence {
  findByEmail(email: Email): Promise<Customer | null>;
  save(customer: Customer): Promise<void>;
}

// Adapter simple
export class PrismaCustomerPersistence implements CustomerPersistence {
  constructor(private prisma: PrismaClient) {}

  async findByEmail(email: Email): Promise<Customer | null> {
    const row = await this.prisma.customer.findUnique({
      where: { email: email.value }
    });
    
    return row ? Customer.fromPersistence(row) : null;
  }

  async save(customer: Customer): Promise<void> {
    const data = customer.toPersistence();
    
    await this.prisma.customer.upsert({
      where: { id: data.id },
      create: data,
      update: data
    });
  }
}

Métriques et monitoring

// Repository instrumenté pour observabilité
export class InstrumentedCustomerRepository implements CustomerRepository {
  constructor(
    private inner: CustomerRepository,
    private metrics: MetricsClient
  ) {}

  async findByEmail(email: Email): Promise<Customer | null> {
    const timer = this.metrics.startTimer('customer_repository.find_by_email');
    
    try {
      const result = await this.inner.findByEmail(email);
      this.metrics.increment('customer_repository.find_by_email.success');
      return result;
    } catch (error) {
      this.metrics.increment('customer_repository.find_by_email.error');
      throw error;
    } finally {
      timer.end();
    }
  }
}

Conclusion : Pragmatisme avant purisme

Le Repository Pattern en 2024 doit être :

  1. Justifié par la complexité métier, pas par dogme architectural
  2. Spécialisé par agrégat, pas générique
  3. Expressif métier, pas CRUD technique
  4. Testé en intégration, pas seulement unitaire

Ma recommandation finale :

  • Petites applications : ORM direct avec encapsulation dans les services
  • Applications moyennes : Data Access Objects spécialisés
  • Domaines complexes : Repository Pattern avec interfaces métier

L'architecture doit servir le business, pas l'inverse. Choisissez la solution qui maximise la vélocité de votre équipe tout en maintenant la qualité du code.

📚 À lire aussi

Patterns Event Sourcing : Au-Delà des Bases

Patterns avancés pour implémenter Event Sourcing en production. Stratégies de snapshotting, patterns de projection et gestion de l'évolution des schémas avec exemples pratiques.

#tactical #event-sourcing #patterns