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.
Domande frequenti
Qual è il vantaggio principale del processo di migrazione incrementale rispetto a un progetto big-bang?
Il processo di migrazione incrementale riduce il rischio e consente di procedere in parallelo allo sviluppo di nuove feature, con ogni passo che produce un sistema funzionante e testabile.
Qual è il Pattern Strangler Fig e come si applica alla modularizzazione interna?
Il Pattern Strangler Fig consiste nell'avvolgere gradualmente le parti del monolith legacy con moduli ben strutturati, fino a quando il codice legacy viene completamente sostituito.
Quali sono i passaggi della Fase 1 di migrazione e cosa si cerca di ottenere?
I passaggi della Fase 1 includono l'analisi delle dipendenze, la sessione di Event Storming con il team e la priorità dei moduli. L'obiettivo è comprendere la struttura attuale del monolith e identificare i confini naturali dei moduli.
Qual è la strategia di rollback per ogni fase della migrazione?
Ogni fase della migrazione deve essere reversibile, quindi si utilizza una strategia di rollback che consente di tornare indietro immediatamente in caso di problemi.

Commenti
Caricamento commenti...
Accedi per commentare