Low Level Design: Migrating from Monolith to Microservices

Microservices migration is one of the most consequential architectural decisions an engineering organization makes. Done well, it unlocks team autonomy, independent scaling, and technology flexibility. Done poorly, it produces a distributed monolith — all the complexity of distribution with none of the autonomy benefits — or a failed big-bang rewrite that burns a year and ships nothing. This post covers the patterns and anti-patterns that determine which outcome you get.

When a Monolith Becomes a Problem

Microservices add complexity. Never migrate speculatively. Migrate when the monolith is causing concrete, measurable pain across one or more of these dimensions.

Deployment bottleneck: every team’s changes go out in the same release train. One team’s risky migration blocks all other teams from deploying. Rollbacks affect everyone. You’re coordinating deploys across a dozen teams for a single binary.

Scaling bottleneck: your payment processing module gets a traffic spike but you must scale the entire application — including the billing and reporting modules that are completely idle — to handle it. Compute costs scale with the highest-load component.

Development velocity: the codebase is large enough that developers can’t hold it in their heads. Build times are long. A change in one module unexpectedly breaks another. Onboarding new engineers takes months. Test suite takes an hour.

Technology lock-in: the entire application must run on the same language/framework/runtime. You can’t use Python for ML inference or Go for a high-performance real-time component without extracting them from the monolith first.

Strangler Fig Pattern

The strangler fig is the canonical safe migration strategy. The name comes from a fig tree that grows around a host tree, eventually replacing it. You never rewrite the monolith — you grow services around it until the monolith can be removed.

Mechanics: all traffic goes through an API gateway. New features are built as standalone microservices from day one — they never enter the monolith. The API gateway routes requests for new features to the new services and everything else to the monolith. Incrementally, you extract existing monolith functionality into services and update routing in the gateway. Over time — months or years — the monolith shrinks as each domain is extracted. When it’s empty, you decommission it.

The key properties that make this safe: the system is always deployable and functional (no big-bang cutover), you can validate each extracted service independently before routing production traffic to it, and if a service has problems you can fall back to the monolith by updating a routing rule. Risk is incremental, not concentrated.

Identifying Service Boundaries

The hardest question in microservices migration is where to draw service boundaries. Wrong boundaries create chatty services that call each other constantly — the distributed monolith problem.

Domain-Driven Design and bounded contexts: a bounded context is a domain boundary within which a consistent model applies. User management, payments, inventory, and notifications are likely different bounded contexts. Each service should own one bounded context — the data, the business logic, and the API surface for that domain. Services do not share domain models across boundaries.

Conway’s Law alignment: systems tend to mirror the communication structure of the organizations that build them. If your org has a payments team, a checkout team, and a catalog team, your service boundaries should match. Services owned by the same team can be colocated; services that require cross-team coordination to deploy together should be separated. If you’re fighting Conway’s Law — service boundaries that cut across team structures — expect high coordination cost.

Change frequency: code that changes together for the same reasons should live together. If you find yourself making coordinated changes across multiple services for every feature, those services were split at the wrong boundary. Group by rate-of-change and reason-for-change.

Database Decomposition

Extracting services while leaving them on a shared database is the most common migration mistake. The shared database anti-pattern looks like microservices but retains monolithic coupling: any service can read or write any table, schema changes require coordinating all services, one service’s slow query degrades all others, and you cannot deploy services independently if they share schema.

The goal is a separate database per service — each service is the sole owner of its data. No other service accesses it directly; data is shared via API calls or events.

Incremental database decomposition: start by creating a separate schema per service within the same database instance (lower operational cost, isolation at the schema level). Once the schema is stable and the service is running correctly, migrate to a separate database instance. This two-step approach reduces risk compared to jumping straight to separate instances.

Event-driven synchronization: when Service A needs data that Service B owns, Service B publishes events on state changes (user created, order placed, payment processed). Service A consumes these events and maintains its own read-optimized copy. This is eventual consistency — accept that Service A’s view may be slightly stale, in exchange for full database autonomy.

Strangler Fig Database Pattern

During the transition period when a new service is being extracted from the monolith, you often need both to coexist with consistent data. The strangler fig database pattern handles this: the new service writes to its own database and also publishes sync events to the monolith’s database during the transition window. The monolith continues reading from its own tables. Once all traffic has been routed to the new service and the monolith no longer handles that domain, you decommission the sync and the monolith’s copy of that data.

This is a temporary dual-write arrangement — it adds complexity and must be time-bounded. Define an explicit decommission date for the sync bridge when you begin the extraction. Letting it persist indefinitely recreates the shared database coupling you’re trying to eliminate.

The Distributed Monolith Anti-Pattern

The distributed monolith is the worst possible outcome: services that must be deployed together (due to shared libraries, shared database schemas, or synchronous call chains where one service can’t function without another), combined with the full operational overhead of a distributed system.

Warning signs: a deployment requires coordinating multiple services simultaneously; a shared domain model library is imported by many services and changes to it require updating all of them simultaneously; a request requires synchronous calls through 5 services before returning a response; one service going down causes cascading failures across most of the system.

A well-functioning monolith is preferable to a distributed monolith. If you’re identifying these patterns during migration, stop and re-examine service boundaries and coupling before proceeding.

API Versioning During Migration

When you extract a service from the monolith, its external API must remain backward compatible — the monolith and any other callers should not require simultaneous changes. The API gateway is your versioning layer: v1 routes to the monolith, v2 routes to the new service. Both can run simultaneously. Callers migrate to v2 on their own schedule.

Avoid breaking changes: add new fields rather than removing or renaming existing ones. Use semantic versioning. Maintain old API versions for a defined deprecation period with advance notice to consumers. The goal is that each service can be deployed independently — the API contract is what enables this independence.

Testing Strategy

Testing changes fundamentally when you move to microservices. End-to-end tests that spin up the entire system become slow and brittle. The testing pyramid shifts: more unit tests, more contract tests, fewer end-to-end tests.

Contract testing with Pact is the key tool: each service publishes its API contract (what it expects to receive, what it will return). Consumer services record their expectations as contracts. The provider service verifies its implementation satisfies all consumer contracts in CI. This catches integration breaks without spinning up all services together.

Consumer-driven contract testing inverts ownership: consumers define the contract based on what they actually use, and providers verify against it. This prevents providers from making breaking changes to fields consumers depend on, even when those fields are technically part of a larger response.

Operational Maturity Requirements

Microservices don’t just require coding discipline — they require operational infrastructure that a monolith doesn’t need. Attempting microservices without this infrastructure in place produces systems that are hard to debug and unreliable in production. Before migrating, ensure you have:

Distributed tracing (Jaeger, Zipkin, OpenTelemetry): a request that fans out across 5 services needs a trace ID that lets you see the full call graph and where latency or errors occurred. Without this, debugging production issues in a microservices system is extremely painful.

Centralized logging (ELK stack, Datadog): logs from all services aggregated and searchable by trace ID, service name, and request ID. Grepping individual host logs is not a viable strategy with dozens of services.

Service discovery (Consul, Kubernetes DNS): services need to find each other’s addresses dynamically, especially as instances scale up and down. Hardcoded IPs are not sustainable.

Circuit breakers (Resilience4j, Hystrix): when a downstream service is slow or down, calls to it should fail fast rather than queuing up threads waiting for timeouts. Without circuit breakers, one slow service cascades into all callers becoming slow.

Container orchestration (Kubernetes): managing deployment, scaling, health checks, and rolling updates for dozens of services manually is not feasible. Kubernetes (or equivalent) is table stakes for microservices at scale.

If your team doesn’t have these in place, invest in operational tooling before migrating architecture. A monolith with good observability is far more manageable than microservices without it.

Scroll to Top