Retour aux articles

Stratégies de mise en œuvre du pattern CQRS avec Spring Boot pour des services évolutifs

Stratégies de mise en œuvre du pattern CQRS avec Spring Boot pour des services évolutifs | Laty Gueye Samba - Développeur Full Stack Dakar Sénégal, Expert Java Spring Boot Angular
```html Stratégies de mise en œuvre du pattern CQRS avec Spring Boot pour des services évolutifs

Stratégies de mise en œuvre du pattern CQRS avec Spring Boot pour des services évolutifs

Le pattern CQRS (Command Query Responsibility Segregation) sépare strictement la logique de traitement des commandes (écritures) de la logique des requêtes (lectures). Cette séparation permet d’optimiser l’architecture pour l’évolutivité, la performance des lectures et la flexibilité d’évolution du modèle de données.

Pourquoi CQRS pour des services évolutifs

Dans des systèmes en croissance, les besoins de lecture et d’écriture évoluent rarement de manière uniforme. CQRS aide à :

  • Scaller différemment les opérations de lecture et d’écriture.
  • Optimiser les modèles de lecture (read models) sans impacter le modèle d’écriture.
  • Réduire l’effet domino lors des changements fonctionnels sur les requêtes.
  • Améliorer la maintenabilité en clarifiant les responsabilités.

Architecture CQRS : concepts clés

Commands, Queries et handlers

Les Command portent l’intention d’un changement (ex. créer une réservation). Les Query décrivent une demande de lecture (ex. lister les réservations). Chaque type est traité par un handler dédié.

Read model vs Write model

Le Write model est centré sur la cohérence et les invariants métier. Le Read model est orienté performance de lecture et peut être dérivé (vue matérialisée, tables de consultation, cache, index).

Approche recommandée avec Spring Boot

Spring Boot offre un socle efficace pour CQRS via :

  • Des contrôleurs REST séparés (ou des endpoints clairement différenciés).
  • Un bus interne d’exécution (pattern mediator / dispatch).
  • Des handlers dédiés (services applicatifs).
  • Une persistance adaptée (même base ou séparation logique).
  • Un mécanisme de publication d’événements pour synchroniser les read models.

Stratégie 1 : Séparer les couches applicatives

La séparation commence par la conception du code. Les contrôleurs exposent des endpoints distincts pour commandes et requêtes, puis délèguent à des handlers dédiés.

Exemple de structure

com.example.cqrs
 ├── command
 │    ├── CreateReservationCommand.java
 │    ├── CreateReservationHandler.java
 │    └── api/CreateReservationController.java
 ├── query
 │    ├── ReservationByIdQuery.java
 │    ├── ReservationByIdHandler.java
 │    └── api/ReservationQueryController.java
 └── event
      ├── ReservationCreatedEvent.java
      └── ReservationEventHandlers.java

Stratégie 2 : Dispatcher de commandes et requêtes

Un dispatcher centralise la résolution du handler approprié, réduisant le couplage entre la couche API et la logique applicative.

Exemple de dispatcher minimal

public interface CommandHandler<C> {
  void handle(C command);
}

public interface QueryHandler<Q, R> {
  R handle(Q query);
}

public class CqrsDispatcher {

  private final Map<Class<?>, CommandHandler<?>> commandHandlers;
  private final Map<Class<?>, QueryHandler<?, ?>> queryHandlers;

  public CqrsDispatcher(Map<Class<?>, CommandHandler<?>> commandHandlers,
                         Map<Class<?>, QueryHandler<?, ?>> queryHandlers) {
    this.commandHandlers = commandHandlers;
    this.queryHandlers = queryHandlers;
  }

  public <C> void dispatch(C command) {
    CommandHandler<C> handler = (CommandHandler<C>) commandHandlers.get(command.getClass());
    if (handler == null) throw new IllegalArgumentException("No handler for " + command.getClass());
    handler.handle(command);
  }

  public <Q, R> R dispatch(Q query) {
    QueryHandler<Q, R> handler = (QueryHandler<Q, R>) queryHandlers.get(query.getClass());
    if (handler == null) throw new IllegalArgumentException("No handler for " + query.getClass());
    return handler.handle(query);
  }
}

Stratégie 3 : Un modèle d’écriture cohérent

Les handlers de commandes encapsulent la logique métier : validation, règles d’invariants, transactions et persistance du write model. Pour une évolutivité durable, il est recommandé de :

  • Utiliser des transactions au niveau du handler.
  • Prévoir une stratégie d’idempotence (par exemple via un identifiant de commande).
  • Limiter la diffusion de la persistance au sein du domaine.

Exemple de handler de commande

@Service
public class CreateReservationHandler implements CommandHandler<CreateReservationCommand> {

  private final ReservationRepository reservationRepository;
  private final ApplicationEventPublisher eventPublisher;

  public CreateReservationHandler(ReservationRepository reservationRepository,
                                   ApplicationEventPublisher eventPublisher) {
    this.reservationRepository = reservationRepository;
    this.eventPublisher = eventPublisher;
  }

  @Override
  @Transactional
  public void handle(CreateReservationCommand command) {
    // Validation & invariants (exemples)
    if (command.getStartDate() == null || command.getEndDate() == null) {
      throw new IllegalArgumentException("Dates obligatoires");
    }

    Reservation reservation = new Reservation(
      command.getCustomerId(),
      command.getStartDate(),
      command.getEndDate()
    );

    reservationRepository.save(reservation);

    eventPublisher.publishEvent(new ReservationCreatedEvent(reservation.getId(), reservation.getCustomerId()));
  }
}

Stratégie 4 : Synchroniser les read models via événements

Le read model doit être mis à jour après la commande. L’approche la plus courante consiste à publier un événement de domaine et à le consommer pour alimenter les vues de lecture.

Exemple d’événement et de projection

public record ReservationCreatedEvent(Long reservationId, Long customerId) {}

@Component
public class ReservationEventHandlers {

  private final ReservationReadRepository readRepository;

  public ReservationEventHandlers(ReservationReadRepository readRepository) {
    this.readRepository = readRepository;
  }

  @EventListener
  public void on(ReservationCreatedEvent event) {
    // Projection vers un modèle de lecture optimisé
    ReservationReadEntity entity = new ReservationReadEntity();
    entity.setReservationId(event.reservationId());
    entity.setCustomerId(event.customerId());
    readRepository.save(entity);
  }
}

Selon le niveau de robustesse requis, la consommation peut être déployée via messagerie (ex. Kafka/RabbitMQ) pour améliorer la résilience et gérer le décalage d’indexation (eventual consistency).

Stratégie 5 : Modèles de requêtes dédiés et API optimisée

Les handlers de requêtes s’appuient sur le read model. Les requêtes peuvent être optimisées pour :

  • des index de base de données adaptés aux parcours fréquents ;
  • des vues SQL spécialisées ;
  • des caches (Redis) ;
  • des réponses paginées pour limiter la charge.

Exemple de handler de query

@Service
public class ReservationByIdHandler implements QueryHandler<ReservationByIdQuery, ReservationDto> {

  private final ReservationReadRepository readRepository;

  public ReservationByIdHandler(ReservationReadRepository readRepository) {
    this.readRepository = readRepository;
  }

  @Override
  public ReservationDto handle(ReservationByIdQuery query) {
    ReservationReadEntity entity = readRepository.findByReservationId(query.reservationId())
      .orElseThrow(() -> new EntityNotFoundException("Reservation not found"));

    return new ReservationDto(entity.getReservationId(), entity.getCustomerId());
  }
}

Stratégie 6 : Gestion de la consistance, des erreurs et de l’observabilité

CQRS introduit souvent de la cohérence éventuelle entre write et read models. Pour réduire les risques en production, il est recommandé de :

  • Capturer clairement les statuts de propagation (ex. pending index update).
  • Implémenter des stratégies de retry sur la projection d’événements.
  • Garantir une idempotence au niveau des handlers de projection.
  • Ajouter une instrumentation : logs structurés, métriques, traces distribuées (OpenTelemetry / Micrometer).

Exemple d’idempotence côté projection

@Component
public class ReservationEventHandlers {

  private final ReservationReadRepository readRepository;

  @EventListener
  public void on(ReservationCreatedEvent event) {
    // Exemple simple : évite les duplications via clé unique
    if (readRepository.existsByReservationId(event.reservationId())) {
      return;
    }

    ReservationReadEntity entity = new ReservationReadEntity();
    entity.setReservationId(event.reservationId());
    entity.setCustomerId(event.customerId());
    readRepository.save(entity);
  }
}

Stratégie 7 : Évolutivité des schémas et versionnement

Quand les read models doivent changer (nouveaux besoins de lecture, nouveaux champs, nouveaux index), la discipline de versionnement évite de casser les consommateurs :

  • Versionner les événements de domaine si nécessaire.
  • Maintenir des handlers capables de traiter plusieurs versions pendant une période.
  • Créer de nouveaux read models en parallèle, puis migrer progressivement.
  • Prévoir des mécanismes de rebuild des projections (replay).

Stratégie 8 : Décisions techniques pragmatiques

Même base ou bases séparées

Deux options existent :

  • Même base (séparation logique) : plus simple à démarrer, utile pour réduire la complexité opérationnelle.
  • Bases séparées (séparation physique) : augmente l’isolation et peut faciliter les schémas divergents.

Le choix dépend du niveau de charge, des contraintes de conformité et du besoin de découplage.

Event-driven vs synchronisation directe

Pour l’évolutivité, les projections événementielles sont généralement préférées. La synchronisation directe est possible, mais elle réduit la capacité d’indépendance des modèles et peut introduire des couplages indésirables.

Conclusion

La mise en œuvre du pattern CQRS avec Spring Boot peut être robuste et durable en combinant : séparation stricte commandes/requêtes, handlers dédiés, read models optimisés, projections via événements, et pratiques de consistance, idempotence et observabilité. Ces choix améliorent la capacité d’évolution tout en maîtrisant la complexité.

Recommandation finale : démarrer avec une séparation logique et un mécanisme d’événements, puis renforcer progressivement l’isolation (messagerie, bases séparées, versionnement) selon la croissance du système.

À propos de l'auteur

Laty Gueye Samba est développeur Full Stack basé à Dakar, Sénégal. Spécialiste des écosystèmes Java / Spring Boot et Angular.

Contact : latygueyesamba@gmail.com  |  Dakar, Sénégal