Software Architecture · Legacy Systems
Routing Requests Between Old and New with a Strangler Facade
How a routing layer in front of a legacy application lets capabilities move to a replacement one slice at a time, what the routing key must capture, and why session and data ownership decide the order.
The strangler pattern replaces a system by growing a new one around it, moving capability by capability, until the old system has nothing left to serve. The mechanism that makes this possible is a routing layer that sits between callers and the systems that serve them. Callers keep a single address; the router decides, per request, whether the legacy application or the replacement answers.
The pattern is often described as an architecture, but the interesting content is in the routing key. A facade that routes by URL prefix is easy to build and fails on the first request whose behaviour spans two prefixes. A facade that routes by tenant is trivially safe and yields a migration that takes years because each tenant must be migrated wholesale. The key determines both the blast radius of a mistake and the number of slices the migration can be cut into.
What the router must decide, and what it must not
A router needs a rule set that maps an incoming request to a target. The minimal rule set is an ordered list of matchers (host, path prefix, header, method, query parameter), each with a target. Longest-prefix matching on the path is the common default because it composes: a rule for /invoices/ covers /invoices/2024/, and a more specific rule can override it.
The router must not translate. The moment it rewrites request bodies, merges responses, or adjusts headers beyond what is needed for forwarding, it becomes a second application with its own logic, its own bugs, and no owner. Everything the router understands is a thing that must be migrated twice: once into the replacement and once out of the router. The one exception is mechanical header handling for forwarded identity and trace propagation, which has a defined contract.
In Go, the minimal implementation is a route table and the standard library’s reverse proxy:
type rule struct {
prefix string
target *url.URL
}
type router struct {
rules []rule // sorted longest prefix first
proxy *httputil.ReverseProxy
}
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
for _, ru := range r.rules {
if strings.HasPrefix(req.URL.Path, ru.prefix) {
req.URL.Scheme = ru.target.Scheme
req.URL.Host = ru.target.Host
req.Header.Set("X-Forwarded-Host", req.Host)
r.proxy.ServeHTTP(w, req) // ReverseProxy preserves method and body
return
}
}
http.Error(w, "no route", http.StatusNotFound)
}
Two details in that sketch are load-bearing when the router serves real traffic. X-Forwarded-* headers let the target reconstruct the original request; without them, generated absolute URLs and redirects point at an internal hostname. And the fallback is an explicit error rather than a default route to the legacy system, because a default route silently hides a missing rule until the legacy system is switched off.
Choosing the unit of migration
The unit of migration is the smallest set of behaviour that can be moved while leaving the legacy system consistent. Four axes are available, and they have different costs.
By path. Move /invoices/* to the replacement. Simple routing, but the legacy database is usually shared, so the two systems must agree on the schema for the tables under /invoices, and both must handle the same rows during the transition.
By tenant or account. Move a cohort of customers wholesale. Routing is trivial because identity is in the session or the hostname, but every capability must exist in the replacement before the first tenant moves, which reduces the pattern to a big-bang migration with a smaller blast radius.
By capability behind a shared endpoint. Move the read path first, then the write path. This is the common sequencing for data-heavy systems, and it forces the question of which system owns a row at any moment.
By traffic fraction. Send a percentage of requests to the replacement for comparison. This is not a migration unit but a validation mechanism; the differential comparison it enables is what makes a risky slice safe to move.
Read-before-write is the ordering that most often survives contact with reality. Reads can be served from the replacement while the legacy system remains the writer, as long as the replacement can see the legacy data. Writes are where ordering, ownership, and rollback all become difficult, and they are treated separately in switching the write path.
Whichever axis is chosen, a slice has to be complete in a vertical sense before it can be routed. A path prefix that returns correct data but lacks authentication, rate limiting, or an audit record is not a slice; it is an outage waiting for the first request that exercises the missing part. The checklist for a slice is short and unglamorous: the replacement authenticates the same callers through the same mechanism, emits the same audit events, enforces the same limits, and appears in the same dashboards. Each of those is a dependency of the legacy endpoint that the dependency map should have recorded, and each is easy to omit because the happy-path test never touches it.
The corollary is that slices are larger than they look. Moving the invoice list endpoint usually means moving invoice detail, invoice download, and the search that returns invoices, because callers discover one through the other. A slice that is too small produces a replacement whose endpoints call back into the legacy system for the neighbours, which is a distributed system with the worst of both designs: network failure modes on top of a schema shared with the thing being replaced.
Sessions, identity, and the state the router cannot see
A request routed to the replacement carries whatever the caller sent. If the legacy application put the user’s identity, permissions, or a shopping cart in a server-side session keyed by a cookie, the replacement must be able to read that session or the router must route session-bound requests to the system that can.
Three approaches exist, with distinct costs. A shared session store, either a database table or a key-value service both systems read, keeps routing free but requires the legacy system to be changed to use it, which may be impossible for a packaged application. A router rule keyed on the session cookie’s presence locks every session-bound request to the legacy system, which prevents migration of any stateful flow. Re-authentication at the boundary, where the replacement validates the same token the legacy system issued, works when the token is self-contained and signed, and fails when the legacy session is an opaque identifier with server-side state.
This is the constraint that most often determines migration order: stateless reads first, stateful flows later, and anything with a legacy-only session last.
Failure modes that recur
Shared tables with two writers. Both systems write the same table with different invariants. The legacy code enforces them in application logic; the replacement enforces them in the schema or not at all. The divergence appears later as rows that neither system can explain, which is the problem dual writes addresses and which is best avoided by giving each table a single writer.
Routing rules that drift. Rules accumulate: a hotfix sends one tenant back to the legacy system, an experiment sends a path prefix to the new one, a customer-specific exception is added by hand. After a year, nobody can state which system serves which request. The rule set must be versioned like code, reviewed, and tested against a table of representative requests.
Masks that hide replacement bugs. A router that falls back to the legacy system on any 5xx from the replacement turns every replacement outage into an invisible degradation. Fallback is appropriate for read-only, idempotent requests during a bounded period; for writes it converts a failure into a double-execution risk.
The permanent facade. The routing layer is described as temporary and becomes the system’s most critical component, because every caller now depends on it. It needs the same operational treatment as any other production service: redundancy, timeouts, a rate limit, and an owner. A single-instance proxy that both systems sit behind is a single point of failure for the whole migration and the whole business.
The hop that costs latency. Each request crosses an additional network hop and, unless the proxy is configured to stream, an additional buffering step. For a 5 ms handler this is measurable; for a 500 ms handler it is not. The proxy should stream request and response bodies and must not buffer responses whose size is unbounded.
Once a slice is served by the replacement, the remaining question is whether the legacy code behind it is still needed. That question is answered by observation, not by intent, and it is the subject of retiring the legacy component. Between routing and retirement sits the translation layer that keeps the new model from inheriting the old one, covered in anti-corruption layers.
References
- Strangler Fig Application — Martin Fowler
- RFC 9110: HTTP Semantics — IETF
- Monolith to Microservices — Sam Newman, O'Reilly, 2019