🎯 OBJECTIF
Comprendre comment :
@Externalized en trois étapes, sans toucher au code métier🧠 MODÈLE MENTAL
Spring Modulith sait déjà journaliser un événement en base dans la transaction métier. C'est l'Event Publication Registry. Ce journal évite de perdre un événement. Mais il ne sait pas le livrer proprement. La livraison vers Kafka est un simple listener asynchrone. Plusieurs threads publient en parallèle. Plusieurs instances rejouent les mêmes lignes. Un événement rejoué peut doubler un événement plus récent. Le registry est un carnet de dettes, pas un facteur.
Namastack Outbox est le facteur. Il stocke l'événement dans sa propre table, dans la même transaction que la donnée métier. Après le commit, un scheduler prend les enregistrements, les livre au broker dans l'ordre de leur clé, et réessaie en cas d'échec. Les instances se partagent le travail par hachage de la clé, sans verrou distribué. Depuis Spring Modulith 2.1, une seule propriété fait passer l'externalisation du listener maison à ce facteur. Le code qui publie l'événement ne change pas.
@Externalized — annotation Modulith qui marque un événement à externaliser. Sa valeur porte la cible et une clé de routage : topic::clé.event_publication de Modulith. Une ligne par listener transactionnel, écrite dans la transaction métier.outbox — valeur de spring.modulith.events.externalization.mode qui délègue la livraison à un outbox externe (Namastack ou JobRunr).outbox_record de Namastack : payload sérialisé, clé, statut, compteur d'échecs, prochaine tentative.Le point de départ est la note spring-modulith-monolithe-modulaire-avec-spring-boot (section 8). Le problème de fond, le dual write, est traité dans pattern-outbox-publication-fiable-de-messages-depuis-une-transaction-db.
Rappel du mécanisme natif. Quand un événement est publié, Modulith repère les listeners transactionnels concernés. Il écrit une ligne par listener dans event_publication, dans la transaction métier. L'externalisation vers Kafka est elle-même un de ces listeners.

Avant exécution : une ligne par listener, écrite avec la transaction métier. Source : doc Spring Modulith.

Après exécution : chaque listener marque sa ligne comme complétée. Une ligne non complétée est rejouable. Source : doc Spring Modulith.
Depuis la 2.0, chaque ligne porte un statut :
stateDiagram-v2
[*] --> PUBLISHED : INSERT dans la transaction métier
PUBLISHED --> PROCESSING : le listener démarre
PROCESSING --> COMPLETED : listener OK
PROCESSING --> FAILED : listener KO ou périmé
FAILED --> RESUBMITTED : resubmit()
RESUBMITTED --> PROCESSING
COMPLETED --> [*]mermaidCe mécanisme protège contre la perte. Il ne protège pas contre le désordre ni contre les doublons entre instances. La doc elle-même le dit : le mode natif est pragmatique mais il manque des fonctions attendues d'un vrai outbox.
| Capacité | Event Publication Registry | Namastack Outbox |
|---|---|---|
| Journalise l'intention de publier dans la transaction | oui | oui |
| Rejoue les publications incomplètes | oui, via API | oui, automatique |
| Coordonne plusieurs instances | à la charge de l'application | oui, partitionnement |
| Orchestre les retries (backoff, filtres d'exceptions) | à la charge de l'application | oui |
| Garantit l'ordre par clé | non | oui |
| Conçu comme infrastructure d'outbox | non | oui |
⚠️ Le vrai trou du mode natif : l'ordre
L'externalisation native est un @TransactionalEventListener asynchrone. Deux publications d'une même commande peuvent partir sur deux threads. La seconde peut arriver sur Kafka avant la première. Le rejeu aggrave le cas : un événement resoumis après une panne peut dépasser un événement plus récent. La propriété serialize-externalization=true sérialise tout, sur une seule instance, au prix du débit. Elle ne coordonne pas deux instances.
🔑 Conclusion clé
Le registry répond à « ai-je perdu quelque chose ? ». Un outbox répond à « ai-je livré dans le bon ordre, une seule fois par instance, avec une politique de retry ? ». Ce sont deux problèmes différents. Modulith 2.1 délègue le second.
Namastack Outbox est une librairie Spring Boot open source (Apache 2.0), écrite en Kotlin, utilisable en Java. Elle implémente l'outbox transactionnel sans composant d'infrastructure supplémentaire. Tout tourne dans l'application, avec la base de données comme seul point de coordination.
Écriture atomique de l'entité et du record, puis livraison asynchrone par le scheduler. Source : namastack.io.
Les garanties, telles que la doc les formule :
flowchart LR
subgraph DB["Base de données"]
R["outbox_record<br/>key, payload, status,<br/>failure_count, next_retry_at"]
I["outbox_instance<br/>heartbeat toutes les 5s"]
P["outbox_partition<br/>256 slots → instance"]
end
A["Instance A<br/>partitions 0-127"] -->|heartbeat| I
B["Instance B<br/>partitions 128-255"] -->|heartbeat| I
I -->|rebalance toutes les 10s| P
P -->|"hash(key) % 256"| R
A -->|poll ses partitions| R
B -->|poll ses partitions| RmermaidTrois tables. outbox_record porte les événements. outbox_instance enregistre les instances vivantes par heartbeat. outbox_partition affecte chaque slot à une instance. Une instance qui ne bat plus depuis stale-instance-timeout (30 s par défaut) est retirée. Ses partitions sont redistribuées au prochain rebalance.
🔑 Conclusion clé
Le partitionnement remplace le verrou. Une clé donnée est toujours traitée par une seule instance à la fois. C'est ce qui rend l'ordre par clé compatible avec plusieurs réplicas.
Le diagramme ci-dessus donne la structure. Voici la mécanique dans l'ordre où elle se déroule.
Insertion. Dans la transaction métier, la lib insère une ligne dans outbox_record. La ligne porte la clé (par exemple order-123), le payload, le type, le statut NEW, la date de création, et une colonne partition. Cette colonne est calculée à l'insertion : MurmurHash3(clé) mod 256. Le nombre de partitions est fixe. Il ne dépend pas du nombre de pods.
Registre des instances. Au démarrage, chaque pod s'enregistre dans outbox_instance avec un identifiant et un heartbeat. Le heartbeat est renouvelé toutes les 5 s (heartbeat-interval). Une instance silencieuse depuis 30 s (stale-instance-timeout) est considérée morte.
Attribution des partitions. Toutes les 10 s (rebalance-interval), chaque instance relit outbox_instance, trie les instances vivantes, et calcule de façon déterministe qui possède quoi : 256 partitions réparties à parts égales entre les N instances vivantes. Le résultat est écrit dans outbox_partition (partition vers instance). Il n'y a pas de coordinateur. Chaque instance arrive au même calcul parce qu'elle voit la même liste.
Poll. À chaque cycle (2 s en mode fixed, variable en mode adaptive), une instance ne lit que ses partitions : records en NEW ou FAILED, dont next_retry_at est passé, triés par date de création. Aucun SELECT FOR UPDATE SKIP LOCKED n'est nécessaire. Deux instances ne regardent jamais les mêmes partitions, donc il n'y a pas de contention.
Traitement. Un seul executor par instance. Le cycle se termine avant que le suivant démarre. Les records sont groupés par clé et traités dans l'ordre au sein d'une clé (batch-size clés par cycle). Succès : le record passe en COMPLETED, ou est supprimé si delete-completed-records=true. Exception : le record passe en FAILED, failure_count augmente, next_retry_at est calculé par la politique de retry, la dernière erreur est persistée. Avec stop-on-first-failure=true (défaut), les records suivants de la même clé attendent. Retries épuisés : le fallback handler prend la main, sinon le record reste en FAILED.
Rebalancing. Un pod tombe : après 30 s sans heartbeat il sort de la liste. Au check suivant, ses partitions sont réattribuées aux survivants. Un pod arrive : il s'enregistre, les autres recalculent, chacun lâche une partie de ses partitions. À l'arrêt propre, graceful-shutdown-timeout laisse le temps de finir le cycle en cours avant de se désinscrire.
Deux conséquences de cette mécanique :
L'exemple : un module order publie OrderCompleted vers le topic Kafka orders.completed, avec l'identifiant de commande comme clé.
Le BOM Modulith aligne la version de Namastack. Modulith 2.1.1 embarque Namastack 1.8.1. On ne déclare pas la version de Namastack soi-même, sauf pour personnaliser (section 6).
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>2.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- registry Modulith persisté (JPA ou JDBC) -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>
<!-- transport : publieur Kafka de Modulith -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-kafka</artifactId>
</dependency>
<!-- ✓ intégration outbox : tire namastack-outbox-core + starter JDBC -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-namastack</artifactId>
</dependency>
</dependencies>xmlLe starter spring-modulith-starter-namastack ne fonctionne qu'avec une base relationnelle. Pas de MongoDB dans ce mode.
spring:
modulith:
events:
externalization:
mode: outbox # ✓ délègue @Externalized à Namastack
kafka:
bootstrap-servers: localhost:9092yamlC'est la seule ligne obligatoire. Le multicaster d'événements de Namastack est désactivé par le starter. C'est celui de Modulith qui intercepte les événements et les route vers l'outbox.
// shop/order/OrderCompleted.java
@Externalized("orders.completed::#{#this.orderId()}") // topic :: clé
public record OrderCompleted(UUID orderId, BigDecimal total) {}java@Service
class OrderManagement {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
@Transactional
public void complete(UUID orderId) {
var order = orders.findById(orderId).orElseThrow();
order.markCompleted();
events.publishEvent(new OrderCompleted(order.getId(), order.total())); // ✓ record écrit dans cette transaction
}
}javaRien ne référence Namastack. Le handler Kafka est fourni par Modulith. Le topic et la clé viennent de @Externalized. Le mapping et les headers restent configurables par EventExternalizationConfiguration, comme en mode natif.
sequenceDiagram
participant S as OrderManagement
participant M as Modulith (multicaster)
participant DB as PostgreSQL
participant SCH as Scheduler Namastack
participant K as Kafka
S->>S: order.markCompleted()
S->>M: publishEvent(OrderCompleted)
M->>M: événement @Externalized ? oui
M->>DB: INSERT outbox_record (key = orderId, status = NEW)
Note over S,DB: même transaction que la commande
S->>DB: COMMIT
loop toutes les 2 s (fixed) sur ses partitions
SCH->>DB: SELECT records NEW, ordonnés par clé et date
SCH->>K: send(topic = orders.completed, key = orderId, payload)
alt envoi OK
SCH->>DB: UPDATE status = COMPLETED (ou DELETE)
else envoi KO
SCH->>DB: UPDATE failure_count + 1, next_retry_at = backoff
Note over SCH,DB: les records suivants de la même clé attendent
end
endmermaidPoints à lire sur le diagramme :
complete(). Un rollback annule les deux.@Externalized sert à la fois de clé Kafka et de clé d'ordre dans l'outbox. Une commande garde donc son ordre de bout en bout.stop-on-first-failure=true (défaut), un échec bloque les records suivants de la même clé. Les autres clés continuent.✓ Bonne pratique
Choisir une clé qui correspond à l'agrégat dont l'ordre compte. orderId pour une commande, customerId pour un client. Une clé trop large (un tenant entier) sérialise tout le tenant sur une seule partition. Une clé trop fine (un UUID par événement) perd l'ordre.
namastack:
outbox:
polling:
trigger: adaptive # fixed | adaptive
batch-size: 50 # clés par cycle (défaut 10)
adaptive:
min-interval: 500ms # accélère quand il y a du travail
max-interval: 5s # ralentit quand la table est vide
processing:
stop-on-first-failure: true
delete-completed-records: true # ✓ la table ne grossit pas
executor-core-pool-size: 4
executor-max-pool-size: 8
instance:
heartbeat-interval: 5s
stale-instance-timeout: 30s
rebalance-interval: 10syamldelete-completed-records=false par défaut. Comme pour le registry Modulith, la valeur par défaut garde tout. Sur un service à fort trafic, activer la suppression ou prévoir une purge.
namastack:
outbox:
retry:
policy: exponential # fixed | linear | exponential
max-retries: 5
exponential:
initial-delay: 2s
max-delay: 1m
multiplier: 2.0
jitter: 500ms # évite les retries synchronisés entre instances
exclude-exceptions:
- org.apache.kafka.common.errors.SerializationException # ⚠️ inutile de réessayer un payload invalideyamlPour une politique en code, on déclare un bean OutboxRetryPolicy. Cela demande io.namastack:namastack-outbox-api en dépendance directe, via le BOM Namastack :
<dependency>
<groupId>io.namastack</groupId>
<artifactId>namastack-outbox-api</artifactId> <!-- version alignée par le BOM Modulith -->
</dependency>xml@Configuration
class OutboxConfig {
@Bean
OutboxRetryPolicy outboxRetryPolicy(OutboxRetryPolicy.Builder builder) { // ✓ builder préconfiguré depuis le yaml
return builder
.retryOn(TimeoutException.class, org.apache.kafka.common.errors.RetriableException.class)
.noRetryOn(SerializationException.class)
.build();
}
}javaOrdre de résolution : politique du handler (interface OutboxRetryAware ou @OutboxRetryable), puis bean global, puis propriétés yaml.
Quand max-retries est atteint, le record passe en FAILED. Un @OutboxFallbackHandler peut le prendre en charge : log structuré, écriture dans une table de quarantaine, alerte. Sans fallback, le record reste en base avec le statut FAILED. Il est visible dans les métriques (section 7) mais rien ne se passe.
Namastack crée ses trois tables au démarrage si namastack.outbox.jdbc.schema-initialization.enabled=true (défaut). En production, désactiver et gérer le DDL par Flyway. Les noms et le schéma sont configurables :
namastack:
outbox:
jdbc:
schema-initialization:
enabled: false # ✓ Flyway gère le DDL
schema-name: messaging
table-prefix: ""
table-names:
record: outbox_record
instance: outbox_instance
partition: outbox_partitionyamlModulith crée aussi sa table event_publication de son côté. Deux jeux de tables cohabitent : le registry Modulith pour les listeners internes, l'outbox Namastack pour la sortie vers le broker. Le DDL de référence est dans le repo GitHub de Namastack (module namastack-outbox-jdbc) et dans l'appendice de la doc Modulith.
# application-test.yml
namastack:
outbox:
enabled: false # ✓ pas de scheduler ni de heartbeat dans un slice testyamlNamastack active @EnableScheduling par auto-configuration. Désactiver l'outbox désactive aussi cette activation. Pour tester le flux complet, garder l'outbox actif et utiliser l'API Scenario de Modulith avec un Kafka Testcontainers.
Ajouter io.namastack:namastack-outbox-observability. Les anciens modules -metrics et -tracing sont dépréciés. Le module s'auto-configure dès qu'un ObservationRegistry Micrometer existe.
| Métrique | Type | Usage |
|---|---|---|
outbox.record.schedule |
timer | latence et débit d'écriture des records |
outbox.record.process |
timer | latence, débit et taux d'erreur de livraison, par handler |
outbox.records{outbox.record.status=new} |
gauge | backlog en attente |
outbox.records{outbox.record.status=failed} |
gauge | records en échec définitif |
outbox.instance.records.pending |
gauge | pression sur cette instance |
outbox.cluster.instances.active |
gauge | instances vivantes |
outbox.cluster.partitions.unassigned |
gauge | partitions orphelines : doit être 0 |
Trois alertes suffisent pour démarrer :
outbox.records{status=failed} > 0 pendant 5 minutes.outbox.records{status=new} qui monte sans redescendre : la livraison ne suit pas, ou le broker est injoignable.outbox.cluster.partitions.unassigned > 0 pendant plus d'un rebalance : une instance est morte et personne n'a repris ses partitions.Le tracing traverse la frontière asynchrone. À l'écriture du record, le module sérialise le contexte de trace (traceparent) dans la colonne context. À la livraison, il ouvre un span enfant sous la trace d'origine. La requête HTTP qui a créé la commande et l'envoi Kafka apparaissent dans la même trace. Le même mécanisme (OutboxContextProvider) propage aussi un tenant ou un correlation id.
Modulith ajoute de son côté /actuator/modulith et les compteurs module.events.published. Les deux jeux de métriques se complètent : Modulith dit combien d'événements ont été publiés, Namastack dit combien ont été livrés.
⚠️ At-least-once : le consommateur doit être idempotent
Un crash entre l'envoi Kafka et la mise à jour du record produit une double livraison au redémarrage. C'est inhérent au pattern. Le consommateur doit dédoublonner sur un identifiant d'événement, ou appliquer une opération naturellement idempotente. Namastack ne fait pas d'exactly-once, et aucun outbox ne le fait sans coopération du consommateur.
⚠️ Le schéma auto-créé est un piège de déploiement
schema-initialization.enabled=true par défaut, et Modulith fait pareil pour event_publication. Deux librairies qui créent des tables au démarrage, sur une base gérée par Flyway, produisent des conflits de versions ou des tables hors migration. Désactiver les deux et versionner le DDL.
⚠️ Une panne d'instance retarde ses clés de 30 secondes au moins
Une instance qui meurt sans arrêt propre continue d'être considérée vivante pendant stale-instance-timeout. Ses partitions ne sont reprises qu'au rebalance suivant. Les records de ces clés attendent. Sur Kubernetes, prévoir un preStop et un graceful-shutdown-timeout non nul pour que l'instance libère ses partitions avant de partir.
Autres points à connaître :
event_publication (Modulith) et outbox_record (Namastack) grossissent chacune de leur côté. Régler completion-mode côté Modulith et delete-completed-records côté Namastack.stop-on-first-failure bloque une clé, pas le monde. Une commande dont un événement est en échec voit tous ses événements suivants attendre. Si les événements d'une même clé sont indépendants, passer à false.EventExternalizationConfiguration.mapping(...) et versionner ce contrat. Les bases Spring Kafka sont dans kafka-spring-core.| Critère | Externalisation native | Modulith + Namastack | Modulith + JobRunr | CDC (Debezium) |
|---|---|---|---|---|
| Perte d'événement | protégé par le registry | protégé | protégé | protégé |
| Ordre par clé multi-instance | non | oui | non garanti | oui, ordre du WAL |
| Retry avec backoff | à écrire | intégré | intégré (jobs) | côté connecteur |
| Infrastructure supplémentaire | aucune | aucune | aucune (ou dashboard) | Kafka Connect + connecteur |
| Latence de livraison | immédiate après commit | intervalle de polling | intervalle de polling | quasi immédiate |
| Charge sur la base | une table | quatre tables, polling | tables JobRunr, polling | lecture du WAL, sans polling |
| Contrat de sortie | libre (JSON) | libre (JSON) | libre | forme des lignes, ou event router |
| Effort de mise en place | nul | une propriété | une propriété | pipeline à déployer |
Modulith + Namastack :
Externalisation native :
FailedEventPublicationsJobRunr :
CDC :
⚡ TL;DR — chaque concept en une ligne
Event Publication Registry ✓ Journalise chaque événement par listener dans la transaction métier, et permet le rejeu. ⚠ Ne garantit ni l'ordre ni la coordination entre instances : ce n'est pas un outbox.
Mode outbox (Modulith 2.1)
✓ spring.modulith.events.externalization.mode=outbox délègue la livraison des @Externalized à Namastack ou JobRunr.
⚠ Relationnel uniquement avec Namastack ; le code métier ne change pas mais deux jeux de tables cohabitent.
Clé de record
✓ Issue de @Externalized("topic::clé"), elle fixe l'ordre, la partition Namastack et la clé Kafka.
⚠ Trop large elle sérialise tout, trop fine elle perd l'ordre.
Partitionnement 256 slots ✓ Hachage MurmurHash3 de la clé, répartition entre instances par heartbeat, sans verrou distribué. ⚠ Une instance morte retient ses partitions jusqu'au timeout de staleness (30 s) puis au rebalance.
Retry
✓ Politiques fixe, linéaire, exponentielle, jitter, filtres d'exceptions, override par handler.
⚠ Après max-retries, le record reste en FAILED sans action tant qu'aucun fallback handler n'existe.
At-least-once ✓ Aucun événement perdu, même en cas de crash entre commit et livraison. ⚠ Doublons possibles : le consommateur doit être idempotent.
Observabilité
✓ Timers outbox.record.*, gauges de backlog et de cluster, trace propagée de la requête à l'envoi Kafka.
⚠ Les anciens modules -metrics et -tracing sont dépréciés ; utiliser namastack-outbox-observability.
🎓 À retenir
mode=outbox change la sémantique, pas l'API. Le même @Externalized passe d'un listener asynchrone à un enregistrement dans l'outbox. Un projet peut basculer sans toucher aux événements. Mais la latence de livraison passe d'immédiate à un intervalle de polling. Vérifier que personne ne s'appuyait sur l'immédiateté.partitions.unassigned est la métrique la plus parlante. Elle vaut zéro en régime normal. Toute valeur positive qui dure signifie que des clés sont bloquées sans qu'aucune erreur ne soit levée.event_publication. Les deux tables ont chacune leur cycle de vie et leur purge.namastack.outbox.*, activation automatique du schedulingstop-on-first-failure, partitionnement 256 slots et coordination d'instancesOutboxRetryPolicy et builder, ordre de résolutionnamastack-outbox-example-modulith