The situation
We run a ten-year-old Rails monolith on a fleet of EC2 instances behind an ALB in ap-southeast-2. It is the whole business: subscriptions, billing, a product catalogue, search, notifications, an admin console, and a public API. One RDS for PostgreSQL instance sits behind all of it, around 180 tables, and every part of the application reads and writes across the whole schema. There are foreign keys between tables that, on paper, belong to entirely different capabilities.
The application still works. That is the problem. It works well enough that nobody will sign off on stopping feature work for eighteen months to rewrite it, and it is tangled enough that every change is slow and every deploy is a held breath. The board wants two things that pull against each other: keep shipping, and get off the monolith.
A big-bang rewrite is off the table. We have all seen that plan; it is the one that runs two years late, ships nothing in the meantime, and gets cancelled when the market moves. What we want instead is a way to peel the monolith apart one capability at a time, in production, with the old system carrying the load the whole way, and with a clean path to undo any step that goes wrong.
What actually matters
The property that matters more than any other is reversibility. Every extraction is a bet, and some bets lose. If routing search to a new service turns out to be slower or buggier than the monolith’s built-in search, we need to move that traffic back in seconds, not schedule a rollback deploy. That pushes the whole design toward a routing layer we can flip per-capability, and toward keeping the old code path alive until the new one has earned the traffic.
Close behind is keeping the lights on. The monolith is the revenue system; nothing we do can require a maintenance window measured in hours, and no single extraction can put the core at risk. That means the seams we cut have to be at capability boundaries where the blast radius is contained. Pulling out notifications can fail without touching checkout. Pulling out billing cannot, so billing goes late, once the pattern is boring.
Then there is data ownership, which is where these programmes actually live or die. It is tempting to think the hard part is standing up an ECS service and pointing some traffic at it. That part is a week. The hard part is that the new service and the old monolith both need to read and write the same rows, and as long as they share one database, they are not decoupled at all; they are two front-ends over a single shared state, and a schema change for one can break the other. Real modernisation means each capability ends up owning its own data, which means decomposing that shared schema without a flag day.
The last thing worth weighing before picking any service is sequencing. The order of extraction is a design decision, not an afterthought. The first capability out should be low-risk, low-coupling, and mostly write-and-forget, so the team can build the facade, the deployment pipeline, the observability, and the rollback muscle on something that cannot take down the business. The scary, highly-connected capabilities go last, when the machinery is proven and the team trusts it.
What we’ll filter on
- Reversibility, can we move traffic back to the monolith per-capability, fast, without a deploy?
- Blast radius, does a failed extraction stay contained to that one capability?
- Data coupling, how tangled is this capability’s data with the rest of the schema?
- Steady-state carrying cost, what does running the facade and the parallel path cost while both exist?
- Consistency needs, can this capability tolerate eventual consistency, or does it need a synchronous read of authoritative state?
- Operational maturity required, how much of the pipeline and observability has to exist before this step is safe?
The landscape
The pattern has three moving parts: a routing facade in front of everything, a target to extract each capability into, and a way to decompose the data. Each part has a small landscape of AWS options.
The routing facade. This is the front door that decides, per request, whether a call goes to the new service or the old monolith. It is the thing that makes extraction incremental and reversible.
- ALB path- and header-based routing. The monolith already sits behind an ALB. Listener rules can match on path (
/notifications/*), host header, HTTP method, or a custom header, and forward to a different target group. Adding a rule that sends/searchto a new ECS target group, and leaving everything else on the monolith, is the lowest-friction facade there is. Weighted target groups let a rule split traffic (say 5% to the new service, 95% to the monolith) for a gradual cutover. - Amazon API Gateway. A managed API front door with per-route integrations, request/response transformation, throttling, usage plans, and authorizers. Heavier than an ALB rule, but it is worth it when the public API is being carved up route-by-route and you want per-route auth, rate limits, and mapping. Stage variables and canary release deployments give you weighted cutover at the API layer.
- Amazon CloudFront. Sits further out, at the edge. Behaviours route by path pattern to different origins (the monolith’s ALB, or an API Gateway, or an S3 bucket for a capability that became static). CloudFront Functions or Lambda@Edge can rewrite and route on headers or cookies. Useful as the outermost layer when you also want caching and a single public hostname over several origins.
- A proxy inside the monolith. Before any of the AWS layers, the crudest facade is a branch in the monolith’s own code: an
ifthat calls out to the new service and falls back to the local implementation. It needs a deploy to change, so it fails the reversibility filter, but it is sometimes the only seam available for a capability with no clean URL boundary.
The extraction target. Where the peeled-off capability runs.
- AWS Lambda. Best for event-driven or spiky capabilities with clean, stateless request handling: notifications, webhooks, image processing, a thin API route. No servers, scales to zero, pay per invocation. The fit is worst for chatty, long-lived, or connection-pool-hungry workloads.
- Amazon ECS on Fargate. The default landing spot for a service extracted from a web monolith. It looks like the thing it replaced (a long-running container behind a target group), the operational model is familiar, and it slots straight behind an ALB target group. Least conceptual distance from the monolith, which is exactly what you want while you are also learning the pattern.
- Amazon EKS. Worth it only if the organisation is already standardised on Kubernetes. For a team leaving a monolith, adopting the strangler-fig pattern and Kubernetes at the same time is two hard things at once; Fargate is the calmer choice unless EKS is already home.
The data decomposition. The genuinely hard part: giving the new service its own data without a flag day.
- Shared database, read-only or read-write. The transitional state almost every extraction passes through. The new service talks to the same RDS instance as the monolith. It is fast to reach and it is not decomposition; it is a stepping stone you must plan to leave, because the schema is still a shared coupling point.
- Dual-write. The application writes to both the old table and the new store during the transition. Simple to reason about, but it puts consistency in the application’s hands: a crash between the two writes leaves them out of sync, and there is no transaction spanning both. Workable for a while with reconciliation, risky as a destination.
- Change data capture with AWS DMS. DMS reads the source database’s transaction log and streams changes to a target continuously. Ongoing replication (CDC) keeps a new per-service database in sync with the monolith’s tables in near real time, with no application code change and no dual-write consistency hole. It is the workhorse for moving a capability’s data into its own store while the monolith keeps writing the original.
- Event-carried state transfer. The owning service publishes domain events (via EventBridge, SNS, or a Kinesis/MSK stream) carrying the state other services need, and each consumer keeps its own local copy. It decouples services fully at the cost of eventual consistency and the work of designing event contracts. This is the end state for cross-capability data sharing once ownership has actually moved.
- Per-service data ownership. The destination: each capability owns its schema (or its own database) outright, no other service reads its tables directly, and all cross-service reads go through an API or an event stream. Foreign keys that used to cross capability lines become API calls or locally-projected copies.
The anti-corruption layer. Not an AWS service; a design tenet that runs through the whole thing. As each new service comes out, it should not inherit the monolith’s model. A thin translation layer at the service’s edge maps the monolith’s shapes and vocabulary to the new service’s own model, so the legacy design does not leak in and calcify. When the monolith eventually goes, the anti-corruption layer is the only part you delete, and the clean service underneath stands on its own.
Evaluation
Side by side
Routing facades, by what they are good at:
| Facade | Reversible per-capability | Weighted cutover | Per-route auth / transform | Edge caching | Friction to adopt |
|---|---|---|---|---|---|
| ALB listener rules | ✓ | ✓ (weighted target groups) | ✗ | ✗ | Lowest |
| API Gateway | ✓ | ✓ (canary + stage vars) | ✓ | ✗ | Moderate |
| CloudFront behaviours | ✓ | ✓ (continuous deploy) | Partial (Functions / L@E) | ✓ | Moderate |
| In-monolith proxy | ✗ (needs deploy) | Partial | ✓ (in code) | ✗ | Lowest, but not reversible |
Data decomposition, by what it costs you:
| Approach | Application change | Consistency | Good as a destination | Main risk |
|---|---|---|---|---|
| Shared DB | None | Strong (one DB) | ✗ | Still coupled through the schema |
| Dual-write | Yes | App-managed | ✗ | Partial-failure drift between writes |
| CDC via DMS | None | Near-real-time | Transitional to ✓ | Replication lag; ongoing task to run |
| Event-carried state transfer | Yes (publish events) | Eventual | ✓ | Contract design; consumer catch-up |
| Per-service ownership | Yes | Owned per service | ✓ | The migration to reach it |
How the fig grows
The solution
Pick a low-risk edge capability first. Notifications is the archetype. It is mostly write-and-forget (something happens, an email or push goes out), it has few inbound reads from the rest of the app, its data (templates, send logs, preferences) touches little else, and if it breaks for ten minutes nobody’s checkout fails. Extracting it first gives the team the facade, the CI/CD pipeline for a service, the dashboards and alarms, and the rollback drill, all built on a capability that cannot take down the business. Search is the usual second: read-heavy, tolerant of eventual consistency (a product indexed a few seconds late is fine), and a natural fit for a purpose-built store rather than LIKE queries against RDS. Billing, subscriptions, and anything with hard foreign keys into the core go last, after the pattern is boring.
Resist the urge to start with the capability that annoys the team most. That one is usually the most coupled, which makes it the worst teacher. The first extraction’s job is to prove the machinery, not to win the biggest prize.
The facade is the high-stakes decision. For a monolith already behind an ALB, start with ALB listener rules. Add a rule matching /notifications/* and forward it to a new target group; leave the default rule pointing at the monolith. Nothing else changes. To cut over gradually, use a weighted forward action across two target groups (new service and monolith) and walk the weight from 5% to 100% while watching error rates and latency; if the new service misbehaves, set the weight back to 0% and traffic is on the monolith again within a health-check interval, no deploy required. That is the reversibility the whole pattern depends on. Reach for API Gateway when you are carving the public API route-by-route and want per-route authorizers, throttling, and request mapping; reach for CloudFront when you also need a single edge hostname and caching over several origins. Keep the in-monolith proxy for the awkward capability with no clean URL seam, and treat its lack of instant reversibility as a known cost.
Behind the facade, land the first services on Fargate unless the workload is genuinely event-shaped, in which case Lambda. Fargate keeps the smallest conceptual distance from the monolith you just left: a long-running container behind a target group, deployed from a pipeline, the mental model the team already has. Save EKS for shops already standardised on Kubernetes; adopting the pattern and Kubernetes together is two hard things at once.
Decompose the database via CDC, and treat the shared DB as a stage you leave. This is where the programme is won. Almost every extraction starts with the new service reading the same RDS instance as the monolith, because it is fast and it works. It is not decoupled. As long as both systems share the schema, a migration for one can break the other, and you have two front-ends over one state.
The move to ownership goes in steps. Stand up the new service’s own database. Run an AWS DMS task with full-load-plus-CDC from the monolith’s tables into it: the full load seeds the new store, and ongoing CDC reads the source’s write-ahead log and streams every change across with low lag and no monolith code change. Point the new service’s reads at its own database. Now flip writes: the new service becomes the writer for its own data, and where the monolith still needs that data, it consumes it through the new service’s API or through domain events the service publishes (event-carried state transfer via EventBridge or a stream). Reverse the CDC direction during the write cutover if the monolith must keep a read-only copy for a while. When nothing in the monolith writes those tables any more, the foreign keys that used to cross the boundary are gone, replaced by API calls or locally-projected copies, and the capability owns its data outright.
Dual-write is available as a bridge when CDC does not fit (no log access, or a target DMS cannot reach), but it puts consistency in the application: a crash between the two writes drifts them apart, and there is no transaction over both. Use it with a reconciliation job and a plan to leave it; do not make it the destination.
Through all of this, put an anti-corruption layer at each new service’s edge. The new service should model its domain on its own terms, not the way the monolith did. A thin translation layer maps the monolith’s shapes to the new model on the way in and out, so a decade of legacy design does not seep into the fresh code and set. When the monolith’s remnant is finally deleted, the anti-corruption layer is the only scaffolding you throw away with it.
Worked example
Start state: every email, SMS, and push notification is sent by a NotificationService class inside the monolith, writing to notification_templates, notification_log, and user_notification_prefs in the shared RDS, and calling SES and SNS directly.
1. Build the service behind the facade, reading the shared DB. Stand up a notify Lambda (event-shaped: it reacts to “something happened, send a message”). For now it connects to the same RDS instance and reads the same three tables. Add an ALB listener rule so POST /notifications/* forwards to the Lambda via a target group; everything else stays on the monolith. Ship it at 0% weight, then walk /notifications/send from 5% to 100% over a week, watching send success, latency, and error rate on a dashboard. At any sign of trouble, weight back to 0 and the monolith is sending again within a health-check interval.
2. Give it its own data with CDC. Create the notify service’s own database (a small RDS or DynamoDB, depending on the access pattern). Run a DMS full-load-plus-CDC task from the three source tables into it. Full load seeds history; CDC keeps it current off the monolith’s write-ahead log with no monolith change. Repoint the Lambda’s reads to its own store and confirm parity against the shared DB for a few days.
{
"TableMappings": {
"rules": [
{
"rule-type": "selection",
"rule-id": "1",
"rule-name": "notify-tables",
"object-locator": {
"schema-name": "public",
"table-name": "%notification%"
},
"rule-action": "include"
}
]
}
}
3. Flip writes and cut the tie. Change the monolith so that when it needs to send a notification, it calls the notify service (through an anti-corruption translation at the boundary, mapping the monolith’s user and event shapes to the notify model) instead of writing the notification tables itself. Or, better, have the monolith publish domain events (SubscriptionPaused, DeliveryDispatched) to EventBridge and let the notify service subscribe and decide what to send. Now the notify service is the sole writer of its own data. Stop the DMS task. Drop the three notification tables from the monolith’s schema once nothing reads them.
4. Delete the old path. Remove NotificationService and its SES/SNS calls from the monolith. The capability is fully extracted: its own service, its own data, reached only through the facade and events, reversible at the routing layer right up until step three. The team now has a proven template, and search is next.
What’s worth remembering
- The strangler-fig pattern beats a big-bang rewrite because it keeps shipping and keeps the old system carrying load the whole way, with a reversible step at every stage.
- Everything else hangs off the facade. For a monolith already on an ALB, listener rules with weighted target groups give per-capability, no-deploy reversibility; API Gateway and CloudFront are worth it when you need per-route control or edge caching.
- Sequence deliberately: a low-risk, low-coupling edge capability first (notifications, then search), the tangled core (billing, subscriptions) last, once the machinery is boring.
- Standing up the new service is the easy week. The programme is won or lost on decomposing the shared database.
- CDC via AWS DMS (full load plus ongoing replication off the write-ahead log) moves a capability’s data into its own store with no application change and none of the partial-failure risk of dual-write.