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