Monolith to Microservices — Migration Strategies
A practical guide to decomposing monoliths: strangler fig, branch by abstraction, and incremental extraction patterns that reduce risk and preserve business continuity.
Introduction
Migrating a monolith to microservices is one of the riskiest refactorings you can take on. Done wrong, you get a distributed monolith: slower, more complex, harder to operate. Done right, you get team autonomy, independent scaling and faster delivery. Below are the extraction strategies that let you decompose safely.
Before You Start
Validate the Decision
| Question | If “no”, reconsider |
|---|---|
| Do you have > 50 engineers? | Monolith + modular boundaries may suffice |
| Are deploys painful (> weekly)? | Fix CI/CD first |
| Can you measure the cost of the monolith? | Quantify pain before justifying migration |
| Do you have operational expertise? | Microservices need SRE/DevOps maturity |
| Is the business stable enough for a long project? | Migration takes 1-3 years for large systems |
Identify the First Service to Extract
Pick a service that’s:
- Low risk: not payments, not authentication.
- High pain: the thing that changes often, deploys slowly or bottlenecks the rest.
- Clear boundary: little shared state with the monolith.
- Independent value: still useful when it’s running alone.
Good first candidates: notification service, reporting service, or a feature flag service.
Migration Patterns
1. Strangler Fig Pattern
Gradually replace monolith functionality by routing traffic to new services while the monolith still runs.
Phase 1:
[Users] → [Monolith] → [Database]
Phase 2:
[Users] → [API Gateway] → [Monolith] → [Database]
└──────> [New Service] → [New DB]
Phase 3:
[Users] → [API Gateway] → [Service A] → [DB A]
└──────> [Service B] → [DB B]
└──────> [Monolith] → [DB Legacy]
Phase 4:
[Users] → [API Gateway] → [All Services]
Implementation:
# API Gateway routing logic
class Router:
def route(self, request):
if self.feature_flags.is_enabled("use-new-catalog", request.user_id):
return self.catalog_service.handle(request)
return self.monolith.handle(request)
Why it works: you can roll back instantly by flipping a feature flag. The monolith is your safety net until the new service is proven.
2. Branch by Abstraction
Create an abstraction layer in the monolith, then swap the implementation.
# Step 1: Create abstraction in monolith
class NotificationSender(ABC):
@abstractmethod
def send(self, user, message):
pass
# Step 2: Monolith still uses the old implementation
class MonolithNotificationSender(NotificationSender):
def send(self, user, message):
... # old monolith logic
# Step 3: Build new service with the same interface
class ServiceNotificationSender(NotificationSender):
def __init__(self, client):
self.client = client # gRPC/HTTP client to new service
def send(self, user, message):
self.client.send_notification(user.id, message)
# Step 4: Swap implementations via feature flag
sender = ServiceNotificationSender() if flags.enabled("new-notifications") else MonolithNotificationSender()
Why it works: the monolith never knows it’s talking to a service. Rolling back takes one line.
3. Parallel Run
Run both the old and new implementations, compare outputs, but only serve the old output to users.
# Shadow traffic to new service
old_result = monolith_recommendations.get(user_id)
new_result = recommendations_service.get(user_id)
# Log differences, do not serve new_result yet
if old_result != new_result:
metrics.increment("recommendation.divergence")
logger.warning("Divergence detected", old=old_result, new=new_result)
return old_result
When to use: when correctness is critical (payments, recommendations, pricing) and you want confidence before switching.
4. Data Migration Patterns
Shared Database (Temporary)
[Monolith] ──> [Shared DB]
[New Service] ─┘
Use for: weeks, not months — the new service reads the shared DB while you plan the ownership migration.
Change Data Capture (CDC)
[Monolith DB] ──> [Debezium] ──> [Kafka] ──> [New Service DB]
Use for: keeping the new service’s data in sync without touching the monolith. Debezium reads the binlog and publishes changes.
Synchronize and Switch
- Dual-write from monolith to both old and new databases
- Backfill historical data to new database
- Switch reads to new database
- Stop writing to old database
# Dual-write in monolith
def create_order(order):
db.execute("INSERT INTO orders ...") # old
new_service_client.create_order(order) # new
Migration Roadmap
| Quarter | Goal |
|---|---|
| Q1 | Extract non-critical service (notifications, reports) using Strangler Fig |
| Q2 | Implement API Gateway and service discovery |
| Q3 | Extract a medium-critical service with Branch by Abstraction |
| Q4 | Parallel run for a high-critical service (recommendations, search) |
| Year 2 | Extract core services; monolith shrinks to thin orchestration layer |
| Year 3 | Retire monolith; all functionality in services |
What Works
- Never do a big-bang rewrite. Incremental extraction keeps your options open.
- Measure before and after: deployment frequency, lead time, failure rate.
- Keep the monolith deployable — extraction that breaks CI/CD stalls everything.
- Invest in testing early: contract tests between monolith and services catch breaking changes before they ship.
- Show progress to stakeholders: they need visible value, not just engineering activity.
- Accept that some code never moves. A module that changes once a year may not be worth extracting.
Common Mistakes
- Extracting along technical layers (UI, business logic, data) instead of business capabilities.
- Ignoring data consistency. Distributed transactions need sagas, not hope.
- Underestimating the last stretch: those final services tend to be the hardest and most coupled.
- Retiring the monolith too early, while services are still unstable.
- Underinvesting in developer experience: local development and testing get a lot harder with microservices.
See Also
- Martin Fowler — Strangler Fig Application: the original essay that named the pattern.
- AWS Prescriptive Guidance — Strangler Fig Pattern: applied guide for incremental rewrites.
- microservices.io — Refactoring a monolith: the pattern catalog behind most migration advice.
Advanced Topics
Detailed Scenario: Extracting Notifications from a Rails Monolith
System: Rails monolith (450k lines, 35 devs, PostgreSQL)
Target: Extract notification-service as first microservice
Timeline: 8 weeks
Week 1-2: Analyze current notification code
- Identified 12 notification types (order confirmation, shipping, password reset, etc.)
- 3 delivery channels: email (SendGrid), SMS (Twilio), push (Firebase)
- Notification logic scattered across 18 Rails models
- Shared database table: notifications (written by 8 different controllers)
Week 3: Create abstraction layer (Branch by Abstraction)
# app/services/notification_sender.rb
class NotificationSender
def self.send(user_id, type, payload)
if FeatureFlags.enabled?("use-notification-service", user_id)
NotificationServiceClient.send(user_id, type, payload)
else
MonolithNotificationDelivery.send(user_id, type, payload)
end
end
end
# Replace all direct calls throughout the monolith
# Before: NotificationMailer.order_confirmation(user).deliver_now
# After: NotificationSender.send(user.id, :order_confirmation, {order: order})
Week 4-5: Build notification-service (Go)
- gRPC server listening on :50051
- Reads from its own PostgreSQL database
- Integrates SendGrid, Twilio, Firebase via adapters
- Supports templates, retry logic, dead-letter queue
// proto/notifications.proto
service NotificationService {
rpc Send(SendNotificationRequest) returns (SendNotificationResponse);
rpc GetStatus(GetStatusRequest) returns (NotificationStatus);
}
Week 6: Data migration with CDC
- Deploy Debezium connector on monolith PostgreSQL
- Stream notifications table changes to Kafka topic
- notification-service consumes from Kafka and writes to its DB
- Run for 1 week in shadow mode (no traffic served)
[Monolith DB] -> [Debezium] -> [Kafka] -> [notification-service] -> [New DB]
Week 7: Gradual traffic shift
- Day 1-2: 5% of notifications via new service (feature flag)
- Day 3-4: 25% traffic, monitor error rates and latency
- Day 5-6: 50% traffic
- Day 7: 100% traffic
Monitoring dashboard:
- notification.success_rate (target: > 99.5%)
- notification.latency_p95 (target: < 200ms)
- notification.retry_count (target: < 5% of total)
- notification.dead_letter_count (target: < 0.1% of total)
Week 8: Cleanup
- Remove MonolithNotificationDelivery from Rails
- Drop notifications table from monolith DB (after backup)
- Remove Debezium connector
- Document the new service in architecture wiki
Results after 1 month:
| Metric | Monolith | notification-service |
|--------|----------|---------------------|
| Deploy frequency | Weekly | On-demand (3x/week) |
| Error rate | 2.1% | 0.3% |
| p95 latency | 450ms | 120ms |
| Template changes | PR + deploy | Config update (no deploy) | Frequently Asked Questions
How long does a monolith-to-microservices migration take?
On a system with 100+ engineers, plan on 1-3 years. The first service takes months; the tenth takes weeks. The bottleneck is rarely technical: it's organizational alignment and testing confidence.
Should we stop feature development during migration?
No. The business doesn't pause. Run migration as a parallel track: 70% features, 30% migration. If migration takes 100% of capacity, you're extracting too aggressively.
What if we end up with a distributed monolith?
A distributed monolith happens when services share a database or deploy together. The fix is the same as the prevention: enforce database-per-service and independent deployment pipelines. It's painful to fix, so prevent it from the start.
How do I handle shared libraries during migration?
Extract shared code into a versioned library (gem, npm package, JAR) that both the monolith and the new services depend on. Keep it minimal (value objects, DTOs, utilities) and keep domain logic inside the service that owns it. Version it semantically, publish to a private registry, and let it outlive the monolith.
How do I test during the migration?
Contract tests between the monolith and each new service catch breaking changes before they ship. Add a parallel-run phase for anything correctness-critical, and keep the monolith's suite green the whole way through, since it stays your regression baseline until the service proves itself.
How do I get buy-in for a multi-year migration?
Deliver value every quarter instead of promising a big reveal. The first extracted service should show up in metrics stakeholders already watch: deploy frequency, lead time, incident rate. A migration that produces no visible wins in its first six months is a migration that gets cancelled.
Related Resources
Microservices Architecture — When to Use and When Not To
A practical guide to microservices: benefits, trade-offs, common patterns, and when to choose them over monoliths. Covers decomposition strategies and operational complexity.
GuideDomain-Driven Design (DDD): A Practical Guide
Learn DDD fundamentals: bounded contexts, entities, value objects, aggregates, and how to model complex business domains in code.
GuideModular Monolith — A Pragmatic Architecture
A practical guide to Modular Monoliths: combine the simplicity of monoliths with the modularity of microservices through clear bounded contexts and strict module boundaries.
RecipeMicroservices Communication Patterns
Choose between synchronous and asynchronous communication patterns for resilient microservices architectures.
RecipeService Discovery
Implement service discovery with health checks, DNS-based resolution, and service registries for live microservices environments.
GuideAPI Gateway Design: Resilience, Routing, and Security
A practical guide to designing API gateways: routing, authentication, rate limiting, circuit breakers, caching, and deployment patterns for resilient APIs.