La question qui revient à chaque conception de système event-driven : quand un service A doit-il parler à un service B ? Et surtout, comment ? Pendant longtemps, la réponse par défaut a été « un appel REST, c’est simple ». Sauf qu’un appel REST qui modifie l’état d’un autre service crée un couplage temporel : si B est indisponible, A échoue, et la mutation que l’utilisateur attendait part en erreur 500.
Sur les systèmes que j’ai construits avec Kafka comme colonne vertébrale, j’ai fini par appliquer une règle simple et tranchée : les mutations métier passent par des événements, jamais par un appel synchrone. Une transaction n’engage qu’un seul service. Mais cette règle, prise au pied de la lettre, ne suffit pas. Elle ouvre deux problèmes concrets (le dual-write côté émetteur, les redéliveries côté consommateur) et elle laisse de côté un cas légitime : les lectures de données de référence d’un autre service. C’est là qu’un appel REST encadré par un Circuit Breaker reprend tout son sens.
L’idée de fond est de découpler la disponibilité. Quand service-paiement valide un paiement, il ne va pas appeler service-commande en synchrone pour lui demander de marquer la commande comme payée. Il publie un événement paiement.valide, et service-commande réagit quand il peut. Si le service-commande est en redéploiement à ce moment-là, l’événement attend tranquillement dans le topic Kafka. Personne ne reçoit d’erreur.
Le piège, c’est l’implémentation naïve de la publication.
Voici le code qu’on écrit instinctivement, et qu’il faut bannir des flux métier :
@Transactional
public void validerPaiement(Paiement paiement) {
paiement.valider();
paiementRepository.save(paiement); // 1. commit en base
kafkaTemplate.send("domain.events", event); // 2. publication Kafka
}
Le problème est dans l’ordre et l’absence d’atomicité entre les deux écritures. kafkaTemplate.send est asynchrone et ne participe pas à la transaction JPA. Deux scénarios de divergence :
C’est le problème du dual-write : deux systèmes à écrire, pas de transaction distribuée pour les couvrir. Et un fire-and-forget ne vous laisse même pas la possibilité de savoir qu’une synchronisation a échoué. Pour un flux métier, ne pas savoir, c’est inacceptable.
La solution est connue et robuste : on écrit l’événement dans une table de la même base, dans la même transaction que la mutation métier. Tant que la transaction commit, l’événement est persistant. Un relais asynchrone le publiera ensuite vers Kafka.
CREATE TABLE commande_outbox (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished
ON commande_outbox (created_at)
WHERE published_at IS NULL;
Côté code, l’insertion outbox vit dans la transaction du domaine :
@Transactional
public void validerPaiement(Paiement paiement) {
paiement.valider();
paiementRepository.save(paiement);
var event = new OutboxEvent(
UUID.randomUUID(),
paiement.getId().toString(),
"paiement.valide",
serialize(paiement)
);
outboxRepository.save(event); // même transaction, atomicité garantie
}
Le relais est un composant séparé. Deux approches, à choisir selon le contexte :
published_at IS NULL, publie vers Kafka, puis marque la ligne publiée. Simple, sans dépendance externe, mais introduit une latence liée à la fréquence de polling.Dans les deux cas, on obtient une garantie at-least-once : l’événement finit par partir, possiblement plus d’une fois. C’est le compromis fondamental, et il déplace la responsabilité chez le consommateur.
Puisqu’un même événement peut être livré deux fois (relais qui republie après un crash avant de marquer la ligne, rebalance Kafka, retry…), le consommateur doit pouvoir traiter le même événement plusieurs fois sans effet de bord. L’approche la plus directe est une table de déduplication sur la clé d’événement :
@Transactional
public void on(PaiementValideEvent event) {
if (evenementsTraitesRepository.existsById(event.getEventId())) {
log.info("Doublon ignoré eventId={} type={}",
event.getEventId(), event.getType());
return; // déjà traité
}
commandeService.marquerPayee(event.getAggregateId());
evenementsTraitesRepository.save(new EvenementTraite(event.getEventId()));
}
La déduplication et le traitement métier doivent être dans la même transaction, sinon vous recréez un dual-write côté consommateur. Quand le design métier le permet, une opération naturellement idempotente (un UPDATE qui pose un état cible plutôt qu’un incrément) est encore préférable à la table de clés.
Un système distribué à l’exécution opaque est un cauchemar à exploiter. Côté consommateur, j’instrumente donc chaque décision : événement reçu, ignoré car doublon, traité, ou en échec avec la raison. C’est le pendant symétrique de l’outbox côté émetteur : d’un côté on trace ce qu’on publie, de l’autre ce qu’on a fait de chaque message.
Ce journal m’a permis d’attraper un bug pénible. Dans un @KafkaListener, j’accédais à une association JPA configurée en LAZY sur une entité chargée plus tôt. Hors d’un contexte de persistance encore ouvert, l’accès au proxy lève une LazyInitializationException. L’exception est bien réelle et bien levée : elle n’est pas « silencieuse ». Le problème, c’est qu’un listener Kafka asynchrone n’a pas le même cadre transactionnel qu’un contrôleur HTTP : sans transaction englobante explicite, et sans trace de décision, l’erreur passe pour un échec de consommation diffus, facile à mal attribuer.
Deux corrections, qui se cumulent :
@Transactional(readOnly = true)
public Commande chargerAvecLignes(String aggregateId) {
// la session reste ouverte le temps de matérialiser les associations LAZY
return commandeRepository.findWithLignesById(aggregateId);
}
Le @Transactional(readOnly = true) explicite sur les méthodes de lecture des adapters garantit un contexte de persistance ouvert pendant la matérialisation. Et le journal de décision rend l’échec visible immédiatement, avec sa cause, au lieu de le laisser se fondre dans le bruit.
Pour les échecs non transitoires, je route le message vers une DLQ (dead-letter queue) après une politique de retry à backoff exponentiel. Spring Kafka couvre ce besoin via DefaultErrorHandler et un DeadLetterPublishingRecoverer ; l’essentiel est de ne pas bloquer indéfiniment la partition sur un message empoisonné.
Tout n’est pas une mutation. Régulièrement, un service a besoin de lire une donnée de référence détenue par un autre : résoudre un identifiant en libellé, récupérer un attribut structurel qui ne change presque jamais. Reconstruire et maintenir une copie locale de toute cette donnée via des événements est parfois disproportionné. Un appel REST synchrone reste alors légitime, à condition de ne jamais l’écrire nu.
Un REST cross-service sans garde-fou, c’est rebrancher le couplage temporel qu’on vient de retirer. La version acceptable empile : cache local, timeouts explicites, circuit breaker, métriques, et invalidation par événement si la donnée peut évoluer.
resilience4j:
circuitbreaker:
instances:
referentiel:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
timelimiter:
instances:
referentiel:
timeoutDuration: 2s
@CircuitBreaker(name = "referentiel", fallbackMethod = "depuisCache")
@Cacheable(cacheNames = "referentiel", key = "#id")
public Referentiel resoudre(String id) {
return restClient.get()
.uri("/referentiels/{id}", id)
.retrieve()
.body(Referentiel.class);
}
private Referentiel depuisCache(String id, Throwable ex) {
// le cache Caffeine sert de dernier recours quand le circuit est ouvert
return cacheManager.getCache("referentiel").get(id, Referentiel.class);
}
Quand le service distant se dégrade, le circuit s’ouvre, on cesse de le marteler, et le fallback sert la dernière valeur connue depuis Caffeine. Les timeouts (connect et read, ici via le TimeLimiter) évitent qu’un appel pendu n’épuise le pool de threads. Micrometer expose l’état du circuit et le taux d’échec : sans ces métriques, on découvre l’incident par les utilisateurs. Et si la donnée de référence peut changer, son service propriétaire émet un événement d’invalidation qu’on consomme pour vider l’entrée de cache concernée. On reste cohérent à terme, sans appel synchrone à chaque lecture.
Voilà la grille que j’applique pour trancher canal de communication.
| Critère | Mutation métier | Lecture de référence |
|---|---|---|
| Canal | Événement Kafka via Outbox | REST synchrone encadré |
| Couplage temporel | Aucun (asynchrone) | Toléré, amorti par le cache |
| Garantie | At-least-once | Best-effort + fallback cache |
| Côté récepteur | Consommateur idempotent + DLQ | Circuit breaker + timeouts |
| Cohérence des données | Le récepteur possède une copie | Le propriétaire reste la source |
| Quand l’autre service tombe | L’événement attend | Circuit ouvert, on sert le cache |
L’Outbox n’est pas une sophistication optionnelle : dès qu’une mutation métier doit déclencher un effet ailleurs, le dual-write est un bug en sursis, et l’outbox est la réponse standard. Le coût réel n’est pas dans la table : c’est dans l’idempotence des consommateurs et l’observabilité, qu’il faut traiter dès le départ, pas après le premier incident de production.
Le Circuit Breaker, lui, n’est pas l’opposé de l’event-driven : c’est son complément pour la part de communication qui reste légitimement synchrone. La vraie discipline est de savoir distinguer une mutation d’une lecture, et de ne pas appliquer le même outil aux deux. Un appel REST nu entre services est un anti-pattern ; le même appel avec cache, timeouts, circuit breaker et invalidation par événement est une décision d’architecture parfaitement défendable.
Là où je reste prudent : l’at-least-once n’efface pas le besoin de réfléchir à l’ordre des événements et aux relations entre agrégats. L’outbox garantit la livraison, pas la sémantique de votre domaine. C’est un autre sujet, et probablement un autre article.
Liens rapides
Politique
