Introduzione: Il Modular Monolith come Architettura Ibrida
Nel precedente articolo abbiamo analizzato la crisi dei microservizi e i segnali di allarme che indicano quando la distribuzione diventa un problema. Ora e il momento di esplorare la soluzione: il Modular Monolith, un'architettura che combina la semplicità operativa di un singolo processo deployabile con la modularita e l'indipendenza logica tipiche dei microservizi.
Il modular monolith non e un compromesso al ribasso, ma una scelta architetturale consapevole che permette di ottenere il meglio di entrambi i mondi. In questo articolo vedremo come progettare module boundaries, definire internal APIs, e come framework come Spring Modulith possono aiutare a mantenere l'integrita architetturale nel tempo.
Cosa Imparerai in Questo Articolo
- I principi fondamentali dell'architettura modular monolith
- Come definire module boundaries chiare e mantenibili
- Il ruolo delle internal APIs e dei contratti tra moduli
- Spring Modulith: il framework che applica le boundaries a compile time
- Confronto strutturato: monolith, modular monolith, microservizi
- Architettura di riferimento con diagrammi e codice
Principi Fondamentali del Modular Monolith
Un modular monolith si distingue da un monolith tradizionale per quattro principi architetturali che governano la struttura del codice e le interazioni tra componenti:
1. Single Deployable, Multiple Logical Modules
L'applicazione viene compilata e deployata come un singolo artefatto (un JAR, un WAR, un binario), ma internamente e organizzata in moduli logici indipendenti. Ogni modulo ha il proprio package, le proprie classi, e una superficie API pubblica ben definita. I moduli comunicano esclusivamente attraverso interfacce pubbliche, mai accedendo a classi interne di altri moduli.
2. Encapsulation e Information Hiding
Ogni modulo nasconde la propria implementazione interna dietro un'interfaccia pubblica. Le classi di dominio, i repository, i servizi interni sono inaccessibili dall'esterno. Solo le interfacce API, i DTO e gli eventi sono esposti. Questo garantisce che i moduli possano evolvere internamente senza rompere i consumatori.
3. Data Ownership
Ogni modulo e proprietario esclusivo dei propri dati. Anche se il database fisico può essere condiviso, ogni modulo accede solo alle proprie tabelle attraverso il proprio repository. Nessun modulo può leggere o scrivere direttamente nelle tabelle di un altro modulo: deve passare attraverso l'API pubblica.
4. Comunicazione Esplicita
Le interazioni tra moduli avvengono attraverso canali espliciti: chiamate di metodo attraverso interfacce, eventi di dominio, o comandi/query. Non esistono dipendenze nascoste o accessi diretti al database di altri moduli. Ogni dipendenza e visibile e tracciabile.
La Differenza Chiave
In un monolith tradizionale, qualsiasi classe può chiamare qualsiasi altra classe. In un modular monolith, i moduli possono comunicare solo attraverso interfacce pubbliche. Questa restrizione e ciò che rende possibile l'eventuale estrazione di un modulo come microservizio.
Architettura di Riferimento
Vediamo come si struttura un modular monolith in pratica. L'esempio seguente mostra un'applicazione e-commerce con quattro moduli: Order, Catalog, User e Payment.
// Struttura del progetto modular monolith
// com.ecommerce/
// ├── order/
// │ ├── api/
// │ │ ├── OrderModuleApi.java (interfaccia pubblica)
// │ │ ├── OrderDto.java (DTO pubblico)
// │ │ └── OrderCreatedEvent.java (evento pubblico)
// │ └── internal/
// │ ├── OrderService.java (logica privata)
// │ ├── OrderRepository.java (accesso dati privato)
// │ └── Order.java (entità privata)
// ├── catalog/
// │ ├── api/
// │ │ ├── CatalogModuleApi.java
// │ │ └── ProductDto.java
// │ └── internal/
// │ ├── CatalogService.java
// │ └── Product.java
// ├── user/
// │ ├── api/
// │ │ └── UserModuleApi.java
// │ └── internal/
// │ └── UserService.java
// └── payment/
// ├── api/
// │ └── PaymentModuleApi.java
// └── internal/
// └── PaymentService.java
Definizione delle Internal APIs
Ogni modulo espone un'interfaccia che definisce il contratto pubblico del modulo.
Questa interfaccia e l'unico punto di accesso per gli altri moduli. L'implementazione e nascosta
nel package internal.
// API pubblica del modulo Order
package com.ecommerce.order.api;
public interface OrderModuleApi {
/**
* Crea un nuovo ordine.
* Pubblica OrderCreatedEvent al completamento.
*/
OrderDto createOrder(CreateOrderCommand command);
/**
* Recupera un ordine per ID.
* @throws OrderNotFoundException se l'ordine non esiste
*/
Optional<OrderDto> findById(UUID orderId);
/**
* Lista ordini di un utente con paginazione.
*/
Page<OrderDto> findByUserId(UUID userId, Pageable pageable);
/**
* Annulla un ordine esistente.
* Pubblica OrderCancelledEvent al completamento.
*/
void cancelOrder(UUID orderId);
}
// DTO pubblico - solo dati, nessuna logica di dominio
public record OrderDto(
UUID id,
UUID userId,
List<OrderItemDto> items,
BigDecimal total,
OrderStatus status,
Instant createdAt
) {}
Implementazione Interna del Modulo
L'implementazione del modulo e completamente nascosta. Le classi nel package internal
non sono accessibili dall'esterno. Questo permette di refactorizzare liberamente l'implementazione
senza impattare i consumatori.
// Implementazione privata del modulo Order
package com.ecommerce.order.internal;
@Service
class OrderServiceImpl implements OrderModuleApi {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
private final CatalogModuleApi catalogModule; // dipendenza da altro modulo
OrderServiceImpl(OrderRepository orderRepository,
ApplicationEventPublisher eventPublisher,
CatalogModuleApi catalogModule) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
this.catalogModule = catalogModule;
}
@Override
@Transactional
public OrderDto createOrder(CreateOrderCommand command) {
// Verifica prodotti tramite API pubblica del modulo Catalog
List<ProductDto> products = command.getItemIds().stream()
.map(catalogModule::findById)
.map(opt -> opt.orElseThrow(ProductNotFoundException::new))
.toList();
// Crea l'entità di dominio (classe privata)
Order order = Order.create(command.getUserId(), products);
orderRepository.save(order);
// Pubblica evento per altri moduli interessati
eventPublisher.publishEvent(
new OrderCreatedEvent(order.getId(), order.getUserId())
);
return order.toDto();
}
}
Spring Modulith: Boundaries a Compile Time
Spring Modulith e un framework ufficiale di Spring che fornisce strumenti per strutturare, verificare e documentare modular monoliths in Spring Boot. La sua caratteristica principale e la capacità di verificare le boundaries tra moduli a compile time, impedendo accessi non autorizzati tra package.
Configurazione di Spring Modulith
<!-- pom.xml - Dipendenze Spring Modulith -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
Test di Verifica delle Boundaries
Spring Modulith fornisce un test automatico che verifica che le regole di accesso tra moduli siano rispettate. Se un modulo accede a classi interne di un altro modulo, il test fallisce.
// Test che verifica l'integrita delle boundaries
@Test
void verifyModularStructure() {
ApplicationModules modules = ApplicationModules.of(
EcommerceApplication.class
);
// Verifica che nessun modulo acceda a classi interne
// di un altro modulo
modules.verify();
// Genera documentazione automatica dei moduli
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
// Output del test in caso di violazione:
// Module 'payment' depends on non-exposed type
// com.ecommerce.order.internal.Order
// from module 'order'
Event-based Communication con Spring Modulith
Spring Modulith supporta nativamente la comunicazione tra moduli basata su eventi. Gli eventi
vengono pubblicati tramite ApplicationEventPublisher e consumati con
@ApplicationModuleListener, garantendo disaccoppiamento tra produttore e consumatore.
// Modulo Order: pubblica un evento
@Service
class OrderServiceImpl implements OrderModuleApi {
private final ApplicationEventPublisher events;
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepository.save(order);
events.publishEvent(new OrderCreatedEvent(
order.getId(), order.getUserId(), order.getTotal()
));
return order.toDto();
}
}
// Modulo Payment: reagisce all'evento
@Service
class PaymentEventHandler {
@ApplicationModuleListener
void onOrderCreated(OrderCreatedEvent event) {
// Processa il pagamento quando un ordine viene creato
paymentService.processPayment(
event.userId(), event.total()
);
}
}
// Modulo Notification: reagisce allo stesso evento
@Service
class NotificationEventHandler {
@ApplicationModuleListener
void onOrderCreated(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(event.userId());
}
}
Confronto: Monolith vs Modular Monolith vs Microservizi
Per comprendere dove si posiziona il modular monolith nel panorama architetturale, confrontiamo le tre architetture su dimensioni chiave:
Complessità Operativa
- Monolith tradizionale: Bassa. Un deploy, un processo, log centralizzati. Ma il codice interno può diventare un groviglio
- Modular Monolith: Bassa. Stessi vantaggi operativi del monolith, con codice interno strutturato e manutenibile
- Microservizi: Alta. N pipeline, N deploy, distributed tracing, service mesh, network policies
Scalabilità
- Monolith tradizionale: Orizzontale (più istanze). Sufficiente per il 95% dei casi
- Modular Monolith: Orizzontale + possibilità di estrarre moduli critici come servizi indipendenti
- Microservizi: Indipendente per servizio. Necessario solo per carichi molto asimmetrici
Autonomia dei Team
- Monolith tradizionale: Limitata. Tutti lavorano sullo stesso codebase senza confini chiari
- Modular Monolith: Moderata. Team per modulo con interfacce chiare, deploy condiviso
- Microservizi: Alta. Team completamente indipendenti, deploy indipendenti
Costi
- Monolith tradizionale: Minimi. Infrastruttura semplice
- Modular Monolith: Minimi-moderati. Stesso costo infrastrutturale del monolith
- Microservizi: Elevati. 6x rispetto al monolith secondo stime di settore
Il Sweet Spot del Modular Monolith
Il modular monolith si posiziona nel punto ottimale per la maggior parte delle organizzazioni: mantiene i costi operativi bassi del monolith, offre la modularita necessaria per team di medie dimensioni, e preserva la possibilità di estrarre microservizi in futuro quando i dati lo giustificano.
Quando Estrarre un Modulo come Microservizio
Il modular monolith non e necessariamente il punto di arrivo. E piuttosto un punto di partenza solido da cui estrarre microservizi quando necessario. Ma come sapere quando e il momento giusto?
Ecco i segnali che giustificano l'estrazione di un modulo:
- Scaling asimmetrico: il modulo richiede 10x più risorse rispetto agli altri
- Team dedicato: un team di 5+ persone lavora esclusivamente su quel modulo e necessità di deploy indipendente
- Stack tecnologico diverso: il modulo beneficerebbe di un linguaggio o runtime diverso
- Frequenza di rilascio diversa: il modulo necessità di rilasci giornalieri mentre il resto del sistema e settimanale
- Isolation failure: un bug nel modulo può causare il crash dell'intero sistema
Implementazione Pratica: Primo Passo
Se hai un monolith esistente e vuoi iniziare la transizione verso un modular monolith, il primo
passo e organizzare il codice in package per feature piuttosto che per layer tecnico.
Invece di separare per controller, service, repository,
separa per order, catalog, user.
// PRIMA: organizzazione per layer tecnico (sbagliato)
// com.app/
// ├── controller/
// │ ├── OrderController.java
// │ ├── UserController.java
// │ └── CatalogController.java
// ├── service/
// │ ├── OrderService.java
// │ ├── UserService.java
// │ └── CatalogService.java
// └── repository/
// ├── OrderRepository.java
// ├── UserRepository.java
// └── CatalogRepository.java
// DOPO: organizzazione per feature/modulo (corretto)
// com.app/
// ├── order/
// │ ├── api/OrderModuleApi.java
// │ └── internal/
// │ ├── OrderController.java
// │ ├── OrderService.java
// │ └── OrderRepository.java
// ├── user/
// │ ├── api/UserModuleApi.java
// │ └── internal/
// │ ├── UserController.java
// │ ├── UserService.java
// │ └── UserRepository.java
// └── catalog/
// ├── api/CatalogModuleApi.java
// └── internal/
// ├── CatalogController.java
// ├── CatalogService.java
// └── CatalogRepository.java
Prossimi Passi
In questo articolo abbiamo definito i principi fondamentali del modular monolith e visto come strutturare il codice con boundaries chiare. Nei prossimi articoli approfondiremo ogni aspetto:
- Articolo 3: Come usare il Domain Driven Design per identificare i bounded contexts e definire i confini dei moduli
- Articolo 4: Come progettare il database per un modular monolith, con pattern per schema condiviso e separato
- Articolo 5: I pattern di comunicazione tra moduli: sincroni, asincroni ed event-driven
Prossimo Articolo
Nel prossimo articolo esploreremo il Domain Driven Design e come utilizzarlo per identificare i bounded contexts della tua applicazione. Vedremo concetti come ubiquitous language, context mapping, aggregate design, e come tradurli in confini di modulo concreti nel codice.







