Spring Modulith : architecture modulaire monolithique expliquée

Apprenez Spring Modulith pour construire des monolithes modulaires en Java. Architecture, modules, événements asynchrones, tests et observabilité avec exemples Spring Boot 3 et 4.

Spring Modulith : architecture modulaire monolithique avec Spring Boot

Spring Modulith représente une approche pragmatique pour structurer les applications Spring Boot en modules métier cohérents. Cette architecture positionne le monolithe modulaire entre le monolithe classique et les microservices, offrant une modularité forte sans la complexité opérationnelle des systèmes distribués.

Point clé

Spring Modulith formalise les bonnes pratiques d'architecture hexagonale et de Domain-Driven Design directement dans Spring Boot, avec vérification automatique des dépendances entre modules.

Pourquoi choisir le monolithe modulaire ?

Le problème du monolithe classique

Les monolithes traditionnels souffrent d'un couplage excessif entre composants. Au fil du temps, les dépendances croisées s'accumulent et transforment l'application en "big ball of mud" impossible à maintenir. Un changement dans le module de facturation impacte le module utilisateurs, puis le module notifications, créant des effets de bord imprévisibles.

AntiPattern.javajava
// Direct coupling between modules - AVOID THIS
@Service
public class OrderService {

    // Direct dependencies to other modules
    // Creates tight coupling and dependency cycles
    private final UserRepository userRepository;
    private final InventoryService inventoryService;
    private final PaymentProcessor paymentProcessor;
    private final NotificationService notificationService;
    private final ShippingCalculator shippingCalculator;

    public OrderService(UserRepository userRepository,
                        InventoryService inventoryService,
                        PaymentProcessor paymentProcessor,
                        NotificationService notificationService,
                        ShippingCalculator shippingCalculator) {
        this.userRepository = userRepository;
        this.inventoryService = inventoryService;
        this.paymentProcessor = paymentProcessor;
        this.notificationService = notificationService;
        this.shippingCalculator = shippingCalculator;
    }

    public Order createOrder(OrderRequest request) {
        // This service knows too many implementation details
        User user = userRepository.findById(request.userId()).orElseThrow();
        inventoryService.reserveItems(request.items());
        BigDecimal shipping = shippingCalculator.calculate(user.getAddress());
        paymentProcessor.charge(user, request.total().add(shipping));
        notificationService.sendOrderConfirmation(user, request);
        // ...
        return null;
    }
}

Ce pattern génère des problèmes concrets : tests d'intégration fragiles, difficultés à raisonner sur l'impact d'un changement, et impossibilité de déployer ou faire évoluer un module indépendamment.

Les microservices ne sont pas toujours la solution

Les microservices résolvent le problème du couplage mais introduisent une complexité opérationnelle significative : communication réseau, cohérence éventuelle, déploiement distribué, observabilité multi-services. Pour de nombreuses équipes, cette complexité n'est pas justifiée par les bénéfices obtenus.

Le monolithe modulaire offre une alternative : une seule unité de déploiement avec des frontières de modules clairement définies et respectées. Spring Modulith automatise la vérification de ces frontières.

Premiers pas avec Spring Modulith

Choix de la version et compatibilité Spring Boot

Spring Modulith suit deux branches de release actives avec des prérequis Spring Boot différents :

Spring ModulithSpring BootCas d'usage
2.1.x4.0+Nouveaux projets sur Spring Boot 4
1.4.x3.1 à 3.5Projets existants sur Spring Boot 3

Pour les projets sur Spring Boot 3.x, la branche 1.4 est recommandée. Les exemples ci-dessous utilisent la version 1.4.12 avec Spring Boot 3.5.

Configuration du projet

L'intégration de Spring Modulith dans un projet Spring Boot nécessite quelques dépendances Maven. Le starter principal active la détection automatique des modules.

xml
<!-- pom.xml -->
<!-- Spring Modulith dependencies for Spring Boot 3.5 -->
<dependencies>
    <!-- Core Spring Modulith -->
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-core</artifactId>
    </dependency>

    <!-- Async event support with persistence -->
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-jpa</artifactId>
    </dependency>

    <!-- Module structure tests -->
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Automatic documentation generation -->
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-docs</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.modulith</groupId>
            <artifactId>spring-modulith-bom</artifactId>
            <version>1.4.12</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Structure des modules

Spring Modulith détecte automatiquement les modules basés sur les packages directs sous le package principal de l'application. Chaque sous-package représente un module distinct avec ses propres responsabilités.

text
com.example.shop/
├── ShopApplication.java        # Spring Boot entry point
├── order/                       # Order Module
│   ├── Order.java              # Public entity (module API)
│   ├── OrderService.java       # Public service
│   ├── internal/               # Module-internal package
│   │   ├── OrderRepository.java
│   │   └── OrderValidator.java
│   └── OrderCreatedEvent.java  # Published event
├── inventory/                   # Inventory Module
│   ├── InventoryService.java
│   ├── Product.java
│   └── internal/
│       └── StockRepository.java
├── customer/                    # Customer Module
│   ├── Customer.java
│   ├── CustomerService.java
│   └── internal/
│       └── CustomerRepository.java
└── notification/                # Notification Module
    ├── NotificationService.java
    └── internal/
        └── EmailSender.java

Cette convention établit une règle fondamentale : seules les classes du package racine du module (pas dans internal/) constituent l'API publique accessible aux autres modules.

Order.javajava
// Public entity of Order module - accessible from other modules
package com.example.shop.order;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    private UUID id;

    // Reference by ID rather than entity
    // Avoids direct coupling with Customer module
    private UUID customerId;

    private BigDecimal totalAmount;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private LocalDateTime createdAt;

    protected Order() {
        // JPA constructor
    }

    public Order(UUID customerId, BigDecimal totalAmount) {
        this.id = UUID.randomUUID();
        this.customerId = customerId;
        this.totalAmount = totalAmount;
        this.status = OrderStatus.PENDING;
        this.createdAt = LocalDateTime.now();
    }

    // Public getters - part of module API
    public UUID getId() { return id; }
    public UUID getCustomerId() { return customerId; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public OrderStatus getStatus() { return status; }
    public LocalDateTime getCreatedAt() { return createdAt; }

    // Encapsulated business methods
    void confirm() {
        if (this.status != OrderStatus.PENDING) {
            throw new IllegalStateException("Only pending orders can be confirmed");
        }
        this.status = OrderStatus.CONFIRMED;
    }
}
OrderRepository.javajava
// Internal repository - NOT accessible from other modules
package com.example.shop.order.internal;

import com.example.shop.order.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;

// This repository is in the internal package
// Spring Modulith prohibits access from other modules
interface OrderRepository extends JpaRepository<Order, UUID> {

    // Methods specific to the Order module
    List<Order> findByCustomerIdAndStatus(UUID customerId, OrderStatus status);
}

Le repository reste interne car l'accès aux données passe obligatoirement par le service public, garantissant l'encapsulation de la logique métier. Ce pattern s'applique à toute entité JPA et relation dans une architecture modulaire.

Convention de nommage

Le package internal n'a rien de magique pour Java. C'est une convention que Spring Modulith reconnaît et vérifie automatiquement lors des tests. Toute violation génère une erreur explicite.

Communication entre modules

Événements de domaine

La communication entre modules s'effectue via des événements de domaine plutôt que par appels directs. Ce pattern découple les modules émetteurs des modules récepteurs.

OrderCreatedEvent.javajava
// Event published by Order module
package com.example.shop.order;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;

// Immutable record representing the event
// Contains only information needed by consumers
public record OrderCreatedEvent(
    UUID orderId,
    UUID customerId,
    BigDecimal totalAmount,
    LocalDateTime createdAt
) {
    // Factory method to create event from entity
    public static OrderCreatedEvent from(Order order) {
        return new OrderCreatedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getTotalAmount(),
            order.getCreatedAt()
        );
    }
}
OrderService.javajava
// Public service that publishes events
package com.example.shop.order;

import com.example.shop.order.internal.OrderRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.UUID;

@Service
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    public OrderService(OrderRepository orderRepository,
                        ApplicationEventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
    }

    public Order createOrder(UUID customerId, BigDecimal amount) {
        // Create the order
        Order order = new Order(customerId, amount);
        order = orderRepository.save(order);

        // Publish the event
        // Interested modules will react asynchronously
        eventPublisher.publishEvent(OrderCreatedEvent.from(order));

        return order;
    }

    public Order confirmOrder(UUID orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        order.confirm();

        // Confirmation event
        eventPublisher.publishEvent(new OrderConfirmedEvent(
            order.getId(),
            order.getCustomerId()
        ));

        return order;
    }
}

Les autres modules consomment ces événements sans connaître les détails d'implémentation du module Order.

NotificationEventListener.javajava
// Notification module consuming Order events
package com.example.shop.notification.internal;

import com.example.shop.order.OrderCreatedEvent;
import com.example.shop.order.OrderConfirmedEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;

@Component
class NotificationEventListener {

    private final EmailSender emailSender;
    private final CustomerLookup customerLookup;

    NotificationEventListener(EmailSender emailSender,
                               CustomerLookup customerLookup) {
        this.emailSender = emailSender;
        this.customerLookup = customerLookup;
    }

    // @ApplicationModuleListener guarantees async processing
    // and event persistence for retry on failure
    @ApplicationModuleListener
    void onOrderCreated(OrderCreatedEvent event) {
        // Retrieve email via local interface
        // Avoids direct dependency on Customer module
        String email = customerLookup.getEmailByCustomerId(event.customerId());

        emailSender.send(
            email,
            "Order Received",
            "Your order #%s has been received.".formatted(event.orderId())
        );
    }

    @ApplicationModuleListener
    void onOrderConfirmed(OrderConfirmedEvent event) {
        String email = customerLookup.getEmailByCustomerId(event.customerId());

        emailSender.send(
            email,
            "Order Confirmed",
            "Your order #%s is confirmed and being prepared."
                .formatted(event.orderId())
        );
    }
}

Prêt à réussir tes entretiens Spring Boot ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Événements persistés et asynchrones

Spring Modulith offre une fonctionnalité puissante : la persistence des événements. Les événements sont stockés en base de données avant publication, garantissant leur traitement même en cas de crash applicatif.

EventPublicationConfig.javajava
// Persisted events configuration
package com.example.shop.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.modulith.events.config.EnablePersistentDomainEvents;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
@EnablePersistentDomainEvents  // Enables event persistence
public class EventPublicationConfig {

    // Spring Modulith automatically creates required tables
    // EVENT_PUBLICATION stores pending events
    // Processed events are marked as completed
}
InventoryEventListener.javajava
// Listener with transactional event handling
package com.example.shop.inventory.internal;

import com.example.shop.order.OrderCreatedEvent;
import com.example.shop.order.OrderCancelledEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
class InventoryEventListener {

    private final StockRepository stockRepository;

    InventoryEventListener(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

    // Transactional processing - if exception thrown,
    // event will be retried automatically
    @ApplicationModuleListener
    @Transactional
    void onOrderCreated(OrderCreatedEvent event) {
        // Reserve stock for this order
        // On failure, event remains in EVENT_PUBLICATION
        // and will be reprocessed in next cycle
        reserveStockForOrder(event.orderId(), event.items());
    }

    @ApplicationModuleListener
    @Transactional
    void onOrderCancelled(OrderCancelledEvent event) {
        // Release reserved stock
        releaseStockForOrder(event.orderId());
    }

    private void reserveStockForOrder(UUID orderId, List<OrderItem> items) {
        for (OrderItem item : items) {
            Stock stock = stockRepository.findByProductId(item.productId())
                .orElseThrow(() -> new StockNotFoundException(item.productId()));

            stock.reserve(item.quantity());
            stockRepository.save(stock);
        }
    }

    private void releaseStockForOrder(UUID orderId) {
        // Stock release implementation
    }
}

La table EVENT_PUBLICATION créée automatiquement par Spring Modulith :

sql
-- EVENT_PUBLICATION table structure (PostgreSQL)
CREATE TABLE event_publication (
    id UUID PRIMARY KEY,
    listener_id VARCHAR(512) NOT NULL,
    event_type VARCHAR(512) NOT NULL,
    serialized_event TEXT NOT NULL,
    publication_date TIMESTAMP NOT NULL,
    completion_date TIMESTAMP
);

-- Index for retry queries
CREATE INDEX idx_event_publication_incomplete
ON event_publication (completion_date)
WHERE completion_date IS NULL;

Externalisation des événements avec JobRunr (Spring Modulith 2.1+)

Spring Modulith 2.1 introduit le support outbox pour l'externalisation des événements avec JobRunr et Namastack. Cette fonctionnalité permet une livraison fiable des événements vers des brokers de messages externes.

JobRunr outbox configuration (Spring Modulith 2.1+)java
package com.example.shop.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.modulith.events.externalize.Externalized;

@Configuration
public class EventExternalizationConfig {
    // With spring-modulith-events-jobrunr dependency,
    // events annotated with @Externalized are automatically
    // persisted as JobRunr jobs for reliable delivery
}
OrderCreatedEvent.javajava
// Event marked for external publication
package com.example.shop.order;

import org.springframework.modulith.events.externalize.Externalized;

@Externalized(target = "orders")  // Kafka topic or queue name
public record OrderCreatedEvent(
    UUID orderId,
    UUID customerId,
    BigDecimal totalAmount,
    LocalDateTime createdAt
) {
    public static OrderCreatedEvent from(Order order) {
        return new OrderCreatedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getTotalAmount(),
            order.getCreatedAt()
        );
    }
}

Ce pattern outbox garantit une livraison at-least-once : les événements survivent aux redémarrages de l'application et ne sont supprimés qu'après une publication externe réussie.

Interfaces exposées entre modules

Lorsqu'un module a besoin d'informations d'un autre module sans événement, une interface publique dans le module source permet un couplage minimal.

CustomerLookup.javajava
// Public interface of Customer module
package com.example.shop.customer;

import java.util.Optional;
import java.util.UUID;

// Interface exposed to other modules
// Defines contract without exposing implementation details
public interface CustomerLookup {

    Optional<String> findEmailById(UUID customerId);

    boolean exists(UUID customerId);

    // Specific DTO for shared information
    Optional<CustomerInfo> findInfoById(UUID customerId);

    record CustomerInfo(
        UUID id,
        String email,
        String fullName,
        String preferredLanguage
    ) {}
}
CustomerLookupImpl.javajava
// Internal implementation
package com.example.shop.customer.internal;

import com.example.shop.customer.CustomerLookup;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.UUID;

@Component
class CustomerLookupImpl implements CustomerLookup {

    private final CustomerRepository customerRepository;

    CustomerLookupImpl(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    @Override
    public Optional<String> findEmailById(UUID customerId) {
        return customerRepository.findById(customerId)
            .map(Customer::getEmail);
    }

    @Override
    public boolean exists(UUID customerId) {
        return customerRepository.existsById(customerId);
    }

    @Override
    public Optional<CustomerInfo> findInfoById(UUID customerId) {
        return customerRepository.findById(customerId)
            .map(customer -> new CustomerInfo(
                customer.getId(),
                customer.getEmail(),
                customer.getFullName(),
                customer.getPreferredLanguage()
            ));
    }
}

Cette approche permet au module Notification d'accéder aux informations client sans dépendre directement du repository ou de l'entité Customer.

Tests de structure des modules

Vérification automatique des dépendances

Spring Modulith fournit des outils de test pour vérifier que les règles d'architecture sont respectées. Ces tests échouent si un module accède aux classes internes d'un autre module.

ModularityTests.javajava
// Modular architecture verification tests
package com.example.shop;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;

class ModularityTests {

    // Load application module structure
    private final ApplicationModules modules = ApplicationModules.of(ShopApplication.class);

    @Test
    void verifyModularStructure() {
        // Verify all modules are correctly structured
        // Fails if a module accesses internal packages of another
        modules.verify();
    }

    @Test
    void printModuleOverview() {
        // Print module structure to console
        // Useful for understanding dependencies
        modules.forEach(System.out::println);
    }

    @Test
    void createModuleDocumentation() {
        // Generate automatic module documentation
        // Includes dependency diagrams
        new Documenter(modules)
            .writeModulesAsPlantUml()
            .writeIndividualModulesAsPlantUml();
    }

    @Test
    void detectCyclicDependencies() {
        // The verify() method also detects cycles
        // Module A → Module B → Module C → Module A = failure
        modules.verify();
    }
}

L'exécution de modules.verify() analyse le bytecode et détecte :

  • Les accès aux packages internal depuis d'autres modules
  • Les dépendances cycliques entre modules
  • Les violations des règles d'encapsulation

Tests d'intégration par module

Spring Modulith permet de tester chaque module en isolation, chargeant uniquement les beans nécessaires. Cette approche complète les tests d'intégration avec Testcontainers pour la vérification de la base de données.

OrderModuleIntegrationTests.javajava
// Order module integration test in isolation
package com.example.shop.order;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.Scenario;
import java.math.BigDecimal;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;

@ApplicationModuleTest  // Load only Order module and its dependencies
class OrderModuleIntegrationTests {

    @Autowired
    private OrderService orderService;

    @Test
    void shouldCreateOrder() {
        // Given
        UUID customerId = UUID.randomUUID();
        BigDecimal amount = new BigDecimal("99.99");

        // When
        Order order = orderService.createOrder(customerId, amount);

        // Then
        assertThat(order.getId()).isNotNull();
        assertThat(order.getCustomerId()).isEqualTo(customerId);
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
    }

    @Test
    void shouldPublishEventOnOrderCreation(Scenario scenario) {
        // Given
        UUID customerId = UUID.randomUUID();

        // When / Then - verify event is published
        scenario.stimulate(() -> orderService.createOrder(customerId, BigDecimal.TEN))
            .andWaitForEventOfType(OrderCreatedEvent.class)
            .matching(event -> event.customerId().equals(customerId))
            .toArriveAndVerify(event -> {
                assertThat(event.orderId()).isNotNull();
                assertThat(event.totalAmount()).isEqualTo(BigDecimal.TEN);
            });
    }

    @Test
    void shouldHandleOrderConfirmation(Scenario scenario) {
        // Given - create an order
        Order order = orderService.createOrder(UUID.randomUUID(), BigDecimal.TEN);

        // When / Then - confirm and verify event
        scenario.stimulate(() -> orderService.confirmOrder(order.getId()))
            .andWaitForEventOfType(OrderConfirmedEvent.class)
            .toArriveAndVerify(event -> {
                assertThat(event.orderId()).isEqualTo(order.getId());
            });
    }
}

L'annotation @ApplicationModuleTest configure automatiquement :

  • Le chargement des beans du module Order uniquement
  • Les mocks pour les dépendances vers d'autres modules
  • L'infrastructure de test pour les événements

Support des slice tests (Spring Modulith 2.1+)

Spring Modulith 2.1 ajoute l'intégration avec les slice tests de Spring Boot. Les tests de modules peuvent désormais être combinés avec @DataJpaTest ou @WebMvcTest pour des tests ciblés.

OrderRepositorySliceTest.javajava
// Combining module isolation with slice testing
package com.example.shop.order;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.ApplicationModuleTest.BootstrapMode;

@ApplicationModuleTest(BootstrapMode.STANDALONE)
@DataJpaTest  // Only load JPA infrastructure
class OrderRepositorySliceTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldPersistAndRetrieveOrder() {
        // Test runs with minimal context
        // Only JPA beans from Order module are loaded
    }
}
OrderNotificationIntegrationTest.javajava
// Inter-module integration test
package com.example.shop;

import com.example.shop.order.OrderService;
import com.example.shop.order.OrderCreatedEvent;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.Scenario;
import org.springframework.modulith.test.ApplicationModuleTest.BootstrapMode;
import java.math.BigDecimal;
import java.util.UUID;

// DIRECT loads all directly dependent modules
@ApplicationModuleTest(BootstrapMode.DIRECT)
class OrderNotificationIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Test
    void shouldTriggerNotificationOnOrderCreated(Scenario scenario) {
        // This test verifies Order → Notification integration
        UUID customerId = UUID.randomUUID();

        scenario.stimulate(() -> orderService.createOrder(customerId, BigDecimal.TEN))
            .andWaitForEventOfType(OrderCreatedEvent.class)
            .toArriveAndVerify(event -> {
                // Event was processed by NotificationEventListener
                // Test verifies email was sent
            });
    }
}
Isolation des tests

Utiliser BootstrapMode.STANDALONE (défaut) pour les tests unitaires de module. Réserver BootstrapMode.ALL_DEPENDENCIES aux tests d'intégration end-to-end pour éviter les dépendances cachées.

Configuration avancée des modules

Modules explicites avec @ApplicationModule

Pour les cas complexes, l'annotation @ApplicationModule permet de configurer explicitement les règles d'un module.

package-info.javajava
// Explicit Order module configuration
@org.springframework.modulith.ApplicationModule(
    // Modules allowed to depend on this one
    allowedDependencies = {"customer", "inventory"},
    // Module type: OPEN (free access) or CLOSED (explicit API)
    type = Type.CLOSED
)
package com.example.shop.order;

import org.springframework.modulith.ApplicationModule.Type;
NamedInterface.javajava
// Named interface definition for finer API control
package com.example.shop.order;

import org.springframework.modulith.NamedInterface;

// Exposes only certain classes as public API
@NamedInterface("order-api")
public class OrderApi {
    // Classes in this package are accessible via "order-api"
}
package-info.javajava
// Module depending on a specific named interface
@org.springframework.modulith.ApplicationModule(
    allowedDependencies = "order::order-api"  // Access limited to named API
)
package com.example.shop.shipping;

Gestion des dépendances circulaires

Les dépendances circulaires entre modules indiquent souvent un problème de conception. Spring Modulith les détecte et échoue lors de la vérification. La solution passe généralement par l'extraction d'un nouveau module ou l'utilisation d'événements.

java
// BEFORE - Circular dependency
// Order → Inventory (to check stock)
// Inventory → Order (to know current orders)

// AFTER - Resolution through events
// Order publishes OrderCreatedEvent
// Inventory listens and reserves stock
// Inventory publishes StockReservedEvent
// Order listens and confirms availability
StockReservedEvent.javajava
// Event published by Inventory
package com.example.shop.inventory;

import java.util.UUID;

public record StockReservedEvent(
    UUID orderId,
    UUID productId,
    int quantity,
    boolean success,
    String failureReason
) {}
OrderStockListener.javajava
// Order module listens to Inventory events
package com.example.shop.order.internal;

import com.example.shop.inventory.StockReservedEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;

@Component
class OrderStockListener {

    private final OrderRepository orderRepository;

    OrderStockListener(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @ApplicationModuleListener
    void onStockReserved(StockReservedEvent event) {
        Order order = orderRepository.findById(event.orderId())
            .orElseThrow();

        if (event.success()) {
            order.markStockReserved();
        } else {
            order.markStockUnavailable(event.failureReason());
        }

        orderRepository.save(order);
    }
}

Observabilité et monitoring

Traçage des événements

Spring Modulith s'intègre avec Micrometer pour le traçage distribué des événements entre modules. Le starter spring-modulith-starter-insight regroupe les dépendances d'observabilité.

xml
<!-- pom.xml -->
<!-- Observability starter (Spring Modulith 1.4+) -->
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-insight</artifactId>
    <scope>runtime</scope>
</dependency>
ObservabilityConfig.javajava
// Module observability configuration
package com.example.shop.config;

import io.micrometer.observation.ObservationRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.modulith.observability.ModuleEventListener;

@Configuration
public class ObservabilityConfig {

    @Bean
    ModuleEventListener moduleEventListener(ObservationRegistry registry) {
        // Adds spans for each processed event
        return new ModuleEventListener(registry);
    }
}
yaml
# application.yml
# Observability configuration
management:
  tracing:
    sampling:
      probability: 1.0  # Trace all events in dev
  endpoints:
    web:
      exposure:
        include: health,info,metrics,modulith

spring:
  modulith:
    events:
      # Retry interval for failed events
      republish-outstanding-events-on-restart: true
      # Retention duration for completed events
      completion-mode: DELETE  # or ARCHIVE

Métriques des modules (Spring Modulith 1.4+)

Spring Modulith 1.4 introduit des compteurs d'événements automatiques via l'API ModulithEventMetrics. Ces compteurs suivent les événements publiés par chaque module.

MetricsCustomization.javajava
// Custom event metrics
package com.example.shop.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.modulith.observability.ModulithEventMetricsCustomizer;

@Configuration
public class MetricsCustomization {

    @Bean
    ModulithEventMetricsCustomizer metricsCustomizer() {
        return metrics -> {
            // Add custom tags or configure metric behavior
            // Metrics automatically include module name and event type
        };
    }
}

Endpoint Actuator des modules

Spring Modulith expose un endpoint Actuator pour visualiser l'état des modules en production. Pour des métriques et une observabilité détaillées, consulter le monitoring Spring Boot Actuator.

ModulithActuatorConfig.javajava
// Actuator endpoint activation
package com.example.shop.config;

import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.modulith.actuator.ApplicationModulesEndpoint;
import org.springframework.modulith.core.ApplicationModules;

@Configuration
public class ModulithActuatorConfig {

    @Bean
    @ConditionalOnAvailableEndpoint
    ApplicationModulesEndpoint modulesEndpoint(ApplicationModules modules) {
        return new ApplicationModulesEndpoint(modules);
    }
}

L'endpoint /actuator/modulith retourne :

json
{
  "modules": [
    {
      "name": "order",
      "basePackage": "com.example.shop.order",
      "dependencies": ["customer"],
      "publishedEvents": [
        "com.example.shop.order.OrderCreatedEvent",
        "com.example.shop.order.OrderConfirmedEvent"
      ],
      "listenedEvents": [
        "com.example.shop.inventory.StockReservedEvent"
      ]
    },
    {
      "name": "inventory",
      "basePackage": "com.example.shop.inventory",
      "dependencies": [],
      "publishedEvents": [
        "com.example.shop.inventory.StockReservedEvent"
      ],
      "listenedEvents": [
        "com.example.shop.order.OrderCreatedEvent"
      ]
    }
  ]
}

Migration vers les microservices

Préparation de l'extraction

L'architecture modulaire facilite l'extraction future vers les microservices. Chaque module devient un candidat naturel pour l'extraction.

ExtractionReadinessChecker.javajava
// Extraction readiness verification
package com.example.shop;

import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.core.ApplicationModule;

public class ExtractionReadinessChecker {

    public void checkModule(String moduleName) {
        ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
        ApplicationModule module = modules.getModuleByName(moduleName)
            .orElseThrow();

        System.out.println("Module: " + moduleName);
        System.out.println("Dependencies: " + module.getDependencies());
        System.out.println("Published Events: " + module.getPublishedEvents());
        System.out.println("Listened Events: " + module.getBootstrapDependencies());

        // A module ready for extraction:
        // - Communicates only through events
        // - Has no synchronous dependencies to other modules
        // - Owns its own data tables
    }
}

Les modules qui communiquent exclusivement par événements peuvent être extraits en microservices avec un minimum de modifications : remplacer le bus d'événements local par un broker de messages (Kafka, RabbitMQ).

Sources

Avantages de l'architecture Spring Modulith

Spring Modulith apporte une solution pragmatique pour structurer les applications Spring Boot monolithiques :

  • Structure conventionnelle : les packages deviennent des modules, internal assure l'encapsulation
  • Communication découplée : événements de domaine entre modules
  • Vérification automatique : tests de structure détectant les violations
  • Événements persistés : garantie de traitement avec @ApplicationModuleListener
  • Externalisation des événements (2.1+) : pattern outbox avec JobRunr pour une livraison fiable
  • Tests isolés : @ApplicationModuleTest pour tester chaque module
  • Support des slice tests (2.1+) : combiner l'isolation de module avec les slice tests Spring Boot
  • Documentation générée : diagrammes PlantUML automatiques
  • Observabilité : intégration Micrometer, métriques d'événements et endpoint Actuator
  • Chemin vers microservices : extraction facilitée par le découplage

Cette architecture convient particulièrement aux équipes qui souhaitent structurer leur monolithe sans la complexité opérationnelle des microservices, tout en gardant la possibilité d'évoluer vers une architecture distribuée si nécessaire. Pour les questions d'entretien sur l'architecture Spring Boot, la compréhension de la conception modulaire démontre une réflexion de niveau senior.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Défi du jour

Tu saurais repérer le bug en Spring Boot ?

Un vrai bout de code, un bug caché, une tentative par jour. Sans compte pour essayer.

Anthony Fillion-Maillet

Écrit par

Anthony Fillion-Maillet

Fondateur de SharpSkill

Développeur fullstack depuis plus de 10 ans. Il dirige SharpSkill et répond de tout ce qui y est publié.

Mis à jour le 21 août 2026

Tags

#spring modulith
#architecture modulaire
#spring boot
#java
#monolithe modulaire

Partager

Articles similaires