Le monolithe modulaire avec Gradle et Spring Modulith
Une application Spring Boot de quatre ans. Un seul module de build, deux cents packages, et n’importe quelle classe peut importer n’importe quelle autre. Le service des commandes lit directement la table des produits. Le stock appelle directement les commandes, qui appellent le stock. Personne ne sait plus quel service dépend de quel autre.
À ce stade, la réponse qu’on entend le plus souvent, c’est « on va découper en microservices ». C’est une réponse qui peut rapidement coûter très cher. Elle remplace des appels de méthode par des appels réseau, une transaction par plusieurs, un déploiement par dix. Et elle ne règle pas le vrai problème, qui est l’absence de frontières. Des services qui s’appellent tous entre eux, c’est le même désordre, avec de la latence en plus.
Il existe une autre solution avant de changer complètement l’architecture. Garder un seul déployable, et poser de vraies frontières à l’intérieur. C’est ce qu’on appelle un monolithe modulaire. Spring Modulith permet de vérifier les frontières, et Gradle de les faire vérifier durant la phase de build.
Tous les exemples de cet article ont été exécutés sur Spring Boot 4.1.1, Spring Modulith 2.1.1, Gradle 9.5.1 et JDK 25.
Un module, c’est une fonctionnalité métier
Le mot « module » a plusieurs sens selon le contexte. Ici, il en a un seul : une partie de l’application qui correspond à une fonctionnalité métier, avec une API publique et une logique privée non exposée à l’extérieur.
Pour une application de gestion de commandes, les modules sont par exemple : catalog, order et inventory. Pas controllers, services et repositories. Un découpage par couche technique met dans le même package tout ce qui se ressemble techniquement. Un découpage par module met ensemble tout ce qui change pour la même raison.
Le point qui compte, c’est la frontière. Un module expose quelques types et quelques méthodes. Le reste est propre à lui. Un autre module qui a besoin de quelque chose doit passer par l’API, et jamais par le code interne au module.
Sur ce point, c’est la même idée que dans l’architecture hexagonale. La différence, c’est l’échelle. L’architecture hexagonale protège le domaine d’un module contre l’implémentation technique (e.g. framework). Le monolithe modulaire protège les modules les uns des autres et permet de clarifier leurs frontières.
Les conventions de Spring Modulith
Spring Modulith est un projet officiel de Spring. Il ne demande pas de restructurer son application. Il lit les packages et en déduit les modules, avec trois conventions :
- chaque sous-package direct du package de l’application est un module. Avec
ShopApplicationdansshop, les modules sontshop.catalog,shop.orderetshop.inventory; - les types placés à la racine du module représentent son API publique.
shop.catalog.Catalogetshop.catalog.Productsont visibles par les autres modules ; - tout ce qui est dans un sous-package est interne.
shop.catalog.internal.ProductStoreest propre au modulecatalog.
L’arborescence de départ :
shop
├── ShopApplication
├── catalog
│ ├── Catalog // API
│ ├── Product // API
│ └── internal
│ └── ProductStore
├── order
│ ├── Orders // API
│ ├── Order // API
│ └── internal
│ └── OrderRepository
└── inventory
└── Inventory // API
Le BOM et deux dépendances suffisent, une pour le code et une pour les tests :
implementation(platform("org.springframework.modulith:spring-modulith-bom:2.1.1"))
implementation("org.springframework.modulith:spring-modulith-starter-core")
testImplementation("org.springframework.modulith:spring-modulith-starter-test")
Un premier test affiche ce que Modulith a détecté :
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void printsModules() {
modules.forEach(System.out::println);
}
}
# Catalog
> Logical name: catalog
> Base package: shop.catalog
> Excluded packages: none
> Spring beans:
+ ….Catalog
o ….internal.ProductStore
# Inventory
> Logical name: inventory
> Base package: shop.inventory
> Excluded packages: none
> Spring beans:
+ ….Inventory
# Order
> Logical name: order
> Base package: shop.order
> Excluded packages: none
> Spring beans:
+ ….Orders
o ….internal.OrderRepository
Le + marque un bean exposé, le o un bean interne. Sans rien configurer, l’outil a déjà la même carte du code que vous.
Le test qui protège la frontière
Une ligne de test :
@Test
void verifiesModuleStructure() {
modules.verify();
}
Par défaut, ce test vérifie deux choses. Aucun module n’utilise le code interne d’un autre. Et il n’y a pas de cycle entre modules. Si un module déclare ses dépendances autorisées avec @ApplicationModule(allowedDependencies = ...), le test les vérifie aussi.
Essayons de casser la première règle. Le service des commandes a besoin du prix d’un produit. Le plus court, c’est d’injecter directement le ProductStore du catalogue :
@Service
public class Orders {
private final ProductStore products; // shop.catalog.internal
private final OrderRepository repository;
Orders(ProductStore products, OrderRepository repository) { ... }
public Order place(String sku, int quantity) {
var product = products.find(sku).orElseThrow();
var order = new Order(sku, quantity, product.priceInCents() * quantity);
repository.save(order);
return order;
}
}
Ça compile. Spring injecte le bean sans discuter. Mais le test échoue :
org.springframework.modulith.core.Violations:
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Orders declares constructor Orders(ProductStore, OrderRepository) in (Orders.java:0)
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Method <shop.order.Orders.place(java.lang.String, int)> calls method <shop.catalog.internal.ProductStore.find(java.lang.String)> in (Orders.java:19)
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Field <shop.order.Orders.products> has type <shop.catalog.internal.ProductStore> in (Orders.java:0)
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Constructor <shop.order.Orders.<init>(shop.catalog.internal.ProductStore, shop.order.internal.OrderRepository)> has parameter of type <shop.catalog.internal.ProductStore> in (Orders.java:0)
Quatre violations pour une seule erreur : le champ, le constructeur, son paramètre et l’appel de méthode. Chaque ligne nomme le module fautif, le type interdit et l’endroit exact. Le :0 sur le champ et le constructeur n’est pas un bug. Modulith s’appuie sur ArchUnit, qui lit le bytecode. Seuls les accès faits dans le corps d’une méthode, comme un appel, y gardent un numéro de ligne. Une déclaration, comme le type d’un champ ou le paramètre d’un constructeur, n’en a pas. C’est le même détail que dans l’article sur ArchUnit et Konsist.
La correction consiste à passer par l’API du catalogue :
@Service
public class Orders {
private final Catalog catalog; // shop.catalog, exposé
private final OrderRepository repository;
public Order place(String sku, int quantity) {
var product = catalog.findBySku(sku).orElseThrow();
...
}
}
Le test repasse au vert. Et si vous voulez exposer un sous-package entier, par exemple shop.catalog.api, un @NamedInterface sur son package-info.java suffit. Le reste continue d’être interne au module.
Les cycles
La deuxième règle est celle des cycles. Elle est moins visible, et plus grave.
Un cycle, c’est deux modules qui ont besoin l’un de l’autre. Ici, la commande consomme le stock au moment où elle est passée. Et le stock, pour savoir ce qui est consommé, reçoit la commande :
// shop.order
public Order place(String sku, int quantity) {
...
repository.save(order);
inventory.reserve(order);
return order;
}
// shop.inventory
public void reserve(Order order) {
stock.merge(order.sku(), -order.quantity(), Integer::sum);
}
Chaque module n’utilise que l’API de l’autre. La première règle est respectée. Et pourtant :
org.springframework.modulith.core.Violations: - Cycle detected: Slice inventory ->
Slice order ->
Slice inventory
1. Dependencies of Slice inventory
- Method <shop.inventory.Inventory.reserve(shop.order.Order)> has parameter of type <shop.order.Order> in (Inventory.java:0)
- Method <shop.inventory.Inventory.reserve(shop.order.Order)> calls method <shop.order.Order.quantity()> in (Inventory.java:18)
- Method <shop.inventory.Inventory.reserve(shop.order.Order)> calls method <shop.order.Order.sku()> in (Inventory.java:18)
2. Dependencies of Slice order
- Constructor <shop.order.Orders.<init>(shop.catalog.Catalog, shop.inventory.Inventory, shop.order.internal.OrderRepository)> has parameter of type <shop.inventory.Inventory> in (Orders.java:0)
- Field <shop.order.Orders.inventory> has type <shop.inventory.Inventory> in (Orders.java:0)
- Method <shop.order.Orders.place(java.lang.String, int)> calls method <shop.inventory.Inventory.reserve(shop.order.Order)> in (Orders.java:25)
Le message donne le cycle complet, puis chaque dépendance qui le forme, dans les deux sens.
Pourquoi c’est grave : deux modules en cycle n’en font qu’un. Impossible d’en tester un sans l’autre. Impossible d’en sortir un plus tard. Et un cycle en appelle un autre : dès qu’il y en a un, le suivant coûte moins cher à ajouter qu’à éviter.
Casser le cycle avec un événement
Dans un cycle, un des deux sens est presque toujours un « quand ceci arrive, fais cela ». Ici : quand une commande est passée, décrémente la quantité du stock. Ce sens-là n’a pas besoin d’un appel. Il a besoin d’un événement.
La commande publie un fait, sans savoir qui écoute :
// shop.order
public record OrderPlaced(String sku, int quantity) {}
@Transactional
public Order place(String sku, int quantity) {
...
repository.save(order);
events.publishEvent(new OrderPlaced(sku, quantity));
return order;
}
Le stock écoute :
// shop.inventory
@ApplicationModuleListener
void on(OrderPlaced event) {
stock.merge(event.sku(), -event.quantity(), Integer::sum);
}
Le module inventory dépend toujours de order, pour le type OrderPlaced. Mais order ne connaît plus inventory. Le cycle est cassé, et verify() repasse au vert.
@ApplicationModuleListener n’est pas un simple @EventListener. Il se déclenche après le commit de la transaction qui a publié l’événement. Il s’exécute dans un autre thread, ici task-1, et dans sa propre transaction. Si le listener échoue, la commande est déjà enregistrée, et l’événement reste noté comme non traité dans un registre en base de données.
L’annotation vit dans spring-modulith-events-api, et le registre a besoin d’une base. Le starter de base n’apporte ni l’un ni l’autre, un starter de persistance apporte les deux. Il faut donc trois dépendances de plus, ici avec H2 comme base de données :
implementation("org.springframework.modulith:spring-modulith-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
runtimeOnly("com.h2database:h2")
Et une propriété pour que la table du registre soit créée au démarrage :
spring.modulith.events.jdbc.schema-initialization.enabled=true
Sans gestionnaire de transactions, il n’y a pas de commit, donc pas de déclenchement. Le test de la section suivante le dit d’ailleurs sans détour si on l’oublie : To use a Scenario in an integration test you need to define a bean of type TransactionTemplate!.
Un événement rend le code moins direct à lire. On ne voit plus dans place() que le stock bouge. C’est le prix à payer. Il ne se justifie que pour casser un cycle, ou pour isoler un module qu’on veut sortir un jour. Pour un module qui a simplement besoin d’une réponse, un appel à l’API reste le bon choix.
Tester un module seul
Un monolithe classique a un seul type de test d’intégration : celui qui démarre tout. Modulith en ajoute un par module. @ApplicationModuleTest ne démarre que le module du test. Les beans des autres modules ne sont pas chargés, et si le module en a besoin, il faut les remplacer par des mocks :
@ApplicationModuleTest
class InventoryTests {
@Autowired Inventory inventory;
@Test
void anOrderRemovesStock(Scenario scenario) {
scenario.publish(new OrderPlaced("LAMP-01", 3))
.andWaitForStateChange(() -> inventory.available("LAMP-01"))
.andVerify(available -> assertThat(available).isEqualTo(7));
}
}
Scenario est fourni par Modulith. Il publie l’événement, attend que l’état change, puis vérifie. Sans lui, il faudrait un Thread.sleep ou une boucle d’attente, parce que le listener est asynchrone.
Le log de démarrage dit exactement ce qui a été chargé :
Bootstrapping @org.springframework.modulith.test.ApplicationModuleTest for Inventory in mode STANDALONE (class shop.ShopApplication)…
> Logical name: inventory
Pour un module qui dépend d’un autre, il y a un mode qui embarque ses dépendances directes. C’est le cas d’order, qui a besoin de catalog :
@ApplicationModuleTest(mode = BootstrapMode.DIRECT_DEPENDENCIES)
class OrdersTests {
@Autowired Orders orders;
@Test
void placingAnOrderPublishesAnEvent(Scenario scenario) {
scenario.stimulate(() -> orders.place("LAMP-01", 2))
.andWaitForEventOfType(OrderPlaced.class)
.toArriveAndVerify(event -> assertThat(event.quantity()).isEqualTo(2));
}
}
Bootstrapping @org.springframework.modulith.test.ApplicationModuleTest for Order in mode DIRECT_DEPENDENCIES (class shop.ShopApplication)…
> Logical name: order
Included dependencies:
> Logical name: catalog
Ce test ne sait rien du stock. Il vérifie que la commande publie le bon événement, et s’arrête là. C’est ce qu’un module bien découpé permet : un test qui démarre vite, et qui ne casse pas quand un autre module change.
Quand le compilateur doit vérifier la règle
Tout ce qui précède tient dans un seul module de build. Les frontières existent, mais elles sont vérifiées par un test. Tant que le test tourne, ça suffit.
Il y a des cas où on veut plus. Une équipe par module, avec un temps de build à isoler. Un module qu’on prévoit de sortir dans un service à part. Ou simplement l’envie que l’erreur arrive dans l’IDE, avant même de lancer les tests. Dans ces cas, on passe en multi-module Gradle, et c’est le compilateur qui porte la règle.
Un sous-projet par module, plus un pour l’application :
// settings.gradle.kts
rootProject.name = "shop"
include("catalog", "order", "inventory", "app")
Chaque sous-projet déclare ce dont il a besoin, et seulement ça :
// order/build.gradle.kts
dependencies {
implementation(project(":catalog"))
implementation("org.springframework:spring-context")
implementation("org.springframework:spring-tx")
}
// inventory/build.gradle.kts
dependencies {
implementation(project(":order"))
implementation("org.springframework:spring-context")
implementation("org.springframework.modulith:spring-modulith-events-api")
}
Le sous-projet app a le plugin Spring Boot, les starters, et dépend des trois autres. C’est lui qui porte ShopApplication et le test verify(). Modulith lit les classes sur le classpath, peu importe qu’elles viennent d’un dossier ou d’un jar d’un autre sous-projet. Le test affiche les mêmes trois modules qu’avant.
Ce qui change, c’est ce qui arrive quand on oublie une dépendance. Retirez project(":catalog") du sous-projet order :
> Task :order:compileJava FAILED
.../order/src/main/java/shop/order/Orders.java:6: error: package shop.catalog does not exist
import shop.catalog.Catalog;
^
Pas de test à lancer. L’IDE souligne l’import en rouge avant le commit.
Le cycle aussi devient une erreur de build. Ajoutez project(":inventory") aux dépendances d’order, alors qu’inventory dépend déjà d’order :
FAILURE: Build failed with an exception.
* What went wrong:
Circular dependency between the following tasks:
:inventory:compileJava
\--- :order:compileJava
\--- :inventory:compileJava (*)
Gradle refuse de calculer l’ordre de compilation. Il n’y a rien à vérifier, parce que rien ne compile.
Un mot sur implementation et api. Avec implementation(project(":catalog")), les types du catalogue ne sont visibles que dans order. Un troisième sous-projet qui dépend d’order ne les voit pas. C’est le comportement à garder par défaut. api fait remonter la dépendance, et il ne sert que si l’API d’order expose des types du catalogue dans ses signatures.
Ce que Gradle ne voit pas
Gradle connaît les sous-projets. Il ne connaît pas vos packages. Remettez dans order l’injection directe de shop.catalog.internal.ProductStore du début de l’article, avec project(":catalog") bien déclaré :
> Task :order:compileJava
BUILD SUCCESSFUL
Le sous-projet catalog est sur le classpath d’order, donc toutes ses classes le sont, internal compris. Seul verify() le voit :
org.springframework.modulith.core.Violations:
- Module 'order' depends on non-exposed type shop.catalog.internal.ProductStore within module 'catalog'!
Le découpage Gradle et le test Modulith ne se remplacent pas. Gradle vérifie le sens des dépendances entre modules. Modulith porte ce qu’un module a le droit de voir chez l’autre. Il faut les deux, et ils tiennent en un fichier de build par sous-projet et un test.
Une documentation qui ne ment pas
Un dernier test, une ligne, et Modulith écrit la documentation des modules :
@Test
void writesDocumentation() {
new Documenter(modules).writeDocumentation();
}
Dans build/spring-modulith-docs/, on trouve un diagramme C4 de l’ensemble et un canevas par module. Dans le diagramme, en PlantUML, les seules lignes qui comptent sont les deux relations, juste avant la légende :
Rel(ShopApplication.ShopApplication.Order, ShopApplication.ShopApplication.Catalog, "uses", ...)
Rel(ShopApplication.ShopApplication.Inventory, ShopApplication.ShopApplication.Order, "listens to", ...)
order utilise catalog. inventory écoute order. C’est exactement l’application, parce que c’est généré depuis les classes, à chaque build. Le canevas du module inventory dit la même chose, en tableau :
|Base package
|`shop.inventory`
|Spring components
|_Services_
* `s.i.Inventory`
|Events listened to
|* `s.o.OrderPlaced` (async)
Un schéma dans un wiki est faux au bout de trois mois. Celui-ci ne peut pas l’être : s’il ne correspond plus au code, c’est que verify() est déjà rouge.
Où poser les frontières
L’outil vérifie les frontières. Il ne vous dit pas où les mettre. Quelques repères qui tiennent dans le temps.
Découpez par capacité métier, et non pas par couche technique. Si un module s’appelle services, ce n’est pas un module.
Trois ou quatre modules sur une application moyenne, pas quinze. Un module se coupe en deux plus tard sans problème. Deux modules qui auraient dû n’en faire qu’un, ça se voit au niveau des cycles.
Se baser sur le vocabulaire métier. Une frontière de module qui traverse le vocabulaire d’une équipe est au mauvais endroit. Un module correspond souvent à un mot que les équipes métier utilisent seul : le catalogue, les commandes, le stock.
Gardez ce que le monolithe donne gratuitement. Une transaction qui couvre deux modules reste possible, et parfois c’est la bonne réponse. Un refactoring qui traverse trois modules peut se faire dans l’IDE, en un seul commit. Ce sont des avantages réels, et le monolithe modulaire les garde tous. Le jour où un module doit vraiment être extrait, il a déjà une API, des événements et des tests propres à lui. Ce jour-là, le travail est plus simple. Et souvent, ce jour n’arrivera jamais.
En résumé
Un monolithe n’est pas un problème. Un monolithe où tout importe tout en est un. Avant de penser microservices, il y a une étape moins chère : garder un seul déployable et poser des frontières claires dedans.
Spring Modulith lit vos packages et en fait des modules, avec une API à la racine et le code interne en dessous. Un test d’une ligne, verify(), refuse qu’un module touche au code interne d’un autre, et refuse les cycles. Un événement casse un cycle quand un des deux sens est un « quand ceci arrive ». Et @ApplicationModuleTest permet de tester un module sans démarrer les autres.
Quand on veut que l’erreur arrive au niveau du build, on passe en multi-module Gradle. Ce dernier valide alors le sens des dépendances et refuse les cycles. Mais il ne connaît pas les packages : le test Modulith reste nécessaire pour protéger le code interne. Les deux ensemble, c’est un fichier de build par sous-projet et un test.