Dans les deux précédents articles sur Kafka, nous avons vu comment publier et consommer des messages de manière fiable.


Mais en production, une question finit toujours par arriver :
Comment garantir la cohérence entre une écriture en base et l’envoi d’un événement Kafka ?
Prenons un cas simple : création d’une commande.
- Si la commande est bien enregistrée en base, mais que la publication Kafka échoue, l’écosystème ne voit jamais l’événement.
- Si Kafka publie, mais que la transaction base est rollback, les autres services reçoivent un événement fantôme.
Bienvenue dans le vrai monde des systèmes distribués.
C’est précisément le problème que résout le pattern Transactional Outbox.
Qu’est-ce que le Transactional Outbox ?
Le principe est le suivant :
- On écrit la donnée métier (ex:
order) dans la base. - Dans la même transaction, on écrit un enregistrement dans une table
outbox_events. - Un publisher asynchrone lit les événements
PENDINGde l’outbox. - Il publie vers Kafka.
- Il marque ensuite l’événement en
SENT.
Tant que la transaction SQL n’est pas validée, aucun événement n’est considéré publiable.
⚖️ Pourquoi c’est utile ?
➕ Avantages
- Garantit la cohérence DB ↔ événement sans 2PC.
- Compatible avec les architectures event-driven.
- Permet de rejouer/inspecter les événements non envoyés.
- Se combine très bien avec une gestion propre des erreurs.
➖ Inconvénients
- Ajoute une table et un composant de publication à maintenir.
- Nécessite une stratégie d’observabilité (lag outbox, retries, backlog), voir une supervision.
- L’idempotence côté consumer reste nécessaire, même avec Outbox.
Implémentation dans Spring Boot
Dépendances Maven
Pour commencer, il nous faut ajouter la dépendance suivante à notre fichier pom.xml
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>Le broker Kafka
Pour cet article notre broker Kafka est le même que le précédent, exposé sur le port 9092 depuis un conteneur docker.
Entité métier + entité Outbox
L’entité métier (CustomerOrder) représente la donnée fonctionnelle.
L’entité Outbox stocke l’événement à publier :
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 80)
private String aggregateType;
@Column(nullable = false, length = 64)
private String aggregateId;
@Column(nullable = false, length = 120)
private String eventType;
@Lob
@Column(nullable = false)
private String payload;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OutboxStatus status;
@Column(nullable = false)
private Instant createdAt;
private Instant sentAt;
}Avec l’état :
public enum OutboxStatus {
PENDING,
SENT
}
Écriture atomique: commande + outbox
Le point critique est ici : on persiste commande et outbox dans une seule transaction.
@Transactional
public UUID createOrder(CreateOrderRequest request) {
CustomerOrder order = new CustomerOrder();
order.setCustomerName(request.customerName());
order.setAmount(request.amount());
CustomerOrder savedOrder = customerOrderRepository.save(order);
OrderCreatedEvent event = new OrderCreatedEvent(
savedOrder.getId(),
savedOrder.getCustomerName(),
savedOrder.getAmount(),
savedOrder.getCreatedAt()
);
OutboxEvent outboxEvent = new OutboxEvent();
outboxEvent.setAggregateType("ORDER");
outboxEvent.setAggregateId(savedOrder.getId().toString());
outboxEvent.setEventType("ORDER_CREATED");
outboxEvent.setStatus(OutboxStatus.PENDING);
outboxEvent.setPayload(toJson(event));
outboxEventRepository.save(outboxEvent);
return savedOrder.getId();
}
Ici, pas de publication Kafka immédiate : uniquement de la persistance fiable.
Publisher planifié
Un composant planifié scanne les événements PENDING.
@Scheduled(fixedDelayString = "${app.outbox.publish-delay-ms:3000}")
public void publishPending() {
outboxEventRepository.findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING)
.forEach(event -> {
try {
outboxPublisherTx.publishOne(event.getId());
} catch (Exception exception) {
LOG.warn("Outbox event {} will be retried later", event.getId(), exception);
}
});
}
Et la publication atomique sur un événement unique :
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void publishOne(Long outboxEventId) throws Exception {
OutboxEvent event = outboxEventRepository.findByIdAndStatus(outboxEventId, OutboxStatus.PENDING)
.orElse(null);
if (event == null) {
return;
}
kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload())
.get(10, TimeUnit.SECONDS);
event.setStatus(OutboxStatus.SENT);
event.setSentAt(Instant.now());
}
API de démonstration
POST /api/orders: crée une commande et un événement outboxPENDING.GET /api/outbox?status=PENDING|SENT: inspecte la table outbox.
Exemple de requête :
{
"customerName": "Erwan",
"amount": 42.50
}
Configuration
spring.application.name=transactional-outbox-tutorial
server.port=8088
spring.kafka.bootstrap-servers=localhost:9092
app.kafka.topic=order-events
spring.kafka.consumer.group-id=outbox-demo-group
spring.datasource.url=jdbc:h2:mem:outboxdb;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=update
app.outbox.publish-delay-ms=3000
Tests d’intégration
Deux tests valident le comportement attendu :
OrderServiceIntegrationTest: vérifie qu’une commande et un événementPENDINGsont bien persistés ensemble.OutboxPublisherTxIntegrationTest: mock la publication Kafka et vérifie la transitionPENDING -> SENT.
Bonnes pratiques de prod
- Ajouter un mécanisme de retries/backoff côté publisher (et éventuellement DLQ applicative).
- Exposer des métriques : nombre de
PENDING, âge max d’un événement, taux d’échec publication. - Surveiller ces métriques via Spring Boot Admin et Prometheus et Grafana.
- Garder des tests d’architecture avec ArchUnit pour éviter les contournements (publication directe hors outbox).
Conclusion
Le Transactional Outbox ne rend pas un système “magique”.
En revanche, il donne un cadre robuste pour résoudre un problème classique des architectures distribuées :
ne jamais laisser la base dire une chose pendant que le bus d’événements en raconte une autre.
Et si tu veux pousser encore plus loin la robustesse de la chaîne, combine ce pattern avec des tests de chaos via Chaos Mokey pour Spring Boot et une stratégie d’idempotence côté consumer.
Tout le code présent dans cet article est trouvable ici :