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.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Monolith to Microservices — Migration Strategies
Introduction
Migrating from a monolith to microservices is one of the riskiest refactoring projects in software engineering. Done wrong, it creates a distributed monolith — slower, more complex, and harder to operate. Done right, it enables team autonomy, independent scaling, and faster delivery. Below is a practical guide to strategies that 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
Choose a service that is:
- Low risk — not the payment or authentication system
- High pain — frequently changed, slow to deploy, or a bottleneck
- Clear boundary — minimal shared state with the rest of the monolith
- Independent value — useful on its own
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 is talking to a service. Rollback is a one-line change.
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 need confidence before switching.
4. Data Migration Patterns
Shared Database (Temporary)
[Monolith] ──> [Shared DB]
[New Service] ─┘
Use for: Weeks, not months. The new service reads from the shared DB while you plan data ownership migration.
Change Data Capture (CDC)
[Monolith DB] ──> [Debezium] ──> [Kafka] ──> [New Service DB]
Use for: Keeping new service data in sync without modifying 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 preserves optionality
- Measure before and after — track deployment frequency, lead time, and failure rate
- Keep the monolith deployable — do not let the extraction break CI/CD
- Invest in testing — contract tests between monolith and new services catch breaking changes
- Communicate progress — stakeholders need to see value, not just engineering activity
- Accept that some code never moves — legacy modules that change once a year may not be worth extracting
Common Mistakes
- Extracting services based on technical layers (UI, business logic, data) instead of business capabilities
- Ignoring data consistency — distributed transactions require sagas, not hope
- Underestimating the “last 10%” — the final services are often the hardest and most coupled
- Removing the monolith too early — it is your safety net until services are stable
- Not investing in developer experience — local development and testing become much harder with microservices
Frequently Asked Questions
How long does a monolith-to-microservices migration take?
For a system with 100+ engineers, expect 1-3 years. The first service takes months; the tenth takes weeks. The bottleneck is rarely technical — it is organizational alignment and testing confidence.
Should we stop feature development during migration?
No. The business does not pause. Run migration as a parallel track: 70% capabilities, 30% migration. If migration takes 100% of capacity, you are 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 is painful to fix, so prevent it from the start.
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) |
How do I handle shared libraries during migration?
Extract shared code into a versioned library (gem, npm package, JAR) that both the monolith and new services can depend on. Keep it minimal: shared value objects, DTOs, and utility functions. Do not share domain logic or business rules — those belong to the service that owns the domain. Version the library with semantic versioning and publish to a private registry. When the monolith is retired, the library continues to serve the remaining services.
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.
GuideSOLID Principles Explained with Examples
Learn the five SOLID principles with practical code examples: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.
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.
PatternExternal Configuration Store Pattern
Centralize application configuration outside of deployment artifacts to support live updates and multi-environment management.
GuideAPI Gateway Design: Resilience, Routing, and Security
A practical guide to designing API gateways: routing patterns, rate limiting, authentication, circuit breakers, and observability for resilient APIs.