Migrating from Monolith to Microservices — A Battle-Tested Playbook
The real migration strategy from someone who's done it three times — covering the strangler fig pattern, bounded context identification, database decomposition, and rollback planning.
The Migration That Changed My Perspective
In 2019, I led the decomposition of a 600,000-line Java monolith for a payments company in Singapore. The monolith was 8 years old, had 47 developers working on it, and deployed once every two weeks (when things went well). Deployments took 4 hours and required 6 people on a call.
The business wanted faster feature delivery. The CTO wanted microservices. I wanted to not lose my job.
Here’s what actually worked — not the textbook version, but the battle-scarred reality.
Step 0: Should You Even Migrate?
Before I share the how, let me be direct: most teams should NOT migrate to microservices. You should only consider it when:
- Deployment bottleneck — Teams block each other on releases
- Scaling mismatch — One module needs 50x the resources of another
- Team autonomy — You have 4+ teams that need independent release cycles
- Technology diversity — Part of your system genuinely needs a different tech stack
If your problem is “the code is messy,” microservices won’t fix it. They’ll just distribute the mess across a network. Clean your monolith first.
In our case, we had all four signals. The payments module needed 20x the compute of the reporting module, and 8 teams were stepping on each other’s code daily.
Step 1: Identify Bounded Contexts
Don’t decompose by technical layer (service, repository, controller). Decompose by business domain. This is where most migrations fail — wrong boundaries create chatty services that are worse than the monolith.
How We Found Our Boundaries
We used a technique called Event Storming combined with dependency analysis:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Tool to analyze module coupling in the monolith
@Component
public class CouplingAnalyzer {
public CouplingReport analyze(String basePackage) {
var report = new CouplingReport();
// Scan all classes and track cross-package dependencies
var classes = new ClassPathScanningCandidateComponentProvider(true)
.findCandidateComponents(basePackage);
for (var beanDef : classes) {
Class<?> clazz = Class.forName(beanDef.getBeanClassName());
String sourceModule = extractModule(clazz.getPackageName());
for (Field field : clazz.getDeclaredFields()) {
String targetModule = extractModule(field.getType().getPackageName());
if (!sourceModule.equals(targetModule)) {
report.addDependency(sourceModule, targetModule, clazz.getSimpleName());
}
}
}
return report;
}
private String extractModule(String packageName) {
// com.company.app.payments.service -> "payments"
String[] parts = packageName.split("\\.");
return parts.length > 3 ? parts[3] : "core";
}
}
Our Resulting Bounded Contexts
After two weeks of analysis and three event storming sessions:
Payment Processing — Transaction creation, payment gateway integration, settlement
Account Management — User profiles, KYC, account lifecycle
Notification — Email, SMS, push notifications, templates
Reporting — Analytics, regulatory reports, statement generation
Merchant Portal — Merchant onboarding, configuration, dashboard
Risk & Compliance — Fraud detection, AML screening, rule engine
The key insight: we initially had “User” as a shared concept. But the Payment module only needed user ID and payment methods. The Account module needed full profile data. The Risk module needed transaction history. Each bounded context has its own view of what a “user” means.
Step 2: Strangler Fig Pattern — The Only Safe Approach
Don’t do a big-bang rewrite. I’ve seen three rewrites attempted. All three failed. Instead, use the strangler fig pattern: gradually route traffic from the monolith to new services while the monolith continues running.
The Architecture
1
2
3
4
5
6
7
8
[Load Balancer]
|
[API Gateway / Router]
|
├── /api/payments/* → New Payment Service (microservice)
├── /api/accounts/* → Monolith (still handling this)
├── /api/reports/* → Monolith (still handling this)
└── /api/merchants/* → Monolith (still handling this)
The Router Implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
public class StranglerFigRouter {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// Migrated: route to new microservice
.route("payment-service", r -> r
.path("/api/payments/**")
.and()
.header("X-Migration-Phase", "new")
.uri("lb://payment-service"))
// Shadow mode: send to both, return monolith response
.route("payment-shadow", r -> r
.path("/api/payments/**")
.and()
.header("X-Migration-Phase", "shadow")
.filters(f -> f.filter(shadowTrafficFilter()))
.uri("lb://monolith"))
// Default: everything else stays with monolith
.route("monolith-default", r -> r
.path("/api/**")
.uri("lb://monolith"))
.build();
}
}
Shadow Traffic — The Validation Phase
Before cutting over, we ran both systems in parallel. The monolith served the real response, while the new service processed the same request and we compared results:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Component
public class ShadowTrafficComparator {
private final WebClient monolithClient;
private final WebClient microserviceClient;
private final MeterRegistry metrics;
public void compareResponses(ServerHttpRequest request) {
// Execute both calls
var monolithResponse = monolithClient.method(request.getMethod())
.uri(request.getURI())
.headers(h -> h.addAll(request.getHeaders()))
.retrieve()
.bodyToMono(String.class);
var microserviceResponse = microserviceClient.method(request.getMethod())
.uri(request.getURI())
.headers(h -> h.addAll(request.getHeaders()))
.retrieve()
.bodyToMono(String.class);
Mono.zip(monolithResponse, microserviceResponse)
.subscribe(tuple -> {
boolean match = compareJson(tuple.getT1(), tuple.getT2());
metrics.counter("shadow.comparison",
"match", String.valueOf(match),
"endpoint", request.getURI().getPath()
).increment();
if (!match) {
log.warn("Shadow mismatch on {}: monolith={}, service={}",
request.getURI(), tuple.getT1(), tuple.getT2());
}
});
}
}
We ran shadow mode for 3 weeks. Initially, we had a 12% mismatch rate. By week 3, we were at 0.02% (edge cases in date formatting).
Step 3: Database Decomposition
This is the hardest part. The monolith has one database with foreign keys across domains. You can’t just split it overnight.
Phase 1: Logical Separation (Weeks 1-4)
Create schema boundaries within the same database:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Create schemas for each bounded context
CREATE SCHEMA payments;
CREATE SCHEMA accounts;
CREATE SCHEMA notifications;
-- Move tables to appropriate schemas
ALTER TABLE transactions SET SCHEMA payments;
ALTER TABLE payment_methods SET SCHEMA payments;
ALTER TABLE users SET SCHEMA accounts;
ALTER TABLE user_profiles SET SCHEMA accounts;
-- Create views for cross-schema reads (temporary bridge)
CREATE VIEW payments.user_payment_info AS
SELECT id, email, default_payment_method_id
FROM accounts.users;
Phase 2: Data Duplication via Events (Weeks 4-8)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Account service publishes events when user data changes
@Service
public class AccountEventPublisher {
private final KafkaTemplate<String, AccountEvent> kafkaTemplate;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onAccountUpdated(AccountUpdatedEvent event) {
kafkaTemplate.send("account-events",
event.getUserId(),
new AccountEvent(
event.getUserId(),
event.getEmail(),
event.getPaymentMethodIds(),
Instant.now()
));
}
}
// Payment service maintains its own read model of user data
@Service
public class AccountDataConsumer {
private final PaymentUserRepository paymentUserRepository;
@KafkaListener(topics = "account-events", groupId = "payment-service")
public void onAccountEvent(AccountEvent event) {
PaymentUser user = paymentUserRepository.findById(event.userId())
.orElse(new PaymentUser(event.userId()));
user.setEmail(event.email());
user.setPaymentMethodIds(event.paymentMethodIds());
user.setLastSyncedAt(event.timestamp());
paymentUserRepository.save(user);
}
}
Phase 3: Physical Separation (Weeks 8-12)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Payment service now has its own database
@Configuration
@Profile("microservice")
public class PaymentDataSourceConfig {
@Bean
@Primary
public DataSource paymentDataSource() {
var config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://payment-db:5432/payments");
config.setMaximumPoolSize(20);
return new HikariDataSource(config);
}
}
The Dual-Write Problem
During migration, some writes need to update both the old and new database. We used the Outbox Pattern:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final OutboxRepository outboxRepository;
@Transactional
public Payment createPayment(PaymentRequest request) {
Payment payment = new Payment(request);
paymentRepository.save(payment);
// Same transaction — atomically consistent
outboxRepository.save(new OutboxEvent(
"payment.created",
payment.getId().toString(),
toJson(payment)
));
return payment;
}
}
// Separate process polls outbox and publishes to Kafka
@Scheduled(fixedDelay = 100)
public void publishOutboxEvents() {
List<OutboxEvent> unpublished = outboxRepository.findUnpublished(100);
for (OutboxEvent event : unpublished) {
kafkaTemplate.send(event.getTopic(), event.getKey(), event.getPayload())
.whenComplete((result, ex) -> {
if (ex == null) {
event.markPublished();
outboxRepository.save(event);
}
});
}
}
Step 4: Feature Toggles for Cutover
Never rely on deployment for cutover. Use feature toggles so you can switch between monolith and microservice at runtime:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
private final FeatureToggleService toggles;
private final MonolithPaymentClient monolithClient;
private final PaymentService microservicePayment;
@PostMapping
public ResponseEntity<PaymentResponse> createPayment(
@RequestBody PaymentRequest request) {
if (toggles.isEnabled("payment.use-microservice", request.getMerchantId())) {
return ResponseEntity.ok(microservicePayment.process(request));
} else {
return ResponseEntity.ok(monolithClient.process(request));
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class FeatureToggleService {
private final RedisTemplate<String, String> redis;
public boolean isEnabled(String feature, String segmentKey) {
// Check kill switch first (instant rollback)
String killSwitch = redis.opsForValue().get("toggle:kill:" + feature);
if ("true".equals(killSwitch)) {
return false; // Route everything back to monolith
}
// Percentage-based rollout
String percentage = redis.opsForValue().get("toggle:rollout:" + feature);
if (percentage != null) {
int rolloutPercent = Integer.parseInt(percentage);
int hash = Math.abs(segmentKey.hashCode() % 100);
return hash < rolloutPercent;
}
return false;
}
}
Our rollout was:
- Week 1 — 1% of traffic (canary)
- Week 2 — 10% of traffic (early validation)
- Week 3 — 50% of traffic (load testing the new service under real load)
- Week 4 — 100% of traffic
- Week 5 — Monolith payment code deprecated (but kept as fallback for 3 more months)
Step 5: The Rollback Strategy
Every cutover needs a rollback plan that can execute in under 60 seconds:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RestController
@RequestMapping("/internal/migration")
public class MigrationController {
private final FeatureToggleService toggleService;
private final MeterRegistry metrics;
@PostMapping("/rollback/{service}")
@PreAuthorize("hasRole('MIGRATION_ADMIN')")
public ResponseEntity<String> rollback(@PathVariable String service) {
// Kill switch — instant rollback to monolith
toggleService.setKillSwitch(service, true);
// Record rollback event
metrics.counter("migration.rollback", "service", service).increment();
// Alert the team
alertService.send(AlertLevel.P1,
"Migration rollback triggered for: " + service);
return ResponseEntity.ok("Rolled back " + service + " to monolith");
}
@PostMapping("/resume/{service}")
@PreAuthorize("hasRole('MIGRATION_ADMIN')")
public ResponseEntity<String> resume(@PathVariable String service,
@RequestParam int percentage) {
toggleService.setKillSwitch(service, false);
toggleService.setRolloutPercentage(service, percentage);
return ResponseEntity.ok("Resumed " + service + " at " + percentage + "%");
}
}
We triggered rollback three times during the migration:
- Week 2 — New service had a memory leak under sustained load. Rollback in 8 seconds.
- Week 3 — Edge case in currency conversion. Rollback in 12 seconds. Fixed and resumed next day.
- Week 4 — False alarm. Monitoring showed error spike but it was a downstream timeout, not our service.
Step 6: Shared-Nothing Architecture
The most critical principle: services must not share databases, caches, or in-memory state. Every shared resource becomes a coupling point that negates the benefits of microservices.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// WRONG: Shared cache that couples services
@Cacheable(cacheNames = "users", key = "#userId")
public User getUser(String userId) {
return userRepository.findById(userId);
}
// RIGHT: Each service owns its data and exposes it via API
@Service
public class PaymentUserService {
private final Cache<String, PaymentUser> localCache;
private final AccountServiceClient accountClient;
public PaymentUser getPaymentUser(String userId) {
return localCache.get(userId, key -> {
// Fetch from account service API, cache locally
AccountResponse response = accountClient.getAccount(userId);
return new PaymentUser(response.id(), response.email(),
response.paymentMethods());
});
}
}
Communication Patterns
Synchronous (HTTP/gRPC) — Use only when you need the response to continue processing
Asynchronous (Events) — Use for notifications, data replication, eventual consistency
Choreography — Services react to events independently (simpler, harder to trace)
Orchestration — A central orchestrator coordinates the workflow (more complex, easier to trace)
We chose orchestration for the payment flow (needed consistency) and choreography for notifications and analytics (eventual consistency acceptable).
The Timeline
Here’s how long each phase actually took us:
Bounded context analysis — 3 weeks (2 weeks Event Storming + 1 week dependency analysis)
First service extraction (Notifications) — 6 weeks (lowest risk, minimal data coupling)
Payment service extraction — 14 weeks (complex domain, critical path, database decomposition)
Account service extraction — 10 weeks
Full migration (6 services) — 11 months (not the 6 months we originally estimated)
What We Got Wrong
Underestimated data migration. We allocated 20% of effort. It consumed 45%. Cross-domain foreign keys were everywhere.
Started with the hardest service. We should have extracted Notifications first (we eventually did). It had the least coupling and gave the team confidence.
Didn’t invest in observability early enough. When you go from one monolith to 6 services, you need distributed tracing from day one. We added it in month 4 and wished we had it from month 1.
Shared libraries became coupling. We created a
commonslibrary with DTOs. Every change to it required coordinating deployments across services. Eventually, we duplicated DTOs per service and accepted the redundancy.
What We Got Right
Strangler fig, not big bang. The monolith ran in production the entire time. We never had a “migration weekend.”
Feature toggles for everything. Rollback was always one Redis command away.
Shadow traffic validation. We caught 200+ edge cases before they hit production.
Starting with team boundaries. Each service was owned by one team. Conway’s Law actually helped us here.
The End Result
Deployment frequency — Every 2 weeks → Multiple times per day (per service)
Deployment duration — 4 hours → 8 minutes
Team independence — 47 developers blocking each other → 8 teams deploying independently
Incident blast radius — One bug takes everything down → Failures contained to one service
Infrastructure cost — 15% increase (more overhead per service) but offset by independent scaling
Was it worth it? For our scale and team size — absolutely. But I wouldn’t recommend it for a team under 20 developers with a codebase under 200K lines. The operational complexity tax is real.