When Amazon Prime Video published its 2023 write-up about folding a monitoring tool back into one application, most of the attention went to the 90 percent cost drop. The detail that matters more for a migration is the direction they moved, from many small services toward fewer larger ones, because it shows that splitting is a decision you can get wrong in both directions. This is a playbook for the other direction, when you already have a monolith and a specific part of it has started to hurt.
Whether you should split at all is a separate question, and the monolith vs microservices guide covers that decision along with the modular monolith middle ground you should exhaust first. This article assumes you have decided a split is worth it and focuses on doing it without a rewrite: reading the signals, extracting the first service with the strangler-fig pattern, untangling the shared database, and avoiding the two mistakes that sink most migrations.
The strangler-fig extraction flow: a gateway sits in front of a monolith, one capability is carved into a new service, and the gateway routes that capability's traffic to the new service while everything else still hits the monolith.
A migration should follow a named cost, the same test that governs whether to split at all. The difference at this stage is that the cost is concrete enough to point at, and you can usually name the exact part of the system it lives in. The signals that a specific capability has earned its own service:
If you cannot map the pain to one of these, the honest move is to tighten the monolith's internal boundaries instead and revisit later. Splitting to relieve a cost you can't name gives you the operational bill of microservices with nothing on the other side of the ledger.
The name comes from a fig that grows around a host tree, gradually taking over until the original is gone. Applied to a migration, you grow new services around the monolith and let them take over its work one capability at a time, so the monolith is never rewritten and never stops serving traffic. Each extraction is small enough to ship, verify, and roll back on its own.
The mechanics rest on a routing layer in front of the monolith, usually an API gateway or a reverse proxy. Every request enters through it, and by default it forwards to the monolith. When a new service is ready to own a capability, you change the routing so the paths for that capability go to the service and everything else keeps hitting the monolith. Clients see one endpoint the whole time and never learn that a route moved.
A single extraction runs through a few steps:
Then you repeat for the next capability, and you stop whenever the remaining monolith isn't causing pain. A migration doesn't have to end at zero monolith; most stop with a smaller core plus a handful of services around the parts that needed to move.
The first extraction sets the pattern the rest of the migration copies, so pick a capability that will succeed rather than the one that hurts most. You want a boundary that is easy to reason about and cheap to reverse, which means favoring a capability that reads and writes a narrow, self-contained slice of data and rarely needs synchronous access to the rest of the system.
Good first candidates share a shape:
| Candidate | Why it's a good first cut |
|---|---|
| Notifications (email, SMS, push) | Consumes events, owns its own templates and delivery logs, almost no synchronous reads from the core. |
| Search / indexing | Different scaling and its own datastore already, reads the core's data through a feed rather than live joins. |
| Media or PDF processing | CPU-heavy on a separate curve, takes a job and returns a result, minimal shared state. |
| Payments / billing | Clear business boundary, benefits from isolation, well-defined interface. |
Avoid making the first cut a capability that sits in the middle of every request path, like authentication or a core domain object that half the codebase reads. Those are entangled enough that a first extraction turns into a research project, and a stalled first attempt tends to end the whole migration.
Carving the capability out is mostly a boundary exercise before it is a networking one. Find every place the rest of the monolith calls into the capability and route those calls through a single interface, then find every place the capability reaches into the rest and do the same. Once both directions go through a narrow interface inside the monolith, moving the capability across a network boundary becomes a change of transport rather than a change of design. A modular monolith gives you these seams for free, which is why tightening the monolith first makes the eventual split so much cheaper.
The shared database is where most migrations stall, because a monolith usually has one schema with foreign keys and joins reaching across what will become service boundaries. The rule that keeps you out of trouble is one owner per table: exactly one service writes a given table, and everyone else reaches that data through the owner's API or through events, never with a direct connection.
Do the split in two stages rather than one. First extract the service but leave its tables in the monolith database, and have the service read and write that data by calling the monolith's API. The service is running and owns its logic, while the data hasn't moved, so you can prove the boundary is right before you touch the schema. Second, once the service is stable, move the tables it owns into its own database and replace any cross-boundary joins with API calls or events.
Cross-boundary joins are the specific thing to hunt for, because a query that joins the service's tables to the monolith's won't survive the data split. Each one becomes either an API call to fetch the related data or a local copy the service keeps up to date from events the owner publishes. When a service needs a slice of another service's data on its own read path, having the owner publish changes to a Pub/Sub topic and letting the consumer maintain a local read model is the standard fix, since it removes the synchronous dependency that a live join would create.
The big-bang rewrite. The tempting version of a migration is to freeze the monolith, build the whole microservices system beside it, and cut over once it's done. It fails for reliable reasons: feature work stops for months while the business keeps needing features, the new system has to reproduce behavior in the old one that nobody fully remembers, and requirements drift the entire time so the target moves as you build. Strangler-fig extraction avoids all three, because the product keeps shipping, each step is small enough to understand, and you're always migrating against the current system rather than a frozen snapshot of it.
Extracting by layer instead of by capability. The other common failure is splitting along technical layers, so that one service handles data access, another the business logic above it, and another the API on top. It looks tidy on an architecture diagram and produces the worst of both worlds, because every request now hops across all the layer-services in sequence, so you've added network calls and separate deploys while the services stay so coupled they must change together. Splitting by business capability instead, where one service owns billing or search end to end including its data, gives each service a boundary that matches how the product changes and lets it deploy and scale on its own.
Most of what makes an extraction expensive is the infrastructure the new service needs on day one: its own database, the queues it consumes, the secrets it reads, and the service discovery, tracing, and deploy pipeline that let it talk to what's left of the monolith. With Encore that infrastructure is declared as typed objects in the service's own code, so standing up the first service is closer to writing a module than to provisioning a new system.
// notifications/notifications.ts
import { api } from "encore.dev/api";
import { Topic } from "encore.dev/pubsub";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { users } from "~encore/clients";
// The extracted service declares its own database...
const db = new SQLDatabase("notifications", { migrations: "./migrations" });
// ...and subscribes to events instead of joining the monolith's tables.
export const orderPlaced = new Topic<OrderPlaced>("order-placed", {
deliveryGuarantee: "at-least-once",
});
export const send = api(
{ method: "POST", path: "/notify" },
async (p: NotifyParams): Promise<void> => {
// A type-checked call to another service, no HTTP client or service URL.
const user = await users.get({ id: p.userId });
await deliver(user.email, p.template);
},
);
The parts of the strangler-fig playbook that usually take the most setup come with the framework. Services call each other as type-checked functions through the ~encore/clients import, so the boundary between the extracted service and the rest is checked at compile time instead of discovered in production. Each service gets its own Postgres database provisioned into your own AWS or GCP account, which fits the one-owner-per-table rule directly. Pub/Sub topics with delivery guarantees give you the event-driven read models that replace cross-boundary joins, and distributed tracing spans the monolith-plus-services shape you have mid-migration, so a request that hops from the old code into a new service is one trace rather than two disconnected logs. Because the same codebase can hold several services, you can extract a capability into its own service while it still lives beside the monolith code, which is close to what the strangler-fig pattern asks for. When you'd rather run the result on your own platform, encore build docker produces a standard image.
Break out a service when you can point at a concrete cost the monolith is imposing, such as one part of the system needing to scale on its own, teams blocking each other on a shared deploy pipeline, or a component that needs isolation for compliance or reliability. If nothing in the monolith is hurting yet, splitting adds network calls and separate deploys without buying anything back.
The strangler-fig pattern extracts a monolith into services one piece at a time behind a routing layer. You stand up a new service for a single capability, point a proxy or gateway at it for the routes it now owns, and leave everything else in the monolith. Over successive extractions the new services take over more traffic while the old code shrinks, until the monolith is either gone or reduced to a small core.
Pick a capability with a clear business boundary, few dependencies on the rest of the code, and a real reason to be separate, such as different scaling or a team that owns it. Notifications, search indexing, PDF or media processing, and payments are common first candidates because they read or write a narrow slice of data and rarely need synchronous access to the rest of the system.
Keep the tables in the monolith database at first and have the new service reach them through the monolith's API rather than connecting directly, which preserves one owner per table. Once the service is stable, move the tables it owns into its own database and replace cross-boundary joins with API calls or events. Splitting the schema before the service works is the step that most often forces a rollback.
A big-bang rewrite stops feature work for months, ships nothing until everything is done, and asks the team to reproduce behavior in the old system that nobody fully remembers. Requirements drift while the rewrite is in flight, so the new system chases a moving target. Incremental extraction keeps the product shipping the whole time and lets you stop once the painful parts are out.
Split by capability, not by technical layer. Carving out a service for a business capability like billing or search gives it ownership of its own data and a boundary that maps to how the product changes. Splitting by layer, such as a separate service for all data access or all business logic, produces services that must talk to each other on every request and gives you distributed calls with none of the independence.