The legacy monolith. Every senior engineer has inherited one. Hundreds of thousands of lines of code, minimal test coverage, tribal knowledge locked in the heads of people who left years ago. The temptation to rewrite from scratch is almost irresistible — and almost always wrong. Here is how to modernize incrementally without stopping feature delivery.
The Strangler Fig Pattern
The strangler fig is the most reliable approach I have used. You build new functionality alongside the old system and gradually route traffic to the new implementation. The old code stays running until every feature has been migrated and validated.
// Reverse proxy that routes based on feature flags
app.MapWhen(
context => FeatureFlags.IsEnabled("new-order-service"),
newApp =>
{
newApp.MapGet("/api/orders", async (INewOrderService service) =>
await service.GetOrdersAsync());
});
app.MapWhen(
context => !FeatureFlags.IsEnabled("new-order-service"),
legacyApp =>
{
legacyApp.MapGet("/api/orders", async (ILegacyOrderRepository repo) =>
await repo.GetOrdersAsync());
});
The key is to route at the edge, not deep inside the codebase. A reverse proxy or API gateway gives you clean control over which requests go where.
Establish a Test Harness First
Before you touch a single line of legacy code, establish characterization tests. These tests do not verify that the code is correct — they verify that the code does what it currently does. When you refactor, any test failure means you changed behavior, intentionally or not.
[Fact]
public async Task GetOrders_ReturnsExactCurrentBehavior()
{
// Arrange — seed the database with known data
await SeedTestData();
// Act — capture current output
var response = await _client.GetAsync("/api/orders?status=active");
var content = await response.Content.ReadAsStringAsync();
// Assert — golden file comparison
await Verify(content)
.UseDirectory("Snapshots")
.UseMethodName("GetOrders_Active");
}
We used the Verify library extensively for this. It captures the first output as a "golden file" and flags any deviation on subsequent runs. It is not glamorous, but it catches regressions that unit tests would miss.
Identify Seams and Extract Interfaces
Michael Feathers' concept of "seams" — places where you can change behavior without changing code — is essential. In legacy C# code, the most common seam is the class boundary. Extract an interface, inject it, and you have a point where you can swap implementations.
// Step 1: Extract interface from legacy class
public interface IPricingCalculator
{
decimal CalculatePrice(Order order);
}
// Step 2: Legacy implementation stays untouched
public class LegacyPricingCalculator : IPricingCalculator
{
public decimal CalculatePrice(Order order)
{
// 2000 lines of undocumented pricing logic
// DO NOT TOUCH
}
}
// Step 3: New implementation for gradual rollout
public class NewPricingCalculator : IPricingCalculator
{
public decimal CalculatePrice(Order order)
{
// Clean, tested, documented implementation
}
}
Measure and Communicate Progress
Refactoring a monolith takes months or years. Without visible progress metrics, stakeholders lose patience and the project gets cancelled. Track metrics like test coverage percentage, number of legacy endpoints remaining, and deployment frequency. Share them weekly. A burndown chart for legacy migration is surprisingly effective at maintaining organizational support.
The hardest part of legacy modernization is not technical — it is maintaining momentum while continuing to deliver value. Ship small, ship often, and never let the refactoring branch drift too far from main.
Comments (3)
Could you elaborate on this topic in a follow-up post?
Thanks for your comment — glad it helped!
Exactly! I had the same thought.
This is exactly what I was looking for, thank you!
I have a question: does this also apply to older versions?
Leave a Comment