Strangler Fig Pattern — Safe Legacy Modernization with YARP and .NET
Posted on: 4/26/2026 6:11:59 AM
Table of contents
- 1. Why Big Bang Rewrites Are a Dangerous Gamble
- 2. Strangler Fig Pattern: How It Works
- 3. Practical Implementation with .NET and YARP
- 4. Data Migration Strategy
- 5. Five-Phase Implementation Roadmap
- 6. Choosing the Right Module to Migrate First
- 7. Monitoring and Observability During Migration
- 8. Common Mistakes to Avoid
- 9. When to Use (and Not Use) Strangler Fig
- 10. Conclusion
You have a monolithic ASP.NET Framework 4.8 system that has been running in production for 10 years, millions of lines of code, hundreds of stored procedures — and management wants it "migrated to .NET 10 in 6 months." If the only approach you can think of is a complete rewrite (Big Bang Rewrite), stop right there. There is a far safer alternative: the Strangler Fig Pattern.
Named by Martin Fowler in 2004, this pattern draws inspiration from the strangler fig vine found in tropical forests — a plant that wraps around a host tree, gradually growing until it completely replaces the original. Applied to software: you build the new system in parallel, incrementally shifting traffic until the old system is "strangled" and can be shut down.
1. Why Big Bang Rewrites Are a Dangerous Gamble
Before diving into the Strangler Fig, let's understand why the "rewrite from scratch" approach so often fails:
| Criteria | Big Bang Rewrite | Strangler Fig Pattern |
|---|---|---|
| Time to value | Value only after 100% completion | Delivers value from the first migrated service |
| Risk | Extremely high — 1 critical bug = rollback everything | Low — per-component rollback, no system-wide impact |
| Team load | Must maintain 2 systems in parallel long-term | Gradual transition, legacy scope shrinks over time |
| Business continuity | Feature freeze during rewrite | Can develop new features on migrated components |
| Success rate | ~30% finish on time and budget | Significantly higher thanks to incremental delivery |
Real-World Lesson
Netscape Navigator is the classic case study of a failed Big Bang Rewrite. They spent 3 years rewriting the browser from scratch while Internet Explorer captured the entire market. The result: Netscape lost its position and never recovered.
2. Strangler Fig Pattern: How It Works
The pattern operates on a 3-step cycle that repeats for each component:
graph TD
A["🔍 Transform
Build new module to replace
legacy functionality"] --> B["🔀 Coexist
Run old + new in parallel
via routing facade"]
B --> C["✂️ Eliminate
Shut down legacy module
once new is stable"]
C --> A
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style C fill:#4CAF50,stroke:#fff,color:#fff
The Transform → Coexist → Eliminate cycle of the Strangler Fig Pattern
2.1. Routing Facade — The Orchestration Brain
The facade is the most critical component, acting as a proxy that routes requests to either the legacy or new system. In the .NET ecosystem, YARP (Yet Another Reverse Proxy) is the top choice for this role.
graph LR
Client["Client
Browser/Mobile"] --> YARP["YARP Facade
.NET 10"]
YARP -->|"/api/orders/*"| New["New Service
ASP.NET Core 10"]
YARP -->|"/api/products/*"| New
YARP -->|"/api/legacy/*"| Old["Legacy App
ASP.NET Framework 4.8"]
YARP -->|"/reports/*"| Old
style Client fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style YARP fill:#e94560,stroke:#fff,color:#fff
style New fill:#4CAF50,stroke:#fff,color:#fff
style Old fill:#ff9800,stroke:#fff,color:#fff
YARP acts as a Facade, routing requests based on route patterns
2.2. Anti-Corruption Layer (ACL)
The ACL is a protective shield that prevents the new system from being contaminated by the legacy's outdated domain model. It translates data between the two worlds, ensuring the new system maintains a clean design.
graph LR
subgraph New_System["New System"]
NS["Order Service
Clean Domain Model"]
end
subgraph ACL["Anti-Corruption Layer"]
TR["Translator
+ Adapter"]
end
subgraph Legacy["Legacy System"]
LS["tblOrder_Master
+ sp_GetOrderV3_Final2"]
end
NS --> TR
TR --> LS
style New_System fill:#e8f5e9,stroke:#4CAF50,color:#2c3e50
style ACL fill:#fff3e0,stroke:#ff9800,color:#2c3e50
style Legacy fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style NS fill:#4CAF50,stroke:#fff,color:#fff
style TR fill:#ff9800,stroke:#fff,color:#fff
style LS fill:#f8f9fa,stroke:#e94560,color:#2c3e50
The ACL prevents the legacy domain model from leaking into the new system
3. Practical Implementation with .NET and YARP
3.1. Setting Up YARP Reverse Proxy
The first step is configuring YARP as a facade sitting in front of both the legacy app and new services:
// Program.cs — YARP Facade
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();
// appsettings.json — Route configuration
{
"ReverseProxy": {
"Routes": {
"orders-route": {
"ClusterId": "new-services",
"Match": { "Path": "/api/orders/{**catch-all}" }
},
"products-route": {
"ClusterId": "new-services",
"Match": { "Path": "/api/products/{**catch-all}" }
},
"legacy-fallback": {
"ClusterId": "legacy-app",
"Match": { "Path": "/{**catch-all}" },
"Order": 9999
}
},
"Clusters": {
"new-services": {
"Destinations": {
"primary": { "Address": "https://new-api.internal:5001" }
}
},
"legacy-app": {
"Destinations": {
"primary": { "Address": "https://legacy.internal:8080" }
}
}
}
}
}
Practical Tip
Set Order: 9999 on the legacy-fallback route so it only matches when no other route fits. As you migrate more modules, just add new routes with higher priority — the legacy route automatically narrows in scope.
3.2. Feature Toggles for Gradual Rollout
Combining YARP with feature toggles enables percentage-based traffic shifting to minimize risk:
// Custom YARP middleware for feature-based routing
public class StranglerRoutingMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
var feature = context.Request.Path.StartsWithSegments("/api/orders");
var useNewService = await _featureManager
.IsEnabledAsync("OrderService.UseNew");
if (feature && useNewService)
{
context.Items["__ClusterId"] = "new-services";
}
else
{
context.Items["__ClusterId"] = "legacy-app";
}
await _next(context);
}
}
3.3. Anti-Corruption Layer in C#
// ACL: Translate legacy data model to clean domain
public class OrderAntiCorruptionLayer : IOrderRepository
{
private readonly LegacyDbContext _legacyDb;
public async Task<Order> GetByIdAsync(Guid orderId)
{
// Legacy uses int ID + stored procedure
var legacyOrder = await _legacyDb
.Set<TblOrderMaster>()
.Include(o => o.TblOrderDetails)
.FirstOrDefaultAsync(o => o.OrderGuid == orderId);
if (legacyOrder == null) return null;
// Translate to clean domain model
return new Order
{
Id = orderId,
CustomerId = CustomerId.From(legacyOrder.CustID),
Status = MapLegacyStatus(legacyOrder.OrdStatus),
Items = legacyOrder.TblOrderDetails
.Select(d => new OrderItem
{
ProductId = ProductId.From(d.ProdID),
Quantity = d.Qty,
UnitPrice = Money.From(d.Price, d.CurrCode)
}).ToList(),
CreatedAt = legacyOrder.CreateDT
};
}
private static OrderStatus MapLegacyStatus(string status) =>
status switch
{
"A" => OrderStatus.Active,
"P" => OrderStatus.Pending,
"X" => OrderStatus.Cancelled,
"S" => OrderStatus.Shipped,
_ => OrderStatus.Unknown
};
}
4. Data Migration Strategy
This is the hardest part of the entire process. When extracting a service from the monolith, data must also be separated — following the Database per Service principle.
graph TD
subgraph Phase1["Phase 1: Shared Database"]
A1["New Service"] --> DB1["Legacy DB
Shared Access"]
A2["Legacy App"] --> DB1
end
subgraph Phase2["Phase 2: CDC Sync"]
B1["New Service"] --> DB2["New DB"]
B2["Legacy App"] --> DB3["Legacy DB"]
DB3 -->|"CDC / Debezium"| DB2
end
subgraph Phase3["Phase 3: Independent"]
C1["New Service"] --> DB4["New DB
Source of Truth"]
C2["Legacy App
(sunset)"] -.->|"Read-only"| DB4
end
Phase1 -->|"Migrate"| Phase2
Phase2 -->|"Cutover"| Phase3
style Phase1 fill:#fff3e0,stroke:#ff9800,color:#2c3e50
style Phase2 fill:#e3f2fd,stroke:#2196F3,color:#2c3e50
style Phase3 fill:#e8f5e9,stroke:#4CAF50,color:#2c3e50
3 phases of data transition: Shared → CDC Sync → Independent
Anti-pattern: Permanent Shared Database
If the new service still reads from and writes to the legacy database directly, you haven't actually modernized — you've just created a distributed monolith. Phase 1 (shared DB) should only be a temporary transitional step, not the final state.
4.1. Change Data Capture (CDC) with Debezium
CDC enables real-time data synchronization from the legacy DB to the new DB without modifying legacy code:
// Debezium connector config for SQL Server
{
"name": "legacy-order-connector",
"config": {
"connector.class": "io.debezium.connector.sqlserver.SqlServerConnector",
"database.hostname": "legacy-db.internal",
"database.port": "1433",
"database.dbname": "LegacyERP",
"table.include.list": "dbo.tblOrder_Master,dbo.tblOrder_Detail",
"topic.prefix": "legacy",
"schema.history.internal.kafka.topic": "schema-changes.legacy"
}
}
5. Five-Phase Implementation Roadmap
6. Choosing the Right Module to Migrate First
Migration order directly impacts the speed and risk of the entire project. Evaluate each module against 4 criteria:
| Criteria | Weight | Explanation |
|---|---|---|
| Business Value | 30% | Which module delivers the most business value when improved? |
| Technical Complexity | 25% | Number of dependencies, stored procedures, shared state |
| Change Frequency | 25% | Frequently updated modules → migrate early to boost velocity |
| Legacy Coupling | 20% | Low dependency on other modules → easier to extract |
Golden Rule
Start with modules that have high business value + low coupling. This creates a quick win to prove value to stakeholders while minimizing technical risk. Avoid starting with the most complex core module — that's the fastest way to kill a migration project.
7. Monitoring and Observability During Migration
When traffic is split between legacy and new services, you need to monitor both to detect regressions early:
graph TD
YARP["YARP Facade"] -->|"Metrics + Traces"| OTel["OpenTelemetry
Collector"]
New["New Services"] -->|"Metrics + Traces"| OTel
Legacy["Legacy App"] -->|"Metrics"| OTel
OTel --> Dash["Dashboard
Compare latency, error rate
legacy vs new"]
style YARP fill:#e94560,stroke:#fff,color:#fff
style New fill:#4CAF50,stroke:#fff,color:#fff
style Legacy fill:#ff9800,stroke:#fff,color:#fff
style OTel fill:#2c3e50,stroke:#fff,color:#fff
style Dash fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
Observability pipeline comparing performance between legacy and new services
Key metrics to track:
- Latency P50/P95/P99: The new service must not be slower than legacy
- Error rate: Compare 5xx error rates between legacy vs new service per endpoint
- Data consistency: Verify results between legacy and new service using shadow traffic
- Migration progress: % of traffic shifted to new service (target: steady increase each sprint)
8. Common Mistakes to Avoid
| Anti-pattern | Consequence | Solution |
|---|---|---|
| Permanent Shared Database | Distributed monolith — worse than the original monolith | Plan data migration from day one, use CDC for sync |
| Migrating by layer instead of feature | UI migrated but backend still legacy → no real value | Migrate by vertical slice (UI + API + DB together) |
| Missing ACL | New domain model contaminated by legacy design | Always have a translation layer between old and new |
| Migrating too much at once | Team overload, insufficient testing, complex rollbacks | One bounded context at a time, complete before moving on |
| No rollback plan | Critical bugs with no fast path back to legacy | YARP route switch — redirect traffic to legacy in seconds |
9. When to Use (and Not Use) Strangler Fig
Use when
- Large monolith with clearly defined bounded contexts
- Need to continue delivering features during migration
- Team lacks resources for a full simultaneous rewrite
- System is running in production with zero-downtime requirement
- Legacy app has an HTTP interface (web app, API) — easy to place a facade in front
Avoid when
- Small, simple system — a rewrite is faster and cheaper
- Legacy app is batch processing / background jobs with no HTTP endpoints
- Business logic is too coupled — cannot split into bounded contexts
- No test coverage on legacy — no way to verify behavior parity
10. Conclusion
The Strangler Fig Pattern isn't a silver bullet — but it's the most practical strategy for the majority of enterprise modernization projects. Instead of betting everything on a Big Bang Rewrite, you reduce risk by delivering value incrementally, validating at each step, and always having a rollback path.
With YARP as the facade, feature toggles controlling rollout, ACL protecting the domain model, and CDC synchronizing data — you have a complete toolkit in the .NET ecosystem to execute a safe, controlled migration. Start small, prove value, then scale gradually.
References:
.NET 10 Memory & GC Optimization — Span<T>, ArrayPool, DATAS and Zero-Allocation Patterns
Apache Kafka 4.x: The Event Streaming Era Without ZooKeeper
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.