Saurabh Singh
Journal

9 min readSaurabh Singh

Migrating a Monolith to ECS Microservices with Zero Client Changes

How we split a 24+ module Spring Boot monolith into independently deployable AWS ECS Fargate services behind Traefik, using transparent path rewrites, promotion gates, and a planned 308 retirement of legacy routes.

  • #platform-engineering
  • #microservices
  • #migration
  • #traefik
  • #aws
  • #strangler-fig

Every monolith migration plan I have seen starts with the same slide: boxes on the left, smaller boxes on the right, an arrow in between. The arrow is where migrations die.

Ours was a transaction-processing platform: one Spring Boot deployable carrying 24+ modules, several of them exposing APIs under the same context path, all coordinated by long-running processes on a workflow orchestration engine. Every change shipped with every other change. A fix to one module meant regression-testing all of them, and a bad release in one corner rolled back everything.

The goal was simple to say: every module becomes an independently deployable service on AWS ECS Fargate. The constraint was the hard part: no external client changes anything. Not a URL, not a header, not a payload. Integrators had their own release trains, and asking all of them to update endpoints in lockstep would have been a migration plan in itself.

This post is how we did it, what the gates looked like, and what I would change.

Principles before plumbing

We wrote the rules down before anyone touched a Dockerfile, because every later argument came back to them.

Goals:

  • Break out each module into an independently deployable, observable service.
  • Preserve backward compatibility for every existing client through transparent path rewrites.
  • Promote through DEV → QA → UAT → PROD with explicit, measurable gates.
  • Give every service the same CI/CD, monitoring, and rollback.

Non-goals:

  • Functional API changes beyond path prefixes.
  • Schema or payload changes.

That second list mattered more than the first. A migration that also "cleans up" the API is two projects wearing one ticket, and when something breaks you cannot tell which one broke it.

And three principles: small, reversible changes per service; observability first, with error budgets as guardrails; and naming that is identical across services, so the fiftieth routing rule reads like the first.

The target shape

Traffic enters through an HTTPS Application Load Balancer, which forwards to Traefik running on ECS with its ECS provider enabled. Traefik discovers services from their container labels and routes to each one. No central routing file to edit, no load balancer rule per service.

Every service got a canonical external path from day one:

/api/<service>/v1/...

So the notification service lives at /api/notification/v1/..., documents at /api/document/v1/..., and so on. But existing clients call the old shared prefix, /api/v1/..., with no service segment. The whole trick is making both work at once, forever if necessary, without the services knowing the difference.

Two routers, one service

Each service carries two Traefik routers in its container labels.

Architecture · simplifiedTwo routers, one service
A legacy request to /api/v1/accounts/42/notifications matches a narrow, high-priority legacy router. A ReplacePathRegex middleware inserts the service segment, turning it into the canonical /api/notification/v1/… path, and the shared StripPrefix middleware removes /api/notification before the request reaches the container. A request that already uses the new prefix matches a plain PathPrefix router and goes through the same StripPrefix, so both arrive at the service as /v1/accounts/42/notifications.

The new router is boring on purpose: a PathPrefix rule on the service's canonical prefix, plus a StripPrefix middleware so the container sees /v1/....

The legacy router matches the old path with a narrow regex, then runs two middlewares in order. ReplacePathRegex inserts the service segment, turning /api/v1/... into /api/notification/v1/.... Then the same StripPrefix the new router uses removes it again.

That looks redundant: why insert a segment only to strip it? Because it makes the legacy path converge on the canonical path before anything else happens. There is exactly one strip rule per service and exactly one shape of request the container ever sees. When you later retire the legacy router, nothing downstream changes.

Here is the label block for one service, with the templating removed so it reads plainly:

# New canonical prefix: /api/notification/v1/...
traefik.http.routers.notification.rule: "PathPrefix(`/api/notification`)"
traefik.http.routers.notification.middlewares: "notification-strip"
traefik.http.middlewares.notification-strip.stripprefix.prefixes: "/api/notification"
traefik.http.services.notification.loadbalancer.server.port: "8080"
traefik.http.services.notification.loadbalancer.healthcheck.path: "/actuator/health"

# Legacy: /api/v1/.../notifications, rewritten into the canonical prefix
traefik.http.routers.notification-legacy.rule: "PathRegexp(`^/api/v1/accounts/[^/]+/(?:contacts/[^/]+/)?notifications(?:/.*)?$`)"
traefik.http.routers.notification-legacy.priority: "110"
traefik.http.routers.notification-legacy.service: "notification"
traefik.http.routers.notification-legacy.middlewares: "notification-legacy-insert,notification-strip"
traefik.http.middlewares.notification-legacy-insert.replacepathregex.regex: "^/api/v1/(.*)$"
traefik.http.middlewares.notification-legacy-insert.replacepathregex.replacement: "/api/notification/v1/$1"

In practice every value above was a CloudFormation parameter (prefix, port, health path, resource fragment), so adding a service meant filling in a table row, not writing routing logic.

The regex is the whole risk

All legacy traffic shares one prefix. So the only thing deciding which service receives /api/v1/... is the resource fragment in each legacy regex. Get it too broad and one service quietly swallows another's traffic.

Three rules kept that from happening:

  • Match on the resource, narrowly. notifications, documents, a specific validation endpoint. Never a wildcard segment that happens to work today.
  • Set an explicit priority. Traefik's default priority is the rule's length, which is an accident waiting to happen when regexes differ in length. Every legacy router got priority: 110, high enough to beat any broad catch-all.
  • Match what clients actually send, not what the spec says. If some callers send PaymentMethods and others paymentmethods, the regex has to accept both ([Pp]aymentmethods). Access logs are a better source for this than API docs.

Keep a cheat sheet of every legacy regex in one place. When two services disagree about who owns a path, that page is where you find out in a minute instead of an afternoon.

The orchestration engine moves too

The modules were stitched together by processes running on a workflow orchestration engine, and the easy mistake is to point every new service at the monolith's engine database. That keeps the coupling you are trying to remove, just one layer down.

Instead, the extracted services got a fresh orchestration engine database of their own, separate from the monolith's, provisioned through infrastructure-as-code with credentials in the secrets store rather than in config files. Each service then deploys only the process definitions it owns, declared in its configuration. A service that has not deployed a process cannot pick up its work or break it, and the process model becomes part of the service boundary instead of a shared blob.

That leaves the obvious question: what happens to process instances already running on the monolith's engine? We did not migrate them. The monolith kept running alongside the new services, so every in-flight instance finished on the engine that started it. Because Traefik now sent every new request to the extracted services, the monolith received no new work. Once its last instances completed, we shut it down gracefully. No state was copied between engines, and no half-finished process changed hands mid-flight.

Every service, the same pipeline

Uniformity was the cheapest reliability feature we had. Every repository got:

  • A dedicated migration branch with the same skeleton: application entry point, properties, logging config, the Spring Boot Maven plugin, and a standard Dockerfile.
  • A deploy.yml GitHub Actions workflow calling one shared reusable workflow, so pipeline fixes landed once, not two dozen times.
  • A build that runs mvn clean package, boots the app, publishes the image, and updates the CloudFormation ApplicationImage parameter.
  • A CloudFormation stack per service carrying the Traefik labels above.

GitOps here meant something concrete: CloudFormation was the only source of truth for routing. Nobody edited labels in the console. If it was not in the template, it did not exist, which is what makes rollback trustworthy.

Gates, with numbers

A gate without a number is an opinion. Each environment had entry and exit criteria that someone could check without asking me.

StageExit criteria
DEVNew and legacy paths return identical responses (golden tests). Smoke load: p95 < 300ms on simple GETs, error rate < 0.5%. Dashboards, alerts, and logs live.
QAContract tests (OpenAPI, Pact, Postman) pass on both path families. 1-hour soak at 2× baseline RPS with error rate < 0.2%. Rollback tested and documented.
UATBusiness sign-off per service. Integrator shadow testing with no client changes. Incident playbook dry run. Change approvals in place.
PROD24–48 hours stable per service: 5xx at or below baseline, latency within SLO, legacy and new paths returning the same results. Runbook updated.

QA could only start once at least five core services had cleared DEV. That forced us to prove the pattern on a real slice before scaling it.

What the tests actually checked

  • Parity. An automated check that the legacy and new paths produce the same status, the same body, and the same subset of headers that matter. If you build only one test for a migration like this, build this one.
  • Error paths. Happy paths on both URL families, plus 4xx and 5xx behaviour and idempotency on retries.
  • Rewrite overhead. A lightweight k6/Gatling smoke comparing the two paths. The budget was under 2ms at p95 for the rewrite hop.
  • Headers surviving the hop. Auth, X-Forwarded-*, X-Request-Id, and tracing headers, verified with a header-echo endpoint. A rewrite that drops the trace header does not break anything visibly; it just blinds you on the day you need to debug.

Rolling it out

The deployment playbook was the same for every service in every environment:

  1. Apply the service's CloudFormation stack with its prefix.
  2. Confirm task health on /actuator/health.
  3. Smoke the new path and expect 200.
  4. Enable the legacy router labels and update the service.
  5. Smoke the old path, expect 200, and diff the payloads.
  6. Watch for 30–60 minutes.
  7. Merge to main and tag the release.

Enabling the new path before the legacy one meant every service was proven on its canonical route before it took a single legacy request.

The plan budgeted about six weeks with services staggered: five pilot services through DEV in week one, the rest in week two, QA in week three, UAT across weeks four and five, then production in phases. Low-risk services went first: read-heavy, stateless endpoints, each watched for 48 hours before the next phase.

Observability was per service from the first deploy, with dashboards splitting requests by legacy and new path family. Giving each family its own Traefik router makes that split cheap. Alerts fired on 5xx above 1% for five minutes, p95 above SLO, and task restarts.

Rollback was a parameter

Rollback had two levers, and we tested both in QA before any production rollout. Toggle a LegacyEnabled CloudFormation parameter, which drops the legacy router through a conditional, or roll the task definition back to the previous image. Neither needs a code change or a deploy of anything else. If your rollback plan starts with "we'd need to…", it is not a rollback plan.

Retiring the old paths

Transparent rewrites are a bridge, not a destination. Leave them forever and you own two routing tables forever.

The retirement had its own steps. Once production traffic showed integrators moving to the new prefixes, we switched legacy routes from an internal rewrite to a 308 Permanent Redirect. Unlike a 301, a 308 preserves the HTTP method and body, so a POST stays a POST. We announced the deprecation, watched legacy traffic, and then removed the legacy routers.

Plan that window at the start: we set 60–90 days after production as the point to revisit. Otherwise "temporary" compatibility layers become permanent architecture.

How it landed

  • All 24+ modules run as independently deployable services on ECS Fargate, each with its own pipeline, dashboards, and alerts.
  • Zero client changes during the migration. Integrators kept calling the old paths until they chose to move.
  • Rewrite overhead stayed negligible, inside the ~2ms p95 budget.
  • Rollback was exercised, not just documented.
  • Legacy routes were retired through 308 redirects and then removed, so the compatibility layer did not outlive its purpose.
  • Orchestration kept working across the split, with each service owning its processes. In-flight work drained on the old engine, which then shut down gracefully.

What I would do differently

Build workflow tests before the first extraction, not alongside it. Orchestrated processes crossed module boundaries, and BDD-style tests of those flows became our real safety net. Having that framework ready on day one would have made every cutover faster.

Inventory what clients actually call before writing a single regex. The spec tells you what the API is supposed to look like. Access logs tell you what you have to keep working.

The pattern itself is not new. It is the strangler fig: grow the new system around the old one until the old one can be removed. What makes it work in practice is less glamorous: one canonical path per service, one narrow rule for everything legacy, a number on every gate, and a rollback that is a single parameter flip.