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.

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

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

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

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

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.