Transactional Outbox : la pièce manquante entre votre base et Kafka

Source : sfeir.dev

Transactional Outbox : la pièce manquante entre votre base et Kafka

Dans l’article précédent sur Kafka puis dans la version avancée, 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.

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 :

  1. On écrit la donnée métier (ex: order) dans la base.
  2. Dans la même transaction, on écrit un enregistrement dans une table outbox_events.
  3. Un publisher asynchrone lit les événements PENDING de l’outbox.
  4. Il publie vers Kafka.
  5. 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

⚠️ Inconvénients

Implémentation Spring Boot + Kafka

Pour ce tutoriel, on s’appuie sur un module dédié dans le repo de démo :

integration/messaging-tutorial/transactional-outbox-tutorial

Dépendances Maven

<dependencies>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
</dependencies>

On garde Spring Data JPA/H2 via le parent du projet.

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

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

Pour Kafka local, un docker-compose.yml est fourni dans le module.

Tests d’intégration

Deux tests valident le comportement attendu :

  1. OrderServiceIntegrationTest : vérifie qu’une commande et un événement PENDING sont bien persistés ensemble.
  2. OutboxPublisherTxIntegrationTest : mocke la publication Kafka et vérifie la transition PENDING -> SENT.

Ce n’est pas juste “du code qui compile”, c’est du code qui démontre le pattern.

Bonnes pratiques de prod

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 Introduisez du chaos dans votre application Spring Boot et une stratégie d’idempotence côté consumer.