Exam Room · Advanced Solutions Architect

Modernising a Monolith with the Strangler-Fig Pattern

· 40 min read

Advanced Cloud Architecture · part of The Exam Room

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 monolith has its own account, and nothing new is allowed into it. We run an AWS Organization with an account per team per environment, so every capability we peel off starts life on the far side of an account boundary from the code it replaces and the database it still reads. That constraint shapes the answer more than the service choices do.

The application still works. That is the problem. Nobody will sign off on eighteen months of no feature work 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, so the alternative is to peel the monolith apart one capability at a time, in production, with the old system carrying the load and a clean path to undo any step that goes wrong.

What actually matters

Reversibility comes first. Every extraction is a bet, and some bets lose. If the new search service is slower or buggier than the monolith’s, we need that traffic back on the old path in seconds, not a scheduled rollback deploy. That means a routing layer we can flip per capability, and the old code left alive until the new path has carried real traffic.

Close behind is keeping the lights on. The monolith is the revenue system. No extraction can need a maintenance window measured in hours. The seams have to fall 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.

Then there is data ownership, where these programmes live or die. Standing up an ECS service and pointing traffic at it takes a week. The hard part is that the new service and the monolith both read and write the same rows. While they share one database they are two front-ends over a single state, and a schema change for one can break the other. Each capability has to end up owning its own data, which means decomposing that schema without a flag day.

The order of extraction is a design decision. The first capability out should be low-coupling and mostly write-and-forget. Crossing the account boundary belongs in that first build too: the network path, the IAM trust and the database reach get solved once, then reused.

What we’ll filter on

  1. Reversibility, can we move traffic back to the monolith per capability, fast, without a deploy?
  2. Blast radius, does a failed extraction stay inside that one capability?
  3. Data coupling, how tangled is this capability’s data with the rest of the schema?
  4. Account reach, does the mechanism still work when the new service, the monolith and the database sit in three different accounts?
  5. Steady-state running cost, what does the parallel path add to the bill while both exist?
  6. Consistency needs, can this capability tolerate stale reads, or must it read authoritative state synchronously?

The landscape

The routing facade. The front door that decides, per request, whether a call goes to the new service or the old monolith.

  • ALB listener rules. The monolith already sits behind an ALB. A rule matches on path-pattern, host-header, http-request-method or source-ip, with http-header and query-string available too. A forward action spreads matched requests across up to five weighted target groups, each weight an integer from 0 to 999; the five is a hard quota, the 100 rules per load balancer an adjustable one. Rules also carry transforms: host-header-rewrite and url-rewrite rewrite the host header, path or query string against a regular expression, so the new service need not inherit the monolith’s URL shapes. Authentication is a rule action too: authenticate-oidc and authenticate-cognito on HTTPS listeners, plus jwt-validation, which verifies an RS256 token against a JWKS endpoint.
  • Amazon API Gateway. Per-route integrations, request and response mapping, throttling, usage plans, and authorizers. Heavier to adopt than an ALB rule, and the right choice when the public API is carved up route by route. Canary release deployments are a REST API stage feature: percentTraffic takes any value from 0.0 to 100.0, and the canary gets its own stage variable overrides and CloudWatch log group. The account throttle is 10,000 requests a second per account per Region across all API types, with a 5,000-request token bucket; the rate can be raised on request, the burst cannot. The ceiling is per account, so each account in the Organization has its own.
  • Amazon CloudFront. The edge layer, for when one public hostname fronts several origins with caching. Behaviours route by path pattern, and either edge function type can send a request to an origin other than the behaviour’s own. Lambda@Edge does it on origin request, so only on a cache miss; CloudFront Functions do it on viewer request through the runtime 2.0 helpers updateRequestOrigin() and selectRequestOriginById(), so on every request. Continuous deployment is a weak cutover mechanism: a weight-based configuration sends at most 15% of traffic to the staging distribution, it is unavailable on a distribution supporting HTTP/3, and CloudFront routes everything to the primary during its own peak hours regardless of the policy.
  • A proxy inside the monolith. A branch in the monolith’s own code that calls the new service and falls back to the local implementation. Changing it needs a deploy, so it fails the reversibility filter, but sometimes it is the only seam a capability offers.

AWS Migration Hub Refactor Spaces was the managed version of this facade. It stopped accepting new customers on 7 November 2025, with AWS Transform named as its successor, so a new design cannot start there.

The extraction target. Where the peeled-off capability runs.

  • AWS Lambda. Best for event-driven or spiky capabilities with stateless handling: notifications, webhooks, image processing, a thin API route. An ALB target group of type lambda holds exactly one function, a fixed quota, so a fan of routes means a fan of target groups. The fit is worst for chatty, connection-hungry workloads, where every cold start opens another database connection and RDS Proxy becomes a prerequisite.
  • Amazon ECS on Fargate. The default landing spot for a service extracted from a web monolith. A long-running container behind a target group looks like the thing it replaced, and the operational model is familiar. Least conceptual distance from the monolith, which matters while the team is also learning the pattern.
  • Amazon EKS. Worth the overhead only where Kubernetes is already the house standard. Adopting this pattern and Kubernetes together is two hard things at once.

Reaching across the account boundary. The facade and the target are rarely in one account.

  • IP target groups over VPC peering. A target group of type ip takes private addresses from the load balancer’s own subnets or from 10.0.0.0/8, 100.64.0.0/10, 172.16.0.0/12 and 192.168.0.0/16, and those may sit in a peered VPC, same Region or another. Public addresses are rejected. So the ALB in the monolith’s account can forward by IP into another account’s VPC. Instance-ID targets cannot cross a peering at all.
  • Amazon VPC Lattice. A service network is a boundary that services join across VPCs in one or more accounts, shared through AWS RAM. Targets can be ALBs, Lambda functions, ECS tasks, Kubernetes pods or IP addresses, and auth policies at the service and network level replace much of the security-group reasoning. It suits the service-to-service calls between extracted capabilities.
  • A path for the replication. A DMS replication instance lives in one VPC, so a cross-account source and target needs peering, a Transit Gateway attachment or PrivateLink before any table mapping is written.

The data decomposition. The genuinely hard part: giving the new service its own data without a flag day.

  • Shared database. The transitional state almost every extraction passes through. The new service connects to the monolith’s RDS instance. Fast to reach, and not decomposition, because the schema is still a shared coupling point.
  • Dual-write. The application writes to both the old table and the new store. Simple to reason about, and it puts consistency in the application’s hands: a crash between the two writes leaves them out of sync, and no transaction spans both. Workable with reconciliation, risky as a destination.
  • Change data capture with AWS DMS. DMS reads the source’s transaction log and applies changes to a target continuously, with no application code change and none of the dual-write consistency hole. On an RDS for PostgreSQL source that means logical replication: rds.logical_replication set to 1 in the DB instance’s parameter group, a static parameter needing a reboot and raising WAL generation, and an endpoint user holding rds_superuser and rds_replication. Two limits matter more than the mechanics. DMS creates tables and primary keys on the target, not secondary indexes, foreign keys or user accounts, and ongoing replication propagates no indexes, users, privileges or stored procedures. And AWS states that CDC is not real-time, carries no latency SLA, and can run several minutes behind under heavy source load. DMS Serverless swaps the replication-instance sizing decision for a replication configuration that scales itself.
  • Event-carried state transfer. The owning service publishes domain events through EventBridge, SNS, or a Kinesis or MSK stream, and each consumer keeps its own copy of what it needs. Services decouple fully, every consumer reads state that lags the writer, and the event contracts have to be designed rather than discovered. This is the end state once ownership has moved.
  • Per-service data ownership. The destination. Each capability owns its schema or its own database, no other service reads its tables, and 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. A new service should not inherit the monolith’s model. A thin translation layer at its edge maps the monolith’s shapes and vocabulary to the new model, and when the monolith goes, that layer is the only part you delete.

Evaluation

Side by side

Routing facades, by what they can do:

Facade Per-capability rollback Weighted cutover Per-route auth Request rewrite Friction to adopt
ALB listener rules ✓ (change the weights) ✓ (5 target groups, weights 0-999) ✓ (OIDC and Cognito on HTTPS; JWT verification) ✓ (host and URL rewrite transforms) Lowest
API Gateway ✓ ✓ (REST canary, 0.0-100.0%) ✓ (authorizers per route) ✓ (mapping templates) Moderate
CloudFront behaviours ✓ (edge function picks the origin) ✗ (continuous deployment stops at 15%) Partial (edge functions) ✓ (Functions, Lambda@Edge) Moderate
In-monolith proxy ✗ (needs a deploy) Partial ✓ (in code) ✓ (in code) Lowest, no fast rollback

Data decomposition, by what each approach demands:

Approach Application change Consistency Good as a destination Main risk
Shared schema None One database, one transaction ✗ Still coupled through the schema
Dual-write Yes Application-managed ✗ Partial-failure drift between the two writes
CDC via DMS None Lagging, no latency SLA Transitional only Slot growth on the source; no secondary indexes or foreign keys created
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

Day one Mid-migration Nearly done Facade (ALB) Monolith subscriptions · billing catalogue · search notifications · admin public API Shared RDS ~180 tables One system. One database. Facade (ALB) Notify Lambda Monolith subs · billing catalogue admin · API Search Fargate Shared RDS still authoritative notify store search index Two capabilities out. CDC syncs their data. Facade (ALB) Notify Search Catalogue Subs Billing Monolith (remnant) A database per service events carry shared state between them Each owns its data. The remnant is deletable.
The facade stays put; capabilities peel off behind it one at a time. Data leaves the shared database last, via CDC, until every service owns its own.

The solution

Pick a low-risk edge capability first. Notifications is the archetype. Something happens, an email or push goes out; few other parts of the app read from it, and if it breaks for ten minutes nobody’s checkout fails. Extracting it first builds the facade, the cross-account network path, the pipeline, the alarms and the rollback drill on a capability that cannot take down the business. Search is the usual second: read-heavy, tolerant of a stale index, and a better fit for a purpose-built store than LIKE queries against RDS. Billing and subscriptions go last. 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 facade is the high-stakes decision. For a monolith already behind an ALB, start with listener rules. Add a rule matching /notifications/* and forward it to a new target group; leave the default rule on the monolith. To cut over gradually, put both target groups in one forward action with weights and move the split while watching error rate and latency. Weights are shares, not percentages: two groups at 100 each get half the traffic, so keep the pair summing to 100 and each weight reads as a percentage. Rolling back is a modify-rule call setting the new group’s weight to 0, effective on the next requests, no deploy. Where the new service is in another account, the target group is type ip holding private addresses in a peered VPC.

Two behaviours of weighted target groups decide whether that rollback works. The load balancer does not fail over away from a weighted target group that is empty or holds only unhealthy targets. A new service that dies keeps taking its share of requests until somebody changes the weight, so the alarm on its 5XX count and unhealthy host count has to exist before the first request shifts, and the weight change has to be a one-command runbook step. The second is stickiness. With target group stickiness on, the load balancer sets an encrypted AWSALBTG cookie naming the group it chose, and a later request carrying that cookie goes there whatever the weights now say. Leave it off during a cutover, or set DurationSeconds short enough that a rollback drains in minutes.

Reach for API Gateway when the public API is carved route by route and you want per-route authorizers, throttling and request mapping; its 10,000 requests a second throttle is per account per Region, so a service in its own account gets its own headroom. Reach for CloudFront when you need one edge hostname and caching over several origins. Either edge function type can pick the origin: CloudFront Functions on viewer request where traffic is dynamic and goes to origin anyway, Lambda@Edge on origin request where the content is cacheable and the logic should run only on a miss. Behind the facade, land the first services on Fargate unless the workload is genuinely event-shaped, in which case Lambda. Save EKS for shops already standardised on Kubernetes.

Decompose the database via CDC, and treat the shared database as a stage you leave. Almost every extraction starts with the new service reading the same RDS instance as the monolith, because it is quick and it works. It is not decoupled. While both share the schema, a migration for one can break the other.

The move to ownership goes in steps. Stand up the new service’s own database in its own account, and open the network path from a DMS replication instance to both sides. Turn on logical replication on the source: rds.logical_replication to 1 in the DB instance’s parameter group, a reboot to apply it, an endpoint user with rds_superuser and rds_replication. Run a full-load-plus-CDC task into the new store. Create the target’s secondary indexes yourself before the CDC phase starts, because DMS creates tables and primary keys and nothing else, and CDC applying changes against unindexed tables falls into full table scans. Point the new service’s reads at its own database and run DMS data validation for a few days. Then flip writes. The new service becomes the writer, and where the monolith still needs that data it reads the new service’s API or consumes its events. When nothing in the monolith writes those tables, the foreign keys that crossed the boundary are gone.

Two things about CDC matter before anyone promises a cutover window on the strength of it. AWS says plainly that ongoing replication is not real-time and carries no latency SLA, and that it can fall several minutes behind while the source runs batch or maintenance work. Any step that assumes the target is current needs the source quiesced, not just quiet. And the logical replication slot holds WAL on the source until DMS consumes it, so a task that stalls over a weekend fills the monolith’s storage. Alarm on source free storage and on CDCLatencySource from the first task, and enable the WAL heartbeat so an idle slot does not pin old WAL.

Dual-write is the bridge where CDC does not fit. Use it with a reconciliation job and a plan to leave it. Keep an anti-corruption layer at each new service’s edge throughout, so a decade of legacy design does not leak into the fresh code.

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 database. Stand up a notify Lambda in the notifications account. Peer its VPC to the monolith’s, and for now let it read the same three tables from the monolith’s RDS instance. Register it in its own lambda target group, one function per group, and add an ALB rule so POST /notifications/* forwards there; everything else stays on the monolith. Ship at weight 0 against the monolith’s 100, then move the pair to 5/95, 25/75, 50/50 and 100/0 over a week, watching send success, latency and error rate. At any sign of trouble set the new weight back to 0 and the monolith is sending again on the next request.

2. Give it its own data with CDC. Create the notify service’s own database in its account, a small RDS instance or a DynamoDB table depending on the access pattern. Set rds.logical_replication to 1 in the monolith’s DB parameter group and reboot in a quiet window. Create the target tables and indexes by hand, then run a DMS full-load-plus-CDC task against the three source tables, with target preparation set to truncate rather than drop so DMS does not recreate them with inferred column types. Repoint the Lambda’s reads and run data validation for a few days.

{
  "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 sending a notification calls the notify service rather than writing the notification tables, through an anti-corruption translation that maps the monolith’s user and event shapes to the notify model. Better still, have the monolith publish SubscriptionPaused and DeliveryDispatched to EventBridge on a bus shared with the notifications account, and let the notify service subscribe and send from them. The notify service is now the sole writer of its own data. Stop the DMS task and drop its replication slot, or the source keeps retaining WAL for it. Drop the three tables once nothing reads them.

4. Delete the old path. Remove NotificationService and its SES and SNS calls from the monolith, and close the peering route the Lambda used to reach the shared database. The capability is fully extracted: its own account, its own service, its own data, reached through the facade and events. Search is next, on the machinery this one built.

What’s worth remembering

  1. Strangle one capability at a time. The monolith keeps carrying load; every step before the write flip reverses at the routing layer.
  2. Weight zero is the rollback. An ALB forward action spans up to five target groups weighted 0 to 999; setting 0 needs no deploy.
  3. Rollback has two sharp edges. Weighted groups with no healthy targets get no failover, and stickiness pins clients to their first group.
  4. Cross-account targets use ip groups. Private addresses in a peered VPC; instance-ID targets cannot cross a peering.
  5. DMS CDC has three limits. It creates only tables and primary keys, has no latency SLA, and holds source WAL until its task drains.
  6. Extract the low-coupling edge first. Start with something like notifications to build the facade, network path and rollback drill; billing goes last.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.