Introduzione: La Migrazione come Processo Incrementale
Migrare un monolith legacy a un modular monolith non e un progetto big-bang che richiede mesi di riscrittura e un deploy rischioso. E un processo incrementale, sicuro e reversibile, che può procedere in parallelo allo sviluppo di nuove feature. Ogni passo della migrazione produce un sistema funzionante e testabile, riducendo il rischio a ogni iterazione.
In questo articolo presenteremo un playbook di migrazione in 4 fasi: dall'audit del codice all'identificazione dei confini, dalla separazione fisica dei moduli all'introduzione della comunicazione event-driven. Includeremo una timeline realistica, strategie di testing, e gli anti-pattern da evitare.
Cosa Imparerai in Questo Articolo
- Il pattern Strangler Fig applicato alla modularizzazione interna
- Fase 1: Code audit e identificazione dei confini con DDD
- Fase 2: Separazione fisica in package e moduli
- Fase 3: Estrazione delle API e definizione dei contratti interni
- Fase 4: Migrazione alla comunicazione event-driven
- Strategia di testing per ogni fase
- Rollback strategy e gestione del rischio
- Timeline realistica: 2-6 mesi a rischio controllato
- Anti-pattern e errori comuni da evitare
Il Pattern Strangler Fig
Il Strangler Fig Pattern, originariamente concepito per migrare da monolith a microservizi, si applica efficacemente anche alla modularizzazione interna. L'idea e semplice: invece di riscrivere tutto, avvolgi gradualmente le parti del monolith legacy con moduli ben strutturati, fino a quando il codice legacy viene completamente sostituito.
Il processo funziona cosi:
- Identifica un'area funzionale del monolith legacy
- Crea un nuovo modulo con boundaries chiare che implementa la stessa funzionalità
- Instrada gradualmente il traffico dal codice legacy al nuovo modulo
- Quando il nuovo modulo e stabile, rimuovi il codice legacy
- Ripeti per la prossima area funzionale
Vantaggio Chiave dello Strangler Fig
Il sistema rimane sempre funzionante durante la migrazione. Non esiste un momento in cui il sistema e "mezzo migrato e non funzionante". Ogni incremento produce un sistema completo e testabile, con la possibilità di rollback immediato.
Fase 1: Code Audit e Identificazione dei Confini
La prima fase e puramente analitica: non si modifica codice, si analizza. L'obiettivo e comprendere la struttura attuale del monolith e identificare i confini naturali dei moduli.
1.1 Analisi delle Dipendenze
Usa strumenti di analisi statica per mappare le dipendenze tra classi e package. Strumenti come JDepend, ArchUnit, o Structure101 possono generare grafi delle dipendenze che rivelano cluster naturali e accoppiamenti problematici.
// ArchUnit: analizza le dipendenze esistenti
@Test
void analyzeDependencies() {
JavaClasses classes = new ClassFileImporter()
.importPackages("com.legacy.app");
// Identifica i cicli di dipendenze
SliceRule noCycles = SlicesRuleDefinition
.slices()
.matching("com.legacy.app.(*)..")
.should().beFreeOfCycles();
// Questo test probabilmente fallirà nel monolith legacy
// Il report mostra esattamente dove sono i cicli
try {
noCycles.check(classes);
} catch (AssertionError e) {
// Analizza i cicli per identificare i confini
System.out.println("Dependency cycles found:");
System.out.println(e.getMessage());
}
}
1.2 Event Storming con il Team
Organizza una sessione di Event Storming con sviluppatori e stakeholder di business per identificare i bounded contexts. Questa sessione produce una mappa dei domini di business che diventera la base per i confini dei moduli.
1.3 Prioritizzazione
Non tutti i moduli hanno la stessa urgenza. Prioritizza basandoti su:
- Frequenza di cambiamento: i moduli che cambiano più spesso beneficiano prima della modularizzazione
- Complessità: i moduli più complessi necessitano di confini chiari prima degli altri
- Accoppiamento: inizia dai moduli con meno dipendenze esterne (più facili da estrarre)
- Valore di business: i moduli critici per il business meritano attenzione prioritaria
Fase 2: Separazione Fisica
In questa fase, si riorganizza il codice sorgente da un'organizzazione per layer tecnico a un'organizzazione per modulo funzionale. Questa e la modifica più visibile e può essere fatta in modo completamente retrocompatibile.
// Migrazione della struttura dei package
// FASE 2.1: Crea la nuova struttura
// PRIMA (layer-based):
// com.app.controller.OrderController
// com.app.service.OrderService
// com.app.repository.OrderRepository
// com.app.model.Order
// DOPO (module-based):
// com.app.order.api.OrderModuleApi
// com.app.order.internal.OrderController
// com.app.order.internal.OrderService
// com.app.order.internal.OrderRepository
// com.app.order.internal.domain.Order
// FASE 2.2: Sposta le classi una alla volta
// Usa il refactoring "Move Class" dell'IDE
// Il compilatore segnala immediatamente le dipendenze rotte
// FASE 2.3: Verifica che il build funzioni dopo ogni spostamento
// ./gradlew build
// Se il build fallisce, correggi le dipendenze o
// rollback lo spostamento
Strategia di Testing per la Fase 2
La separazione fisica non deve cambiare il comportamento. La strategia di testing e:
- Test di regressione: esegui l'intera suite di test dopo ogni spostamento
- Test di compilazione: il compilatore e il tuo primo test, verifica che il build funzioni
- Test end-to-end: verifica che i flussi utente principali funzionino
- Commit frequenti: ogni spostamento di classe e un commit, per rollback granulare
Fase 3: Estrazione delle API
Una volta che il codice e organizzato per modulo, la fase 3 introduce le interfacce
pubbliche (API) tra moduli. Ogni modulo espone un'interfaccia nel package api
e nasconde l'implementazione nel package internal.
// Fase 3: Estrazione dell'API dal codice esistente
// PASSO 1: Identifica i metodi chiamati da altri moduli
// Cerca tutti gli usi di OrderService al di fuori del package order
// grep -r "OrderService" --include="*.java" | grep -v "order/"
// PASSO 2: Crea l'interfaccia pubblica con solo i metodi necessari
package com.app.order.api;
public interface OrderModuleApi {
// Solo i metodi usati da altri moduli
OrderDto createOrder(CreateOrderCommand cmd);
Optional<OrderDto> findById(UUID id);
List<OrderDto> findByUserId(UUID userId);
}
// PASSO 3: Implementa l'interfaccia nel servizio esistente
package com.app.order.internal;
@Service
class OrderService implements OrderModuleApi {
// Il codice esistente non cambia
// Aggiungi solo "implements OrderModuleApi"
// e i metodi toDto() per le conversioni
@Override
public OrderDto createOrder(CreateOrderCommand cmd) {
// Logica esistente...
Order order = new Order(cmd);
orderRepository.save(order);
return OrderDto.from(order);
}
}
// PASSO 4: Aggiorna i chiamanti per usare l'interfaccia
// Prima: private final OrderService orderService;
// Dopo: private final OrderModuleApi orderModule;
Fase 4: Comunicazione Event-Driven
L'ultima fase introduce la comunicazione basata su eventi tra moduli, sostituendo gradualmente le chiamate sincrone dirette dove il disaccoppiamento e vantaggioso. Non tutte le interazioni devono diventare event-driven: le chiamate sincrone restano valide per le query e le operazioni che richiedono consistenza forte.
// Fase 4: Da chiamata sincrona a evento
// PRIMA: accoppiamento sincrono
@Service
class OrderService {
private final NotificationService notificationService;
private final InventoryService inventoryService;
public void createOrder(CreateOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepo.save(order);
// Chiamate sincrone accoppiate
notificationService.sendConfirmation(order);
inventoryService.reserveStock(order.getItems());
}
}
// DOPO: disaccoppiamento con eventi
@Service
class OrderService implements OrderModuleApi {
private final ApplicationEventPublisher events;
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepo.save(order);
// Pubblica evento: i consumatori reagiscono autonomamente
events.publishEvent(new OrderCreatedEvent(
order.getId(), order.getUserId(), order.getItems()
));
return order.toDto();
}
}
// Il modulo Notification reagisce all'evento
@Service
class NotificationHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void onOrderCreated(OrderCreatedEvent event) {
notificationService.sendConfirmation(event.userId());
}
}
// Il modulo Inventory reagisce allo stesso evento
@Service
class InventoryHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void onOrderCreated(OrderCreatedEvent event) {
inventoryService.reserveStock(event.items());
}
}
Timeline Realistica
Ecco una timeline realistica per un monolith medio (50-100k LOC, 3-5 sviluppatori dedicati):
- Settimana 1-2: Fase 1 - Code audit, event storming, prioritizzazione
- Settimana 3-8: Fase 2 - Separazione fisica, un modulo alla volta
- Settimana 9-14: Fase 3 - Estrazione API, definizione contratti
- Settimana 15-20: Fase 4 - Event-driven communication
- Settimana 21-24: Stabilizzazione, testing, documentazione
Totale: 4-6 mesi per una migrazione completa, con il sistema sempre funzionante e la possibilità di rilasciare feature durante la migrazione.
Rollback Strategy
Ogni fase della migrazione deve essere reversibile. Ecco la strategia di rollback per ogni fase:
- Fase 1: Nessun codice modificato, nessun rollback necessario
- Fase 2: Ogni spostamento di classe e un commit. Rollback =
git revert - Fase 3: L'interfaccia e additiva. Rollback = rimuovi l'interfaccia, torna alle chiamate dirette
- Fase 4: Gli eventi sono additivi. Rollback = rimuovi i listener, torna alle chiamate sincrone
Anti-Pattern da Evitare
Ecco gli errori più comuni durante la migrazione e come evitarli:
1. Big Bang Rewrite
Errore: riscrivere tutto da zero in un nuovo progetto, per poi fare uno switch in produzione. Soluzione: migrazione incrementale con il pattern Strangler Fig.
2. Premature Extraction
Errore: estrarre moduli come microservizi prima di avere i confini chiari. Soluzione: completa la modularizzazione interna prima di considerare l'estrazione.
3. Shared Mutable State
Errore: moduli che condividono oggetti mutabili in memoria. Soluzione: comunicare solo attraverso DTO immutabili e eventi.
4. Circular Dependencies
Errore: modulo A dipende da modulo B che dipende da modulo A. Soluzione: introduci un event bus per rompere i cicli, o crea uno shared kernel.
5. Testing Insufficiente
Errore: migrare senza una suite di test adeguata. Soluzione: prima di iniziare la migrazione, assicurati di avere test end-to-end che coprano i flussi principali. Questi test sono la tua rete di sicurezza.
Prossimo Articolo
Nel prossimo e ultimo articolo della serie, presenteremo un case study completo: una startup con 12 microservizi che ha migrato a modular monolith. Vedremo metriche before/after, ROI quantificato, costi risparmiati, e le lezioni apprese durante i 4 mesi di migrazione.







