Migration Patterns — Strangler-Fig, Sidecar, ACL
Key Points
- Strangler-Fig: replace a legacy system gradually by routing more and more traffic to the new system, until the old one can be torn down. Named after a vine that grows around a tree until the tree is gone.
- Sidecar: deploy a separate process alongside the application that provides cross-cutting capability (proxy, auth, telemetry, secrets) so the app doesn't have to. Linkerd, Envoy, and Dapr all use this shape.
- Anti-Corruption Layer (ACL): a translation seam between the old model and the new one so the new code never speaks the old vocabulary. Often the unsung hero of a successful strangler migration.
- Big-bang rewrites almost always fail. Incremental replacement (strangler) is the senior default for legacy modernization.
- The reverse proxy is the strangler's primary tool. In .NET, YARP is the right call; nginx/Traefik/Envoy work outside .NET-land.
- Sidecars trade simplicity for capability. They let language-X apps benefit from features written in language-Y, at the cost of an extra hop and an extra process to operate.
Concepts (deep dive)
Strangler-Fig: replace, don't rewrite
The naive plan for a legacy system is "rewrite it." That fails because (a) the legacy contains years of bug fixes, regulatory carve-outs, and customer-specific behavior that nobody documented; (b) it has to keep running during the rewrite; © the team learns the legacy while trying to replace it; (d) the new system has to reach feature parity before anyone can switch, which is a years-long bet with no incremental value.
The strangler-fig flips this:
─── before ──────────────────────────────────────
client ──────────────► Legacy System
(all features)
─── during ──────────────────────────────────────
client ──► Routing facade ──► /orders/* → New system (feature 1, 2, 3)
──► /everything-else → Legacy
(decision per URL/feature)
─── after ───────────────────────────────────────
client ──► Routing facade ──► New System
(legacy decommissioned)
Each migrated feature ships to production before the next one starts. Risk is bounded to the migrated slice. The legacy keeps running for everything else. The team learns the domain incrementally.
The routing facade is the seam. It's where you decide "this URL/feature now goes to the new system." In .NET, that's typically:
- YARP — Microsoft's reverse proxy library; configurable routes; runs as a standalone proxy or embedded in an ASP.NET Core app.
- Azure Application Gateway / Front Door / API Management — managed reverse proxies with similar routing capability.
- nginx / Envoy / Traefik — non-.NET options when the team already operates them.
What you migrate first matters: pick a slice that's (a) small enough to ship in a few weeks, (b) valuable enough to justify the work, © loosely coupled to the rest. "User profile read" is a classic first slice; "checkout" is a terrible first slice.
What the strangler is not
- Not "run both for a year and switch over" — that's a rewrite with extra steps. The defining property is that real production traffic moves over feature by feature.
- Not a database migration on its own — the data layer often has to be strangled too (CDC, dual-write, view-based shimming). See "data side" below.
- Not "the new app reads the old database" — that's a shortcut that re-couples the new system to the legacy schema. The ACL (below) is what prevents this.
Anti-Corruption Layer (ACL)
A migration carries an enormous risk: the legacy's model contaminates the new code. Field names you'd rather not use, magic strings instead of enums, statuses that mean different things in different contexts. If the new code consumes the old model directly, the legacy's design debt becomes the new system's foundation.
The ACL sits between them:
┌────────────┐ [new API contract] ┌──────┐
│ New System │ ◄───────────────────────────│ ACL │ ◄── reads legacy DB / legacy API
│ (clean │ legacy concepts │ │ translates fields, statuses
│ model) │ never reach New System │ │ filters/maps/renames
└────────────┘ └──────┘
The ACL only speaks legacy on its inbound side. Its outbound side speaks the new model exclusively. Anything weird (the legacy status 'P2X' meaning "partial-shipped-after-cancel") gets translated to the new model's OrderStatus.PartiallyShippedAfterCancel. The new system has no idea the old system existed.
ACLs are usually their own project / module — easy to find, easy to delete when the legacy goes away. A common smell is "we put a few translations in the controller" — that translation logic spreads, and now your domain layer knows about legacy formats. Put the ACL in one place.
Strangling the data side
A pure routing strangler works for read-mostly slices. For write paths, the new and old systems often need to see each other's writes during the migration. Options:
| Pattern | How it works | Cost |
|---|---|---|
| Database-as-shared-contract | Both systems read/write the same tables. The new system uses an ACL on top. | Cheapest. Couples the new system to the legacy schema until the schema migrates too. |
| CDC (Change Data Capture) | Legacy writes go to legacy DB; CDC stream replays them into the new DB. | Async — eventual consistency. Solid for read-mostly migrations. |
| Dual-write | App writes to both DBs in one transaction (or via outbox). | High coupling; rollback hard. Use sparingly. |
| Views / synonyms | New DB schema, but views on top mimic the legacy schema for the old app. | Lets the new system own the schema before the old system is gone. |
The right answer depends on which direction the writes flow during the migration, and how quickly the data model itself needs to change. Read the schema-migration question separately from the routing question — they're often answered with different patterns.
Sidecar: cross-cutting capability without changing the app
A sidecar is a separate process deployed in the same pod / VM / host as the application:
┌──────────────────────────────────────────────────┐
│ Pod / Host │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Application │◄──►│ Sidecar │ │
│ │ (your code) │ │ (proxy, etc.) │ │
│ └─────────────────┘ └──────────────────┘ │
│ │ ▲ │
│ │ localhost │ cluster / │
│ │ (HTTP/gRPC) │ external │
└──────────────────────────────────────────────────┘
The two processes share network namespace and local disk; the app talks to the sidecar over localhost. What the sidecar does depends on the deployment:
- Service mesh sidecar (Linkerd, Istio's Envoy) — intercepts all inbound/outbound network traffic. Adds mTLS, retries, timeouts, traffic shaping, and telemetry — without the app knowing.
- Dapr sidecar — gives the app a uniform API (state, pub/sub, secrets, bindings) regardless of cloud provider. Polyglot apps benefit equally.
- Log shipper sidecar — tails the app's log files and forwards them. Decouples log shape from log destination.
- Auth proxy sidecar — handles OIDC redirect dance and injects an
X-Authenticated-Userheader into requests to the app.
The trade is real. You get capability without changing the app, at the cost of:
- An extra process per app instance (memory, CPU).
- An extra hop on every request (latency).
- A second thing to operate (another deploy, another upgrade cycle).
- A debugging story that now spans two processes.
Sidecars shine when the capability is uniform across many languages/apps (auth, mTLS, secrets) and operationally heavy enough to be worth a separate process (service mesh). They're overkill if you have a single .NET app where Microsoft.Extensions.* already covers what you need.
Sidecar inside the strangler-fig story
Sidecars and strangler-fig aren't competing patterns — they often appear together. A common migration shape:
- Put YARP in front of the legacy.
- Route one feature to a new .NET service.
- The new service runs with a Dapr sidecar so it gets state/pub-sub abstractions independently of legacy infrastructure.
- The sidecar speaks to the legacy's RabbitMQ (or wherever) without the new service knowing.
The sidecar absorbs "what the legacy infrastructure happens to be" so the new code doesn't have to.
Migration choreography you'll actually use
A typical strangler-fig in a .NET shop, end to end:
- Stand up YARP in front of the legacy. Day-one: 100% of traffic forwards to the legacy unchanged. Smoke-test this — getting the proxy wrong before any migration breaks everything.
- Pick the first slice. Small, read-mostly, well-understood. Add metrics so you can measure "did the new path break anything."
- Build the slice in the new system, with an ACL reading the legacy DB. Internal feature flag.
- Cut over the slice at the YARP layer, behind a percentage rollout (5% → 25% → 100%). Compare new vs old responses with a shadow / canary strategy if the slice is risky.
- Move to the next slice. Repeat. The legacy gets smaller as you go.
- When the legacy holds nothing important, turn it off. (This step gets skipped most often — write the decommission story down on day one.)
The biggest organisational gotcha: leadership assumes "we're rewriting" and expects feature parity by date X. Strangler-fig delivers value continuously and never has a "we're done" date — it has "we've migrated everything material" and a long tail. Be clear about that up front.
Related patterns
- Branch by Abstraction (Fowler) — introduce an interface in the legacy where the swap will happen, then swap implementations behind the flag. Works when "in front of the legacy" isn't an option (e.g., changing an internal dependency of the legacy itself).
- Parallel run — run both old and new on the same input, compare outputs, only return one. Used during cutover to catch correctness regressions.
- Bulkhead (related to sidecar in mindset) — isolate failure between subsystems. See resilience with Polly.
- Refactoring legacy codebases — the in-place complement to the strangler-fig (when you can't replace, improve in-place behind the same boundary).
How it works under the hood
What YARP actually does in a strangler-fig
YARP is a request router with rules. Concretely:
request: GET /orders/123
├── YARP route table:
│ /orders/* → cluster "new-orders-svc" (matched)
│ /* → cluster "legacy"
├── selects cluster "new-orders-svc"
├── picks an upstream from the cluster's destinations (load balancing)
├── (optional) transforms — rewrite headers, add `X-Forwarded-*`, strip prefix
├── proxies the request, streams the response
└── records metrics + access logs
Routes can be added, swapped, or weighted (canary: 5% new, 95% legacy) at runtime by reloading config or via the YARP API. That's the strangler's lever: change the route table, change who handles which traffic, no app redeploy required.
Sidecar IPC
The application talks to its sidecar over localhost — either HTTP/gRPC (Dapr) or a transparent network intercept (Envoy iptables rules in a service mesh). Localhost is cheap (~100–500 µs for HTTP/JSON; less for gRPC) but not free — a chatty app making 50 sidecar calls per request adds non-trivial latency. The right granularity is "once per logical operation" (e.g., one Dapr state.save per request), not "once per inner loop iteration."
In Kubernetes, the sidecar shares the pod's network namespace, so localhost on 127.0.0.1:3500 always finds the sidecar — no service discovery needed at this layer. The sidecar handles cluster-level discovery on the app's behalf.
Why "Branch by Abstraction" is sometimes the only option
Strangler-fig assumes you can put a router in front of the legacy. Sometimes the seam isn't HTTP — it's a method call inside a single process, or a stored procedure inside the database. Branch by Abstraction introduces an interface at that internal seam:
// Before:
public class OrderProcessor { /* monolithic, in-place */ }
// Step 1 — introduce the seam:
public interface IOrderProcessor { Task ProcessAsync(Order o, CancellationToken ct); }
public class OrderProcessor : IOrderProcessor { /* legacy implementation */ }
// Step 2 — add the new implementation alongside:
public class NewOrderProcessor : IOrderProcessor { /* new implementation */ }
// Step 3 — swap behind a flag:
builder.Services.AddSingleton<IOrderProcessor>(sp =>
sp.GetRequiredService<IFeatureFlags>().IsEnabled("orders.new")
? sp.GetRequiredService<NewOrderProcessor>()
: sp.GetRequiredService<OrderProcessor>());
The principles are the same as strangler-fig — small slices, real production traffic, rollback by flag — but the seam is in-process, not at the proxy.
Code: correct vs wrong
❌ Wrong: rewrite-with-cutover
You don't learn anything from production until quarter four. By then the legacy has grown new features the new system doesn't have. The cutover slips. Either project gets cancelled or it ships broken.
✅ Correct: YARP routing, slice by slice
// Program.cs in a YARP-fronted gateway
builder.Services.AddReverseProxy()
.LoadFromMemory(
routes: new[]
{
new RouteConfig
{
RouteId = "orders-v2",
ClusterId = "new-orders-svc",
Match = new RouteMatch { Path = "/orders/{**catch-all}" }
},
new RouteConfig
{
RouteId = "everything-else",
ClusterId = "legacy",
Match = new RouteMatch { Path = "/{**catch-all}" }
}
},
clusters: new[]
{
new ClusterConfig
{
ClusterId = "new-orders-svc",
Destinations = new Dictionary<string, DestinationConfig>
{
["d1"] = new() { Address = "https://new-orders-svc/" }
}
},
new ClusterConfig
{
ClusterId = "legacy",
Destinations = new Dictionary<string, DestinationConfig>
{
["d1"] = new() { Address = "https://legacy.internal/" }
}
}
});
var app = builder.Build();
app.MapReverseProxy();
app.Run();
Each slice migrated adds one route; the catch-all keeps the legacy serving everything else.
❌ Wrong: new system reads legacy types directly
// In the new (clean) domain:
public class OrderService(LegacyOrderDbContext legacyDb)
{
public async Task<Order> Get(int id)
{
var legacy = await legacyDb.tbl_OrderHeader.FindAsync(id); // ← legacy model bleeds in
return new Order { Status = legacy.STATUS_CD }; // ← magic string in domain
}
}
The legacy's vocabulary just colonised the new domain. STATUS_CD == "P2X" will appear in three different places within a year.
✅ Correct: Anti-Corruption Layer in its own project
// MyApp.Legacy.Acl — only this project knows the legacy
public interface ILegacyOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
}
internal class LegacyOrderRepository(LegacyOrderDbContext legacyDb) : ILegacyOrderRepository
{
public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
{
var legacy = await legacyDb.tbl_OrderHeader.FindAsync(new object?[] { id.Value }, ct);
return legacy is null ? null : Map(legacy);
}
private static Order Map(LegacyOrderHeader l) => new()
{
Id = new OrderId(l.ORDER_ID),
Status = MapStatus(l.STATUS_CD), // 'P2X' → PartiallyShippedAfterCancel
// ... other field translations
};
private static OrderStatus MapStatus(string code) => code switch
{
"P" => OrderStatus.Placed,
"S" => OrderStatus.Shipped,
"P2X" => OrderStatus.PartiallyShippedAfterCancel,
_ => throw new InvalidOperationException($"Unknown legacy status: {code}")
};
}
// In the new domain — only sees Order, never STATUS_CD:
public class OrderService(ILegacyOrderRepository repo)
{
public Task<Order?> Get(OrderId id, CancellationToken ct) => repo.GetByIdAsync(id, ct);
}
When the legacy goes away, the ACL project is deleted and the implementation of ILegacyOrderRepository is replaced with the new DB-backed one. The domain doesn't change.
❌ Wrong: sidecar that adds latency for no benefit
Single .NET app, single team, single language, single cloud.
Operator: "Let's add a Dapr sidecar for state management."
Result: every state read is now a localhost gRPC hop. The app gained no portability
(it's only running in this one place) and lost ~300 µs per call.
Sidecars pay rent. If you're not getting capability you'd otherwise have to write yourself, the rent isn't worth it.
✅ Correct: sidecar for polyglot uniformity
Six services in five languages. Need consistent secrets, pub/sub, mTLS, retries.
Decision: deploy a Dapr/Envoy sidecar per service.
Each service uses its language's HTTP/gRPC client; the sidecar provides the abstractions.
You write retry/secret/mTLS code once (the sidecar config) instead of five times.
The sidecar is justified by the polyglot uniformity it provides.
Design patterns for this topic
Pattern 1 — "Strangler-Fig: route, don't rewrite"
- Intent: ship incremental value; bound risk to the migrated slice.
Pattern 2 — "Anti-Corruption Layer in its own project"
- Intent: keep the legacy's vocabulary out of the new domain.
Pattern 3 — "Branch by Abstraction"
- Intent: swap implementations behind a flag when the seam isn't HTTP.
Pattern 4 — "Parallel run / shadow traffic"
- Intent: verify new-system correctness against the legacy before exposing users.
Pattern 5 — "Sidecar for cross-cutting infra"
- Intent: add capability (auth, mTLS, telemetry, secrets) without changing the app.
Pattern 6 — "Decommission story up front"
- Intent: define what "legacy is gone" means before starting; otherwise the migration becomes permanent.
Pros & cons / trade-offs
| Pattern | Pros | Cons |
|---|---|---|
| Strangler-Fig | Continuous value; bounded risk; learns the domain incrementally | Slow; never "feels done"; needs leadership patience |
| ACL | Clean new domain; trivial to remove later | Translation overhead; the ACL itself can rot if neglected |
| Branch by Abstraction | Works for non-HTTP seams; runtime swap | More plumbing; needs feature-flag infra |
| Sidecar | Capability without app change; polyglot uniformity | Extra process, extra hop, extra ops |
| Big-bang rewrite | None worth listing | High failure rate; no incremental value |
When to use / when to avoid
- Use strangler-fig for any non-trivial legacy modernization where the legacy must keep running.
- Use ACL whenever the new and old domain models differ — which is nearly always.
- Use branch by abstraction when the seam is inside a single process or DB.
- Use sidecar when the capability is cross-cutting and uniform across multiple apps/languages.
- Avoid big-bang rewrites unless the legacy is small, well-understood, and short-lived.
- Avoid sidecars when one app is the only consumer and a library would do.
- Avoid skipping the decommission plan — strangler-fig without a "remove the legacy" date becomes a permanent dual-system.
Interview Q&A
Q1. What's the strangler-fig pattern? Incremental replacement of a legacy system by routing more and more traffic to a new one until the legacy can be turned off. Named after a vine that wraps a tree and replaces it.
Q2. Why prefer it over a rewrite? Continuous value, bounded risk per slice, and the team learns the domain by shipping pieces. Rewrites tend to grow the parity gap faster than the team can close it.
Q3. What tool does the routing in a .NET strangler? Usually YARP — Microsoft's reverse proxy. Azure Application Gateway / Front Door / API Management work at the cloud-edge tier; nginx/Envoy/Traefik outside the .NET ecosystem.
Q4. What's an Anti-Corruption Layer and why does it matter? A translation seam between the legacy and the new system. The new domain never speaks the legacy's vocabulary (magic codes, badly-named fields, etc.). Without it, legacy concepts colonise the new code.
Q5. When is branch-by-abstraction the right call? When the seam isn't an HTTP boundary — typically inside a single process, in front of a stored procedure, or against an internal class. You introduce an interface, run both implementations behind a flag, then swap.
Q6. What's a sidecar? A separate process deployed alongside the app (same pod/host/network namespace) providing cross-cutting capability — proxy, auth, telemetry, secrets — without modifying the app.
Q7. When does a sidecar pay off? When the capability is uniform across multiple apps or languages (polyglot service mesh, uniform secrets across teams). For a single .NET app where Microsoft.Extensions.* already covers it, a sidecar is overkill.
Q8. What's the data-side strangler problem? Routing fixes the request layer; the data layer often needs to bridge too. Options range from shared DB + ACL (cheap, coupled) to CDC replay (decoupled, eventual) to dual-write (high-risk).
Q9. Why do strangler-fig migrations stall? The 80/20 lands fast; the long tail of edge cases doesn't have a deadline. Without an explicit decommission plan and budget, the legacy lingers indefinitely as "the bit nobody wants to migrate."
Q10. What's the relationship between sidecar and service mesh? A service mesh is built from sidecars (typically Envoy) deployed alongside each service, plus a control plane that configures them. The sidecar is the data-plane implementation; the mesh is the system.
Q11. Can you do strangler-fig at the database layer? Yes, via views or synonyms in the legacy DB pointing at the new schema, or via CDC streams in the opposite direction. Both let one schema evolve while the other reads through a compatibility layer.
Gotchas / common mistakes
- ⚠️ Migrating the hardest feature first — checkout, billing, anything regulated. Start small; build muscle on a low-stakes slice.
- ⚠️ ACL spreading into the domain — translation logic creeps into handlers and controllers, defeating the boundary. Keep it in its own project, with
internaltypes. - ⚠️ No decommission plan — six years later the legacy still serves 4% of traffic and a team of two is still maintaining it.
- ⚠️ Strangler that grows new features only in the new system — the legacy doesn't move, but you can't kill it because it owns 80% of the traffic. Either move features over or freeze new development behind a flag.
- ⚠️ Sidecar overhead unmeasured — the localhost hop is cheap per call, expensive in chatty inner loops. Profile.
- ⚠️ Two systems writing to the same row without coordination — race conditions and lost updates. Dual-write needs idempotency keys and (usually) one writer of record.
- ⚠️ Parallel-run never turned off — running both forever doubles infrastructure cost. Set a cutoff.