Twenty years in this industry and the question I get asked more than almost any other is some variation of: “We have this monolith. Should we break it into microservices, and if so, how?” I have led these migrations, watched them succeed, watched them fail spectacularly, and occasionally had to reverse engineer what went wrong after the fact. I have opinions.
The short answer is that most teams should not rush into microservices, and those that do migrate often do it in the wrong order and end up with something worse than what they started with: a distributed monolith, which has all the operational complexity of microservices with none of the actual benefits. But there are situations where breaking the monolith is genuinely the right call, and when you do it, the Strangler Fig pattern is the closest thing this industry has to a proven playbook.
Why Monoliths Deserve More Respect
Before I tell you how to kill a monolith, let me defend it for a moment. The word “monolith” has become a pejorative in tech circles, synonymous with legacy and technical debt. That framing is wrong and it leads teams astray.
A well-structured monolith ships faster, is easier to test, and is dramatically simpler to operate than a system of fifty microservices. A monolith has a single deployment artifact, a single database transaction boundary, zero network latency for in-process calls, and one log stream to tail when something breaks. If your team is spending three hours a week on distributed tracing trying to figure out which of your seventeen services caused the checkout failure, you are paying a real tax for that architectural decision.
I have seen startups rearchitect their two-year-old codebase into microservices at twenty engineers and regret it by engineer thirty. The coordination overhead ate them alive.
That said, monoliths have genuine failure modes at scale. When deployment velocity gets locked because every change touches the same codebase. When different parts of the system have radically different scaling profiles and you cannot scale them independently. When teams step on each other constantly because organizational boundaries don’t match code boundaries. When you need to use different technology stacks for genuinely different problems (a Python ML pipeline sitting inside a Java web app is going to cause pain eventually).
Those are the real reasons to migrate. Not because microservices are fashionable.
The Strangler Fig: How It Works
The Strangler Fig is a species of tree that grows around a host tree, gradually enveloping it until the host dies and the fig stands alone. Martin Fowler named the migration pattern after it in 2004, and the metaphor has held up well.
The core idea is simple: you never take your monolith offline to rebuild it. Instead, you stand up new services incrementally alongside the monolith, reroute traffic to those services piece by piece, and progressively hollow out the monolith until it is either empty or just a thin routing layer that you can eventually decommission.

This approach lets you ship value continuously throughout the migration. At any point in time, your system is fully functional. You can pause the migration if business priorities shift. You can retreat if a particular extraction turns out to be harder than expected. This is the opposite of the “big bang rewrite” where you spend eighteen months building a replacement and then flip a switch. Big bang rewrites almost never go well. I have a graveyard of projects that proves this.
The technical implementation of the Strangler Fig usually involves a routing layer at your API gateway or load balancer. Requests for functionality that still lives in the monolith pass through to the monolith. Requests for functionality you have already migrated get routed to the new service. Your API gateway is doing the strangling work: incrementally redirecting traffic as each piece gets extracted.
Finding Your Seams: Domain-Driven Design and Bounded Contexts
The hardest part of this migration is not the technology. It is deciding where to cut. If you draw the wrong boundaries, you will end up with services that are tightly coupled in ways that are worse than the original monolith, because now instead of a method call you have an HTTP request with all the failure modes that entails.
Domain-Driven Design (DDD) gives us the conceptual vocabulary here. The key concept is the “bounded context”: a logical boundary within which a particular model of the domain is consistent and coherent. Inside a bounded context, terms have specific meanings. At the boundary, translation happens.
Your e-commerce platform might have an Order bounded context, an Inventory bounded context, a Customer bounded context, and a Shipping bounded context. Each of these has its own model of what a “product” means. To Inventory, a product is a SKU with a count. To Shipping, it is a weight and a set of dimensions. To Billing, it is a price and a tax category. If you try to share one Product entity across all of these contexts, you will spend years arguing about what fields to add to it.
I have found that the best way to find bounded contexts in an existing codebase is a combination of two approaches. First, look at your database. Tables that are never joined against each other are a strong hint that they belong in different contexts. Tables with many cross-context joins are where your pain will be. Second, look at your organization. Conway’s Law is real: your system architecture will mirror your team structure. If you have a Payments team and a Catalog team, there is usually a natural seam between payments and catalog, because those teams communicate across a boundary already.
The Order of Operations Matters
Not all seams are created equal. Some bounded contexts are nearly stateless and have clean interfaces. Others are deeply entangled in the monolith’s data model. Extract them in the wrong order and you will make the remaining extractions harder.
My general heuristic is to start with the lowest-coupling, highest-value extraction. Leaf nodes in your dependency graph first. The piece of the system that nothing else calls, but that calls many other things, is much easier to extract than the piece at the center of everything.
Email notifications are a classic first extraction. They usually have well-defined inputs, no synchronous callers expecting an immediate response, and their own isolated data (templates, delivery status). Extract notifications into a service, publish events from the monolith for “order created” and “payment processed,” and have the notification service consume those events. You have now validated your event bus infrastructure and your deployment pipeline for new services, without touching anything business-critical.
Authentication and user management, on the other hand, are almost always the last thing you extract. They are called by everything. Getting them wrong breaks the entire system. I have seen teams try to extract auth early because it feels like a clear bounded context, and watched them spend four months untangling session handling edge cases while everything else stalled.
Payment processing is often tempting to extract early because of PCI-DSS compliance requirements. This can make sense, but be cautious. Payments have complex state machines and the cost of getting transactions wrong is very high. Make sure your event-driven patterns are proven before you put payment flows on them.
The Database Problem Is the Hard Problem
Here is the thing about microservices that the conference talks skip over: the distributed computing is not the hard part. The service mesh you need to get services talking to each other is handled by off-the-shelf tooling. The hard part is the database.
Your monolith almost certainly has a single database. Microservices doctrine says each service owns its data and no service reaches into another service’s database. If you violate this, you have not actually decoupled your services. You have just added a network hop.
The path from one big shared database to multiple service-owned databases goes through some painful middle states.
The first step is usually to create a logical schema separation. Keep everything in the same physical database, but define which tables belong to which bounded context, and enforce that application code only reads and writes its own tables through the application layer. This does not give you deployment independence, but it gives you data ownership clarity and lets you find the violations.

The second step is to handle the cross-context data needs. When the Order service needs to know a customer’s shipping address, does it call the Customer service synchronously? Or does it have a denormalized copy of the relevant data? I almost always recommend denormalization over synchronous coupling for reads. The Order record should capture the shipping address at order creation time, not look it up from the Customer service on every read. This makes orders immutable and self-contained, which is what you want anyway.
For cross-context writes, Change Data Capture with Debezium has become my standard approach. Rather than having Service A call Service B’s API when something changes, Service A writes to its own database and Debezium publishes those changes as events on Kafka. Service B subscribes to those events and updates its own read model. This keeps services decoupled and handles the dual-write problem correctly.
The third step, actually moving a service to its own database instance, is the final decoupling. By this point you should have eliminated all cross-schema joins and all direct table reads from other contexts. The physical move is usually straightforward once the logical separation is clean. You can use database replication to seed the new instance, then cut over with a brief write pause once they are in sync.
Event-Driven Decoupling and the Saga Pattern
Once you start having multiple services that need to coordinate on business processes, you need patterns for distributed transactions. The database ACID guarantees you relied on in your monolith are gone.
The saga pattern is the standard answer. A saga is a sequence of local transactions, one per service, where each local transaction is followed by an event or message that triggers the next step. If a step fails, compensating transactions undo the previous steps. This is what your distributed checkout flow looks like: Reserve inventory, then charge payment, then create order record, then send confirmation email. Each step is an atomic operation within one service. Failure at any step triggers compensation (release inventory reservation, issue refund, etc.).
Temporal is the tool I most often recommend for implementing sagas in production. It handles the durable execution concerns so you do not have to: retries, timeouts, compensation logic, and state persistence across service restarts. The Temporal workflow engine effectively gives you reliable distributed transactions without a distributed transaction coordinator.
CQRS and event sourcing often emerge naturally during this kind of migration. Once you are publishing events from your services anyway, it is tempting to make those events the source of truth rather than just side effects. I would advise caution here: event sourcing adds significant complexity and you should adopt it only where the audit trail or temporal query requirements genuinely justify it. Do not pile architectural patterns on top of a migration that is already hard.
The Distributed Monolith Failure Mode
I need to name the most common failure I see in these migrations, because it happens often enough that I give it a name in client conversations: the distributed monolith.
A distributed monolith is a system that has been split into separate services but remains tightly coupled through synchronous API calls and shared databases. It has none of the benefits of microservices (independent deployability, scaling isolation) and all of the costs (network failures, distributed tracing, multiple deployment artifacts to coordinate).
You can recognize it by these signs. Services cannot be deployed independently because they have tight API versioning constraints and any change to Service A requires a simultaneous update to Service B and Service C. The system has frequent cascading failures because Service A calls Service B synchronously and when B is slow, A’s latency degrades. You have a “service” that is really just a remote database wrapper with a thin HTTP layer. Teams cannot work independently because they are constantly negotiating API changes with each other.
The cure is to go back to your bounded context analysis and ask where you drew the lines wrong. Usually, services that cannot deploy independently are in the same bounded context and should be a single service. Teams that cannot work independently because of service coupling have a Conway’s Law problem: the organizational boundary does not match the architectural boundary.
Containerizing as You Go
The migration to microservices is usually paired with containerization, since containers make it practical to deploy and run many services independently. You do not have to containerize everything at once; you can containerize extracted services one at a time while the monolith continues to run on bare metal or traditional VMs.
When containerizing your extracted services, treat each service image as an independent artifact with its own build pipeline. Your Kubernetes deployment manifests for each service should be version-controlled separately from the service code, or at least in a clearly separated directory within the same repo.
Do not try to run your database-per-service pattern on shared database instances just to save money in the short term. The point of separate databases is operational independence: you need to be able to scale, migrate, and tune each service’s storage independently. Shared database instances recreate the coupling you just spent months eliminating. Serverless database options like Neon have made the economics of separate database instances much more tractable for smaller services.
Testing Your Way Through the Migration
Testing strategy during a migration deserves a dedicated section, because it is where teams often cut corners and pay dearly later.
Before you extract any service, make sure you have integration tests at the monolith level that cover the functionality you are about to move. These tests become your regression suite. After extraction, they should pass against the new service without modification. If they do not, you have a behavior mismatch that you need to understand before you route production traffic.
Contract testing becomes essential once you have multiple services with API dependencies. Consumer-driven contract tests (Pact is the standard tool) let the consumer of an API define what it needs, and the producer verify that it satisfies those contracts, without requiring a live integration environment. This catches API breakage before it reaches production.
I also recommend running the monolith and the new service in parallel, serving the same traffic, and diffing the outputs for a period before cutting over. This shadow mode approach has saved me from production incidents many times. Route traffic to both, compare responses, investigate discrepancies, and only cut over when you have high confidence they match.
Knowing When to Stop
Not everything in your monolith needs to become a microservice. This is a point I make explicitly with every client before we start, because once the migration momentum builds it is tempting to keep slicing until there is nothing left.
A service that gets changed once a year, has no independent scaling requirements, and is owned by a single team is probably fine as a module within a larger service. The overhead of running it as a separate deployment, with its own CI/CD pipeline, its own on-call rotation, its own database, is not worth the independence it buys you when nobody is stepping on each other anyway.
The right stopping point is when the remaining monolith represents a coherent bounded context with clear ownership. That might be a “core platform” service that handles a cluster of related functionality that always deploys together and is owned by one team. Shipping that as a single service is not a failure; it is a correct architectural decision.

The Organizational Side
I would be doing you a disservice if I framed this as purely a technical problem. The monolith-to-microservices migration is an organizational change as much as a technical one, and the technical work usually goes fine while the organizational work is where things fall apart.
Teams that own microservices need to own them end-to-end: design, build, deploy, operate, on-call. If you extract a service but then have a central ops team responsible for deploying and operating it, you have recreated organizational coupling while paying the technical cost of distribution. The “you build it, you run it” model is not optional for microservices to work.
This means your platform team’s job changes. They are not there to operate services for product teams; they are there to build the platform that makes it easy for product teams to operate their own services. Internal developer portals and platform engineering practices that make service creation, deployment, and observability self-service are what make the model scale. Without that investment, you end up with a platform team that is a bottleneck for every new service and every deployment.
The migration timeline also needs to be explicit and protected. I have seen migrations that were nominally “in progress” for four years because new feature work always took priority over extraction work. By the time they came back to the migration, the monolith had grown back around the extracted services. Treat extraction work as a first-class roadmap item with dedicated capacity, not something teams do with leftover cycles.
Practical Advice for Starting
If you have decided to proceed, here is the practical sequence I follow with every client:
First, spend two to four weeks doing domain analysis. Map your bounded contexts. Draw the dependency graph. Identify where your current code structure matches or violates context boundaries. Do this before writing a single line of migration code.
Second, instrument your monolith before you extract anything. You cannot measure the impact of extraction if you do not have observability baselines. Latency profiles, error rates, database query patterns per feature area: capture all of it.
Third, extract your first service as a learning exercise, not a production dependency. Pick the lowest-risk candidate. Build your containerized deployment pipeline, your event bus infrastructure, your contract testing setup. Treat this extraction as infrastructure validation, not just feature migration.
Fourth, declare a moratorium on adding new functionality to parts of the monolith you plan to extract. New features go into the new service even if the service is not ready to take production traffic yet. This prevents the monolith from growing back around the seams you are trying to open.
Fifth, set explicit timelines and hold them. Migrations without deadlines become background noise and eventually die.
Twenty years of doing this has taught me that the teams who succeed are the ones who respect the monolith they are leaving (it got you this far for a reason), plan the organizational change as carefully as the technical change, and migrate incrementally with continuous validation rather than betting on a big cutover. The Strangler Fig works precisely because it never asks you to bet the business on an untested rewrite. It asks you to be patient, methodical, and honest about where you are in the process.
That is the hardest part: the patience.
Get Cloud Architecture Insights
Practical deep dives on infrastructure, security, and scaling. No spam, no fluff.
By subscribing, you agree to receive emails. Unsubscribe anytime.
