🎯 OBJECTIF
Comprendre comment :
🧠 MODÈLE MENTAL
Une application Spring Boot classique n'a pas de frontières internes. Toutes les classes sont public. Tous les beans sont injectables partout. N'importe quel service peut appeler n'importe quel autre. Au bout de quelques années, plus personne ne sait quelle classe dépend de quelle autre. Chaque modification a des effets à distance.
La réponse habituelle est de découper en microservices. Mais ce découpage coûte cher dès le premier jour : appels réseau, plusieurs déploiements, transactions distribuées, observabilité éclatée. Souvent, l'équipe n'avait pas besoin de déployer séparément. Elle avait besoin de frontières claires dans le code.
Spring Modulith apporte ces frontières sans le coût du réseau. Un module est un sous-package direct du package principal. Ce qui est à la racine du module est son API. Ce qui est dans ses sous-packages est privé. Un test vérifie ces règles à chaque build. Pour découpler les modules, on remplace les appels directs par des événements, et un journal en base garantit qu'aucun événement n'est perdu. Le code reste un seul déployable, mais il est organisé comme s'il devait un jour être découpé.
Spring Modulith est un projet Spring officiel, mené par Oliver Drotbohm. Il ne remplace ni Spring Boot ni Spring Framework. Il s'ajoute par-dessus.
État des lignes de version (septembre 2026) :
| Ligne | Socle | Statut |
|---|---|---|
2.1.x |
Spring Boot 4.1 | version stable courante (2.1.1) |
2.0.x |
Spring Boot 4 / Framework 7 | première version majeure, GA en novembre 2025 |
1.4.x |
Spring Boot 3.5 | ligne maintenue pour les applications restées en Boot 3 |
2.2.0-M1 |
Spring Boot 4.2 M1 | milestone, en préparation |
Le point d'entrée est un BOM. Ensuite, on ajoute un starter par besoin.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>2.1.1</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>xml| Artefact | Scope | Ce que ça apporte |
|---|---|---|
spring-modulith-starter-core |
compile | modèle de modules, annotations, support runtime |
spring-modulith-starter-jdbc / -jpa / -mongodb / -neo4j |
compile | Event Publication Registry persisté |
spring-modulith-starter-test |
test | @ApplicationModuleTest, API Scenario, Documenter |
spring-modulith-starter-insight |
runtime | actuator modulith + observabilité Micrometer |
spring-modulith-events-kafka / -amqp / -jms / -messaging |
runtime | externalisation vers un broker |
spring-modulith-starter-namastack / -jobrunr |
runtime | externalisation par un vrai outbox (depuis 2.1) |
spring-modulith-junit |
test | n'exécute que les tests des modules modifiés |
🔑 Conclusion clé
Rien n'est obligatoire. On peut n'utiliser que la vérification de structure : deux dépendances et un test. Les événements, la doc et l'observabilité viennent après, si besoin. C'est le mode d'adoption le plus courant.
Toute la suite s'appuie sur la même application : une boutique avec deux modules.
order gère les commandes.inventory gère le stock.Quand une commande est terminée, le stock doit baisser. C'est le seul lien entre les deux modules. La note montre comment l'exprimer proprement.
shop
├── ShopApplication.java ← package principal
├── shop.order ← module "order"
│ ├── OrderManagement.java API : public
│ ├── OrderCompleted.java événement : public
│ └── shop.order.internal
│ └── OrderRepository.java interne : interdit aux autres modules
└── shop.inventory ← module "inventory"
├── InventoryManagement.java API : public
└── StockItem.java package-private : invisible
Mise en place en quatre étapes :
spring-modulith-starter-core.shop par module.Les trois premières étapes tiennent en une heure sur un projet neuf.
Par défaut, chaque sous-package direct du package de la classe @SpringBootApplication devient un module. Il n'y a rien à déclarer.
Deux niveaux de protection existent :
inventory). La visibilité Java suffit. Une classe package-private n'est visible que dans son package. Le compilateur protège.order). Pour que OrderManagement utilise OrderRepository, il faut rendre OrderRepository public. Le compilateur ne protège plus rien. C'est Modulith qui prend le relais : tout ce qui est dans shop.order.internal est interdit aux autres modules, même si c'est public.Parfois, un module veut exposer plus que son package racine. Par exemple, un package spi avec des interfaces à implémenter par les autres modules. On l'annote dans son package-info.java :
@org.springframework.modulith.NamedInterface("spi")
package shop.order.spi;javaSans Modulith, la même intention s'exprime avec les outils de base :
order.api, order.event) qui contient les interfaces et les événements. Tout le reste en package-private.public pour des raisons techniques (entités JPA, config Spring) : aucune classe hors order ne dépend de order.internal...La named interface ne fait rien de plus. Elle rend la convention lisible et vérifiée par un test prêt à l'emploi.
Par défaut, un module peut voir l'API de tous les autres. On peut limiter la liste dans le package-info.java du module :
@org.springframework.modulith.ApplicationModule(
allowedDependencies = { "order", "order :: spi" } // ✓ liste blanche
)
package shop.inventory;javaLecture de la syntaxe :
"order" : accès au package racine du module order."order :: spi" : accès à la named interface spi du module order."order :: *" : accès à toutes les named interfaces déclarées par order.flowchart LR
subgraph ORD["module order"]
OM["shop.order<br/>OrderManagement, OrderCompleted<br/>(API par défaut)"]
OS["shop.order.spi<br/>@NamedInterface("spi")"]
OI["shop.order.internal<br/>OrderRepository"]
end
subgraph INV["module inventory"]
IM["InventoryManagement"]
end
IM -->|autorisé| OM
IM -->|autorisé si déclaré| OS
IM -.->|rejeté par verify| OI
OM --> OI
OS --> OImermaid⚠️ Un module est un domaine, pas une couche
Les modules Modulith sont des tranches métier : order, inventory, billing. Ce ne sont pas des couches techniques : api, domain, kafka, persistence.
Un projet avec un seul domaine découpé en couches n'a rien à vérifier avec Modulith. Les couches se protègent avec la visibilité Java, un multi-module Maven et deux règles ArchUnit. Poser Modulith dessus ajoute une dépendance et des annotations sans protéger quoi que ce soit de plus.
Test rapide : si on ne peut pas nommer deux modules par un mot métier, on n'a pas de modules. On a des couches.
@ApplicationModule en fait un module dans le module. Le module imbriqué voit tout son parent. L'inverse est faux.@ApplicationModule(type = Type.OPEN). Les internes redeviennent accessibles. Sert à poser Modulith sur du code existant sans tout casser le premier jour.spring.modulith.detection-strategy=explicitly-annotated. Seuls les packages annotés deviennent des modules. Les autres sont ignorés.⚠️ Un module ouvert n'est pas un état cible
Sur une application entièrement modularisée, un module ouvert signale presque toujours un découpage raté. C'est un outil de migration. Si la liste des modules ouverts ne diminue jamais, la migration est en fait abandonnée.
Tout tient dans un test.
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void verifiesModularStructure() {
modules.verify(); // ✓ échoue le build en cas de violation
}
}javaLes trois règles vérifiées :
order dépend de inventory et inventory dépend de order, le test échoue.allowedDependencies, tout accès hors liste est rejeté.Supposons que InventoryManagement importe shop.order.internal.OrderRepository. Le test échoue avec un message de ce type :
org.springframework.modulith.core.Violations:
- Module 'inventory' depends on non-exposed type
shop.order.internal.OrderRepository within module 'order'!
Le message dit quel module est fautif, quel type est visé, et dans quel module ce type est enfermé. La correction est toujours l'une de ces trois options :
order (ici OrderManagement),Sur du code existant, on ne corrige pas tout le premier jour. On peut filtrer :
ApplicationModules.of(ShopApplication.class)
.detectViolations()
.filter(v -> !v.getMessage().contains("legacy.reporting")) // ⚠️ dette explicite
.throwIfPresent();javaIl existe aussi une vérification au démarrage de l'application : spring.modulith.runtime.verification-enabled=true. C'est un choix discutable. Une violation d'architecture doit casser le build, pas la production.
✓ Bonne pratique
Écrire ce test le premier jour, quand il passe encore. Ajouté deux ans plus tard, il produit des centaines de violations que personne ne traitera. La valeur de Modulith dépend de la date à laquelle le test est ajouté, pas de l'outil.
Reprenons le besoin : quand une commande est terminée, le stock doit baisser.
@Service
class OrderManagement {
private final InventoryManagement inventory; // ⚠️ dépendance vers un autre module
@Transactional
public void complete(Order order) {
order.markCompleted();
inventory.updateStockFor(order); // ⚠️ order connaît inventory
}
}javaÇa fonctionne et Modulith l'autorise (on reste sur l'API). Mais trois problèmes apparaissent avec le temps :
OrderManagement accumule tout ce qui doit se passer après une commande : stock, facturation, email, statistiques.order, il faut instancier ou mocker les beans de tous ces modules.OrderManagement.@Service
class OrderManagement {
private final ApplicationEventPublisher events;
@Transactional
public void complete(Order order) {
order.markCompleted();
events.publishEvent(new OrderCompleted(order.getId())); // ✓ order ne connaît plus ses consommateurs
}
}java// shop/order/OrderCompleted.java
public record OrderCompleted(UUID orderId) {}javaLe module order publie un fait : « cette commande est terminée ». Il ne sait pas qui écoute. Le module inventory s'abonne.
Publier ne suffit pas. Il faut choisir quand le listener s'exécute.
Synchrone, dans la transaction de l'appelant (le défaut de Spring avec @EventListener). Simple : tout passe ou rien ne passe. Mais la transaction s'allonge à chaque nouveau listener. Et une erreur dans le listener stock fait échouer la commande. Les règles de propagation sont détaillées dans spring-boot-transactional-propagation-isolation-appels-entre-beans.
Asynchrone, après commit (@TransactionalEventListener + @Async). La transaction métier reste courte. Mais si le listener plante, ou si l'application tombe avant de l'exécuter, l'événement est perdu. Rien ne le signale.
C'est ce trou que Modulith bouche.
@Component
class InventoryManagement {
@ApplicationModuleListener // ✓ raccourci fourni par Modulith
void on(OrderCompleted event) {
// baisser le stock des lignes de la commande
}
}java@ApplicationModuleListener regroupe trois annotations :
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
void on(OrderCompleted event) { /* … */ }javaEn clair : le listener s'exécute après le commit de la commande, sur un autre thread, dans une transaction neuve. Et grâce au registry (section suivante), l'événement est journalisé avant même d'être livré.
⚠️ Un autre thread, ce n'est pas neutre
Le listener ne voit ni la transaction, ni le contexte de sécurité, ni le RequestContextHolder de l'appelant. Tout ce qui est porté par le thread appelant disparaît. Ce dont le listener a besoin doit être dans l'événement lui-même. Et le pool async de l'application devient un composant critique : s'il sature, les événements s'accumulent.
Le mot rollback change de sens avec @ApplicationModuleListener.
FAILED.Il n'y a donc pas de rollback global. Il y a un rejeu (section 7) ou une compensation écrite à la main. Deux conséquences :
Si ce modèle ne convient pas au cas d'usage, le @EventListener synchrone reste disponible. Tout passe ou rien ne passe, au prix du couplage transactionnel.
C'est le mécanisme central du projet. Voici ce qui se passe quand order publie OrderCompleted :
InventoryManagement.on(OrderCompleted).event_publication, dans la même transaction que la commande.sequenceDiagram
participant O as OrderManagement
participant DB as table event_publication
participant L as InventoryManagement
O->>O: order.markCompleted()
O->>DB: INSERT 1 ligne par listener (status PUBLISHED)
Note over O,DB: même transaction que la commande
O->>O: COMMIT
O-->>L: livraison asynchrone
L->>DB: UPDATE status = PROCESSING, completion_attempts + 1
alt listener OK
L->>DB: UPDATE status = COMPLETED, completion_date
else listener KO
L->>DB: UPDATE status = FAILED
Note over DB: resoumission possible plus tard
endmermaidSchéma PostgreSQL :
CREATE TABLE IF NOT EXISTS event_publication
(
id UUID NOT NULL,
listener_id TEXT NOT NULL, -- ⚠️ signature de la méthode listener
event_type TEXT NOT NULL,
serialized_event TEXT NOT NULL, -- JSON via Jackson par défaut
publication_date TIMESTAMP WITH TIME ZONE NOT NULL,
completion_date TIMESTAMP WITH TIME ZONE,
status TEXT, -- depuis 2.0
completion_attempts INT, -- depuis 2.0
last_resubmission_date TIMESTAMP WITH TIME ZONE, -- depuis 2.0
PRIMARY KEY (id)
);sqlExemple concret. La commande A1 est terminée. Le listener stock réussit. Puis la commande B2 est terminée, mais la base tombe pendant le traitement du stock. Voici la table après ces deux publications :
-- event_publication (colonnes principales)
id | listener_id | status | completion_attempts
e-01 | shop.inventory.InventoryManagement.on(shop.order.OrderCompleted) | COMPLETED | 1
e-02 | shop.inventory.InventoryManagement.on(shop.order.OrderCompleted) | FAILED | 1sqlLa ligne e-02 reste en base. La commande B2 est bien enregistrée. Le stock n'a pas bougé. Mais on le sait, et on peut rejouer.
La table est créée automatiquement au démarrage, sauf si spring.modulith.events.jdbc.schema-initialization.enabled=false. Sur un vrai projet, on la gère par migration Flyway ou Liquibase.
stateDiagram-v2
[*] --> PUBLISHED : INSERT dans la transaction métier
PUBLISHED --> PROCESSING : le listener démarre
PROCESSING --> COMPLETED : listener OK
PROCESSING --> FAILED : listener en erreur
PUBLISHED --> FAILED : périmé (staleness)
PROCESSING --> FAILED : périmé (staleness)
FAILED --> RESUBMITTED : resubmit()
RESUBMITTED --> PROCESSING : le listener redémarre
COMPLETED --> [*] : purge ou archivemermaid| Statut | Signification |
|---|---|
PUBLISHED |
enregistré, en attente de traitement |
PROCESSING |
un listener l'a pris en charge et s'exécute |
COMPLETED |
listener terminé avec succès |
FAILED |
listener en erreur, ou marqué périmé par le moniteur de staleness |
RESUBMITTED |
publication échouée remise en file |
Avant la 2.0, une publication était « complétée » ou « pas complétée ». Impossible de distinguer « en cours depuis deux secondes » de « planté depuis une heure ». Le statut résout ce problème.
Une application qui tombe en plein traitement laisse des lignes bloquées en PROCESSING. Le moniteur les passe en FAILED après une durée configurable.
spring:
modulith:
events:
staleness:
check-interval: 1m
published: 5m # 0 = désactivé (défaut)
processing: 10m
resubmitted: 10m
completion-mode: archiveyaml⚠️ Les trois durées sont à zéro par défaut. Le moniteur ne fait rien tant qu'on ne les configure pas.
@Component
class EventRecovery {
private final FailedEventPublications failed; // depuis 2.0
private final CompletedEventPublications completed;
@Scheduled(fixedDelay = 60_000)
void resubmitFailed() {
failed.resubmit(ResubmissionOptions.defaults()
.withBatchSize(50)
.withMinAge(Duration.ofMinutes(2)) // ✓ laisse passer les erreurs transitoires
.withFilter(p -> p.getCompletionAttempts() < 5)); // ✓ évite la boucle infinie
}
@Scheduled(cron = "0 0 3 * * *")
void purge() {
completed.deletePublicationsOlderThan(Duration.ofDays(7));
}
}javaTrois modes de complétion, via spring.modulith.events.completion-mode :
| Mode | Comportement | Coût |
|---|---|---|
update (défaut) |
pose la date de complétion, la ligne reste | la table grossit sans fin, purge manuelle obligatoire |
delete |
supprime la ligne | rien à purger, mais plus d'historique |
archive |
déplace vers event_publication_archive |
historique conservé, table active qui reste petite |
⚠️ Le mode par défaut est un piège de production
En update, la table garde chaque publication complétée. Un service qui traite des milliers d'événements par jour atteint plusieurs millions de lignes en quelques mois. Les écritures ralentissent. Choisir delete ou archive dès la mise en place, ou brancher une purge planifiée le premier jour.
⚠️ Le listener_id est la signature de la méthode
listener_id contient le nom complet de la classe, de la méthode et du type de l'événement. Renommer la méthode on, la déplacer dans une autre classe ou changer son paramètre invalide toutes les publications en attente. Plus aucun listener ne correspond à cet identifiant. Elles ne seront jamais rejouées. Avant un renommage : vider les publications incomplètes, ou prévoir une migration SQL sur listener_id.
Une propriété à connaître : spring.modulith.events.republish-outstanding-events-on-restart. Elle rejoue les publications en attente au démarrage. La doc déconseille de l'activer en déploiement multi-instance. Une autre instance est peut-être déjà en train de traiter ces mêmes événements.
Certains événements intéressent d'autres systèmes. Modulith sait republier un événement interne vers un broker.
@Externalized("orders.completed::#{#this.orderId()}") // topic :: clé de routage
public record OrderCompleted(UUID orderId) {}javaLa partie après :: est une expression SpEL évaluée sur l'événement. Elle donne la clé de partition Kafka ou la routing key AMQP. Sans annotation explicite, la cible par défaut est le nom du type relatif au package de base.
| Broker | Artefact | Remarque |
|---|---|---|
| Kafka | spring-modulith-events-kafka |
la clé logique sert de topic et de clé de message. Les bases de Spring Kafka sont dans kafka-spring-core |
| AMQP | spring-modulith-events-amqp |
nécessite d'ajouter Spring Rabbit explicitement |
| JMS | spring-modulith-events-jms |
pas de clé de routage |
| Spring Messaging | spring-modulith-events-messaging |
résout un MessageChannel par nom de bean |
Pour garder la main sur le contrat externe, on configure les trois étapes : quels événements sortent, sous quelle forme, vers où.
@Bean
EventExternalizationConfiguration externalization() {
return EventExternalizationConfiguration.externalizing()
.select(EventExternalizationConfiguration.annotatedAsExternalized())
.mapping(OrderCompleted.class, event -> new OrderCompletedPayload(event)) // ✓ contrat externe distinct du type interne
.headers(event -> Map.of("schema-version", "2"))
.routeKey(OrderCompleted.class, event -> event.orderId().toString())
.build();
}java⚠️ L'externalisation native n'est pas un outbox complet
Le mode natif est un simple listener transactionnel qui appelle le broker. Le registry protège contre la perte. Mais rien ne garantit l'ordre entre plusieurs instances. Deux threads peuvent publier en parallèle. Un événement rejoué peut arriver après un événement plus récent. La propriété spring.modulith.events.externalization.serialize-externalization=true sérialise les envois, au prix du débit.
Depuis la 2.1, on peut déléguer à un vrai outbox avec spring.modulith.events.externalization.mode=outbox. Deux implémentations : spring-modulith-starter-namastack (bases relationnelles, publication ordonnée et multi-instance) ou spring-modulith-starter-jobrunr. Le problème de fond est détaillé dans pattern-outbox-publication-fiable-de-messages-depuis-une-transaction-db.
@ApplicationModuleTest remplace @SpringBootTest. Il ne démarre que le module dans lequel la classe de test se trouve.
package shop.order;
@ApplicationModuleTest
class OrderIntegrationTests {
@MockitoBean InventoryManagement inventory; // ✓ on mocke, on n'élargit pas le périmètre
}javaTrois modes de démarrage :
| Mode | Périmètre |
|---|---|
STANDALONE (défaut) |
le module seul |
DIRECT_DEPENDENCIES |
le module et ses dépendances directes |
ALL_DEPENDENCIES |
le module et tout l'arbre en dessous |
Le contexte est réellement réduit. Le scan de composants, l'auto-configuration et le scan d'entités sont limités aux packages du module. Sur une grosse application, le gain de temps de démarrage est net.
Si un test échoue au démarrage à cause de beans d'autres modules, la bonne réaction est de les mocker, pas d'élargir le mode. Un module qui a besoin de dix beans externes est un module mal découpé.
Tester une chaîne asynchrone à la main est pénible : transactions, attente, assertions. Scenario fait le travail.
@ApplicationModuleTest
class OrderIntegrationTests {
@Test
void completingAnOrderTriggersStockUpdate(Scenario scenario) {
scenario.stimulate(() -> orders.complete(order))
.customize(it -> it.atMost(Duration.ofSeconds(2)))
.andWaitForEventOfType(StockUpdated.class)
.matching(event -> event.orderId().equals(order.getId()))
.toArriveAndVerify(event -> assertThat(event.quantity()).isEqualTo(3));
}
}javaLecture ligne par ligne : on déclenche l'action, on attend au plus deux secondes, on attend un événement StockUpdated, on filtre celui de notre commande, on vérifie son contenu.
Variante quand aucun événement de sortie n'existe. On attend un changement d'état :
scenario.publish(new OrderCompleted(orderId))
.andWaitForStateChange(() -> inventory.findStock(sku))
.andVerify(stock -> assertThat(stock.available()).isZero());java⚠️ Le stimulus n'est jamais rollbacké
Scenario démarre une nouvelle transaction pour déclencher l'action. Sinon, les listeners transactionnels ne recevraient jamais l'événement. Les écritures en base survivent donc au test, même si la classe est annotée @Transactional. Il faut nettoyer soi-même avec andCleanup(…). Sinon les tests se polluent entre eux et deviennent dépendants de l'ordre d'exécution.
Deux autres outils :
PublishedEvents / AssertablePublishedEvents, injectés en paramètre de test, pour vérifier qu'un événement a bien été publié. Depuis la 2.1, ils voient les événements de tous les threads.spring-modulith-junit n'exécute que les tests des modules modifiés, ou dépendant d'un module modifié. En CI, il faut renseigner spring.modulith.test.reference-commit avec le commit du dernier build vert.Le modèle de modules sait se dessiner tout seul, depuis un test.
@Test
void writeDocumentationSnippets() {
new Documenter(modules)
.writeModulesAsPlantUml() // vue d'ensemble, style C4 par défaut
.writeIndividualModulesAsPlantUml() // un diagramme par module
.writeModuleCanvases(); // une fiche par module
}javaLa sortie va dans target/spring-modulith-docs. Voici ce que produit la doc officielle pour une application d'exemple (modules order, inventory, catalog, customer) :
Et le diagramme centré sur un seul module, avec ses dépendances directes :
Le canvas est une table par module. Elle liste :
Avec spring-modulith-apt en compile, le Javadoc des types est repris dans la fiche.
🔑 Conclusion clé
La doc est dérivée du code. Elle ne peut pas mentir. Un diagramme d'architecture dessiné à la main est faux au bout d'un trimestre. Celui-ci est régénéré à chaque build.
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-insight</artifactId>
<scope>runtime</scope>
</dependency>xmlDeux choses arrivent.
L'endpoint /actuator/modulith expose la structure réelle au runtime. Pour chaque module : son package de base, ses dépendances sortantes, et le type de chaque dépendance.
{
"order": { "basePackage": "shop.order", "displayName": "Order", "dependencies": [] },
"inventory": {
"basePackage": "shop.inventory",
"displayName": "Inventory",
"dependencies": [ { "target": "order", "types": [ "EVENT_LISTENER" ] } ]
}
}jsonTrois types de dépendance existent : DEFAULT (référence de type), USES_COMPONENT (injection d'un bean), EVENT_LISTENER (écoute d'un événement). Ici, inventory ne dépend de order que par un événement. C'est le couplage le plus faible possible.
L'observabilité décore les beans exposés par chaque module. Chaque franchissement de frontière produit un span Micrometer, avec les tags module.name, module.identifier, module.method et module.invocation-type (bean ou event listener). Une trace montre alors le chemin entre modules, et pas seulement une pile d'appels Java.
Deux compteurs sont aussi publiés : module.events.published pour l'ensemble, et un compteur par type d'événement. On peut y ajouter des tags métier avec un ModulithEventMetricsCustomizer.
Sur un projet neuf, on pose la structure le premier jour. Sur un projet existant, on procède par étapes.
spring-modulith-starter-core et le test verify(). Il échoue. C'est normal.Type.OPEN). Le test passe. On a une photo de départ.Si un module reste ouvert plus de six mois, c'est qu'il ne sera jamais fermé. Il faut soit le fusionner avec son voisin le plus proche, soit accepter que la migration s'arrête là.
| ✅ Avantages | ❌ Inconvénients |
|---|---|
| Frontières vérifiées au build, pas dans un document | Basé sur les packages : un public reste public pour le compilateur |
| Coût opérationnel nul : un seul déployable, une seule base | Aucun déploiement ni scaling indépendant |
| Événements persistés : pas de message perdu sur échec du listener | Une écriture en base par listener et par événement |
| Refactoring facile, tout est dans un seul repo | Adoption sur du code existant = réorganisation massive des packages |
| Chemin de sortie propre vers les microservices | verify() allonge le build (ArchUnit lit tout le bytecode) |
| Tests par module, contexte Spring réduit | Le nom du module est le nom du package : renommer coûte cher |
| Doc et diagrammes dérivés du code | Externalisation native limitée sans le mode outbox |
| Traces et actuator au niveau module | Une notion de plus à faire adopter à l'équipe |
Bon terrain :
Mauvais terrain :
🔑 Trois questions avant d'adopter Modulith
ApplicationEventPublisher si besoin de découplage.Trois non : Modulith n'apporte rien. Trois oui : c'est exactement son terrain.
| Critère | Spring Modulith | ArchUnit seul | Maven multi-modules | JPMS | Microservices |
|---|---|---|---|---|---|
| Frontières vérifiées | oui, conventions prêtes | oui, règles à écrire | oui, par le compilateur | oui, par la JVM | oui, par le réseau |
| Effort de mise en place | faible | moyen (tout écrire) | moyen à élevé | élevé | très élevé |
| Déploiement indépendant | non | non | non | non | oui |
| Coût opérationnel | nul | nul | nul | nul | élevé |
| Événements fiables fournis | oui | non | non | non | à construire |
| Doc et traces par module | oui | non | non | non | à construire |
| Réversibilité d'un découpage | forte | forte | moyenne | moyenne | faible |
La comparaison la plus juste est avec ArchUnit seul. Modulith est ArchUnit, plus un modèle de modules prêt à l'emploi, plus les événements, les tests, la doc et l'observabilité. Si le besoin s'arrête à « interdire tel package d'appeler tel autre », ArchUnit suffit. Dès qu'on veut le découplage par événements sans perdre de message, Modulith prend l'avantage.
⚡ TL;DR — chaque concept en une ligne
Module applicatif ✓ Un sous-package direct du package principal. Son package racine est son API, ses sous-packages sont privés. ⚠ La règle est vérifiée par ArchUnit, pas par le compilateur Java.
verify()
✓ Casse le build en cas de cycle, d'accès à un interne, ou de dépendance non déclarée.
⚠ Ajouté trop tard, il produit des centaines de violations et devient ingérable.
Named interface
✓ Expose un sous-package supplémentaire, référençable par module :: nom.
⚠ Multiplier les named interfaces revient à ouvrir le module entier.
@ApplicationModuleListener
✓ Raccourci pour async, après commit, dans une transaction neuve.
⚠ Autre thread : ni transaction, ni contexte de sécurité, ni contexte de requête hérités.
Event Publication Registry
✓ Une ligne par listener, écrite dans la transaction métier. Rien n'est perdu si le listener échoue.
⚠ Le mode update par défaut fait grossir la table sans limite.
Cycle de vie 2.0
✓ PUBLISHED → PROCESSING → COMPLETED ou FAILED, avec compteur de tentatives et resoumission ciblée.
⚠ Le moniteur de staleness est inactif tant qu'aucune durée n'est configurée.
Externalisation
✓ @Externalized republie un événement interne vers Kafka, AMQP, JMS ou un MessageChannel.
⚠ Ce n'est pas un outbox : ni ordre garanti, ni sûreté multi-instance sans le mode outbox (2.1).
@ApplicationModuleTest
✓ Démarre un module seul, avec scan et auto-configuration limités à ses packages.
⚠ Les écritures d'un Scenario ne sont pas rollbackées. Il faut nettoyer explicitement.
Documenter et actuator
✓ Diagrammes C4, canvas par module, endpoint /actuator/modulith, spans Micrometer par frontière.
⚠ Ces sorties décrivent la structure réelle, pas l'intention. Un mauvais découpage produit une belle doc d'un mauvais découpage.
🎓 À retenir
listener_id en base est une signature de méthode. C'est un couplage silencieux entre le code et la donnée persistée. Un renommage anodin rend orphelines les publications en attente.@MockitoBean d'autres modules, le découpage est à revoir avant d'écrire la ligne suivante.EVENT_LISTENER est un couplage faible. Une flèche USES_COMPONENT est un couplage fort. Deux modules reliés uniquement par des événements sont candidats à l'extraction en service séparé. Ceux qui s'appellent par bean ne le sont pas.allowedDependencies, modules ouverts et imbriquésmodulith, spans et métriques Micrometer