Introduction: Migration as an Incremental Process
Migrating a legacy monolith to a modular monolith is not a big-bang project requiring months of rewriting and a risky deployment. It is an incremental process, safe and reversible, that can proceed in parallel with new feature development. Each migration step produces a working and testable system, reducing risk at every iteration.
In this article we will present a 4-phase migration playbook: from code audit to boundary identification, from physical module separation to introducing event-driven communication. We will include a realistic timeline, testing strategies, and the anti-patterns to avoid.
What You Will Learn in This Article
- The Strangler Fig pattern applied to internal modularization
- Phase 1: Code audit and boundary identification with DDD
- Phase 2: Physical separation into packages and modules
- Phase 3: API extraction and internal contract definition
- Phase 4: Migration to event-driven communication
- Testing strategy for each phase
- Rollback strategy and risk management
- Realistic timeline: 2-6 months at controlled risk
- Anti-patterns and common mistakes to avoid
The Strangler Fig Pattern
The Strangler Fig Pattern, originally conceived for migrating from monolith to microservices, applies effectively to internal modularization as well. The idea is simple: instead of rewriting everything, gradually wrap parts of the legacy monolith with well-structured modules until the legacy code is completely replaced.
The process works like this:
- Identify a functional area of the legacy monolith
- Create a new module with clear boundaries implementing the same functionality
- Gradually route traffic from legacy code to the new module
- When the new module is stable, remove the legacy code
- Repeat for the next functional area
Key Advantage of Strangler Fig
The system remains always functional during migration. There is never a moment when the system is "half migrated and broken." Each increment produces a complete and testable system, with the ability to rollback immediately.
Phase 1: Code Audit and Boundary Identification
The first phase is purely analytical: no code is modified, only analyzed. The goal is to understand the current monolith structure and identify natural module boundaries.
1.1 Dependency Analysis
Use static analysis tools to map dependencies between classes and packages. Tools like JDepend, ArchUnit, or Structure101 can generate dependency graphs revealing natural clusters and problematic couplings.
// ArchUnit: analyze existing dependencies
@Test
void analyzeDependencies() {
JavaClasses classes = new ClassFileImporter()
.importPackages("com.legacy.app");
// Identify dependency cycles
SliceRule noCycles = SlicesRuleDefinition
.slices()
.matching("com.legacy.app.(*)..")
.should().beFreeOfCycles();
// This test will likely fail in the legacy monolith
// The report shows exactly where the cycles are
try {
noCycles.check(classes);
} catch (AssertionError e) {
// Analyze cycles to identify boundaries
System.out.println("Dependency cycles found:");
System.out.println(e.getMessage());
}
}
1.2 Event Storming with the Team
Organize an Event Storming session with developers and business stakeholders to identify bounded contexts. This session produces a business domain map that will become the basis for module boundaries.
1.3 Prioritization
Not all modules have the same urgency. Prioritize based on:
- Change frequency: modules that change most often benefit first from modularization
- Complexity: the most complex modules need clear boundaries before others
- Coupling: start with modules having fewer external dependencies (easier to extract)
- Business value: business-critical modules deserve priority attention
Phase 2: Physical Separation
In this phase, the source code is reorganized from a technical layer organization to a functional module organization. This is the most visible change and can be done in a completely backward-compatible manner.
// Package structure migration
// PHASE 2.1: Create the new structure
// BEFORE (layer-based):
// com.app.controller.OrderController
// com.app.service.OrderService
// com.app.repository.OrderRepository
// com.app.model.Order
// AFTER (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
// PHASE 2.2: Move classes one at a time
// Use the IDE's "Move Class" refactoring
// The compiler immediately flags broken dependencies
// PHASE 2.3: Verify the build works after each move
// ./gradlew build
// If the build fails, fix the dependencies or
// rollback the move
Testing Strategy for Phase 2
Physical separation must not change behavior. The testing strategy is:
- Regression tests: run the entire test suite after each move
- Compilation tests: the compiler is your first test, verify the build works
- End-to-end tests: verify main user flows work
- Frequent commits: each class move is a commit, for granular rollback
Phase 3: API Extraction
Once code is organized by module, Phase 3 introduces public interfaces (APIs)
between modules. Each module exposes an interface in the api package and hides
the implementation in the internal package.
// Phase 3: API extraction from existing code
// STEP 1: Identify methods called by other modules
// Find all uses of OrderService outside the order package
// grep -r "OrderService" --include="*.java" | grep -v "order/"
// STEP 2: Create public interface with only needed methods
package com.app.order.api;
public interface OrderModuleApi {
// Only methods used by other modules
OrderDto createOrder(CreateOrderCommand cmd);
Optional<OrderDto> findById(UUID id);
List<OrderDto> findByUserId(UUID userId);
}
// STEP 3: Implement the interface in the existing service
package com.app.order.internal;
@Service
class OrderService implements OrderModuleApi {
// Existing code does not change
// Just add "implements OrderModuleApi"
// and toDto() methods for conversions
@Override
public OrderDto createOrder(CreateOrderCommand cmd) {
// Existing logic...
Order order = new Order(cmd);
orderRepository.save(order);
return OrderDto.from(order);
}
}
// STEP 4: Update callers to use the interface
// Before: private final OrderService orderService;
// After: private final OrderModuleApi orderModule;
Phase 4: Event-Driven Communication
The final phase introduces event-based communication between modules, gradually replacing direct synchronous calls where decoupling is beneficial. Not all interactions need to become event-driven: synchronous calls remain valid for queries and operations requiring strong consistency.
// Phase 4: From synchronous call to event
// BEFORE: synchronous coupling
@Service
class OrderService {
private final NotificationService notificationService;
private final InventoryService inventoryService;
public void createOrder(CreateOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepo.save(order);
// Coupled synchronous calls
notificationService.sendConfirmation(order);
inventoryService.reserveStock(order.getItems());
}
}
// AFTER: decoupling with events
@Service
class OrderService implements OrderModuleApi {
private final ApplicationEventPublisher events;
@Transactional
public OrderDto createOrder(CreateOrderCommand cmd) {
Order order = Order.create(cmd);
orderRepo.save(order);
// Publish event: consumers react autonomously
events.publishEvent(new OrderCreatedEvent(
order.getId(), order.getUserId(), order.getItems()
));
return order.toDto();
}
}
// Notification module reacts to the event
@Service
class NotificationHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void onOrderCreated(OrderCreatedEvent event) {
notificationService.sendConfirmation(event.userId());
}
}
// Inventory module reacts to the same event
@Service
class InventoryHandler {
@TransactionalEventListener(phase = AFTER_COMMIT)
void onOrderCreated(OrderCreatedEvent event) {
inventoryService.reserveStock(event.items());
}
}
Realistic Timeline
Here is a realistic timeline for a medium-sized monolith (50-100k LOC, 3-5 dedicated developers):
- Week 1-2: Phase 1 - Code audit, event storming, prioritization
- Week 3-8: Phase 2 - Physical separation, one module at a time
- Week 9-14: Phase 3 - API extraction, contract definition
- Week 15-20: Phase 4 - Event-driven communication
- Week 21-24: Stabilization, testing, documentation
Total: 4-6 months for a complete migration, with the system always running and the ability to release features during the migration.
Rollback Strategy
Every migration phase must be reversible. Here is the rollback strategy for each phase:
- Phase 1: No code modified, no rollback needed
- Phase 2: Each class move is a commit. Rollback =
git revert - Phase 3: The interface is additive. Rollback = remove the interface, return to direct calls
- Phase 4: Events are additive. Rollback = remove listeners, return to synchronous calls
Anti-Patterns to Avoid
Here are the most common mistakes during migration and how to avoid them:
1. Big Bang Rewrite
Mistake: rewriting everything from scratch in a new project, then switching in production. Solution: incremental migration using the Strangler Fig pattern.
2. Premature Extraction
Mistake: extracting modules as microservices before having clear boundaries. Solution: complete internal modularization before considering extraction.
3. Shared Mutable State
Mistake: modules sharing mutable objects in memory. Solution: communicate only through immutable DTOs and events.
4. Circular Dependencies
Mistake: module A depends on module B which depends on module A. Solution: introduce an event bus to break cycles, or create a shared kernel.
5. Insufficient Testing
Mistake: migrating without an adequate test suite. Solution: before starting the migration, ensure you have end-to-end tests covering the main flows. These tests are your safety net.
Next Article
In the next and final article of the series, we will present a complete case study: a startup with 12 microservices that migrated to a modular monolith. We will see before/after metrics, quantified ROI, cost savings, and the lessons learned during the 4-month migration.







