Skip to content
StackPractices
advanced By Mathias Paulenko

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

QuestionIf “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

  1. Dual-write from monolith to both old and new databases
  2. Backfill historical data to new database
  3. Switch reads to new database
  4. 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

QuarterGoal
Q1Extract non-critical service (notifications, reports) using Strangler Fig
Q2Implement API Gateway and service discovery
Q3Extract a medium-critical service with Branch by Abstraction
Q4Parallel run for a high-critical service (recommendations, search)
Year 2Extract core services; monolith shrinks to thin orchestration layer
Year 3Retire 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 testingcontract 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.