AccueilAuteursContact

Outbox et Circuit Breaker - fiabiliser la communication entre services event-driven

Par Siraj ACHABBAK
June 12, 2026
5 min de lecture
Outbox et Circuit Breaker - fiabiliser la communication entre services event-driven

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.

La règle : mutations par événements, une transaction = un service

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.

Le dual-write, ou pourquoi le fire-and-forget direct est à proscrire

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 :

  • La base commit, puis le broker est injoignable au moment de l’envoi : l’état est muté mais aucun consommateur ne le saura jamais.
  • On envoie d’abord vers Kafka, puis la transaction rollback : un événement annonce un fait qui n’a jamais eu lieu.

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.

L’Outbox transactionnel

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 :

  • Polling publisher : un job interroge périodiquement les lignes où 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.
  • CDC (Change Data Capture) avec un outil comme Debezium : on lit directement le journal de transactions (le WAL pour PostgreSQL) et on stream les insertions de la table outbox. Plus réactif, mais ça ajoute un connecteur Kafka Connect à opérer.

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.

At-least-once implique des consommateurs idempotents

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.

Le journal de décision, et le piège LAZY qui m’a coûté du temps

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é.

Et les lectures, alors ? Le cas du REST encadré

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.

Le tableau de décision

Voilà la grille que j’applique pour trancher canal de communication.

CritèreMutation métierLecture de référence
CanalÉvénement Kafka via OutboxREST synchrone encadré
Couplage temporelAucun (asynchrone)Toléré, amorti par le cache
GarantieAt-least-onceBest-effort + fallback cache
Côté récepteurConsommateur idempotent + DLQCircuit breaker + timeouts
Cohérence des donnéesLe récepteur possède une copieLe propriétaire reste la source
Quand l’autre service tombeL’événement attendCircuit ouvert, on sert le cache

Ce que je retiens

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.


Tags

#kafka#eventdriven#resilience#microservices#spring
Article précédent
Ansible pour les DevOps : le guide essentiel pour les débutants
Siraj ACHABBAK

Siraj ACHABBAK

Software engineer

Sommaire

1
La règle : mutations par événements, une transaction = un service
2
Le dual-write, ou pourquoi le fire-and-forget direct est à proscrire
3
L'Outbox transactionnel
4
At-least-once implique des consommateurs idempotents
5
Le journal de décision, et le piège LAZY qui m'a coûté du temps
6
Et les lectures, alors ? Le cas du REST encadré
7
Le tableau de décision
8
Ce que je retiens

Liens rapides

À proposDevenir auteurContact

Social Media