NashTech Blog

Modernizing a 15-Year-Old .NET System Without Breaking Production (Part 3)

Table of Contents
15 year net systeom

Making Legacy .NET Code Testable Without Refactoring Everything

Adapters, Characterization Tests, and Pragmatic Seams

Series: Modernizing a 15-Year-Old .NET System Without Breaking Production
Part 3 of 7


One of the most common statements in legacy .NET projects is:

> “This code can’t be unit tested.”

Usually, that statement is technically true — and also misleading.

The real problem is not that the code is untestable.
The problem is that it was written before testability was a design goal, and refactoring everything first is often impossible.

This article explains how we introduced tests into a 15-year-old .NET system without:

  • rewriting large chunks of code
  • breaking production behavior
  • blocking feature delivery

The key tools were adapters and characterization tests.

1. Why Legacy Code Fails Unit Tests

Legacy code usually breaks testability in predictable ways.

Common Patterns We Found

  • Direct DbContext usage everywhere
  • Static helper classes
  • Hidden dependencies (time, environment, config)
  • Business logic mixed with infrastructure
  • No clear boundaries between read/write logic

Example:

public int CalculateQuantity(long visitId)
{
using var db = new AppDbContext();
var visit = db.Visits
.Include(v => v.StockMoves)
.First(v => v.Id == visitId);
return visit.StockMoves.Sum(x => x.Quantity);
}

This code:

  • creates its own database context
  • mixes data access with logic
  • cannot be tested without a real database
  • is risky to refactor

The usual advice is:
> “Refactor it into smaller pieces first.”

In a large legacy system, that advice is often unrealistic.

2. The Adapter Mindset (This Is the Key Shift)

Adapters are often misunderstood.

They are not:

  • architectural purity layers
  • an attempt to clean everything
  • a permanent abstraction strategy

In legacy systems, an adapter is simply:

> A seam that lets you take control.

That’s it.

3. Before and After: A Realistic Example

Before (Untestable)

public class StockMoveService
{
public int GetTotalQuantity(long visitId)
{
using var db = new AppDbContext();
return db.StockMoves
.Where(x =>x.VisitId == visitId)
.Sum(x => x.Quantity);
}
}

Problems:

  • Hard dependency on EF
  • No control in tests
  • Requires real DB

Step 1: Introduce an Adapter Interface

public interface IStockMoveReader
{
Task GetTotalQuantityAsync(long visitId);
}

This interface is not trying to redesign the system.
It exists purely to give us a seam.

Step 2: EF Implementation (Production)

public class EfStockMoveReader : IStockMoveReader
{
private readonly AppDbContext _db;
public EfStockMoveReader(AppDbContext db)
{
_db = db;
}
public Task GetTotalQuantityAsync(long visitId)
{
return _db.StockMoves
.Where(x => x.VisitId == visitId)
.SumAsync(x => x.Quantity);
}
}

Step 3: Use Adapter in the Service

public class StockMoveService
{
private readonly IStockMoveReader _reader;
public StockMoveService(IStockMoveReader reader)
{
_reader = reader;
}
public Task GetTotalQuantity(long visitId)
{
return _reader.GetTotalQuantityAsync(visitId);
}
}

Now the logic:

  • no longer knows about EF
  • no longer controls DB lifetime
  • is testable

And we didn’t rewrite business behavior.

4. EF Adapter vs Dapper Adapter (Parallel Safety)

In our system, we often had two implementations of the same adapter.

EF Version

public class EfStockMoveReader : IStockMoveReader
{
private readonly AppDbContext _db;
public EfStockMoveReader(AppDbContext db)
{
_db = db;
}
public Task GetTotalQuantityAsync(long visitId)
{
return _db.StockMoves
.Where(x => x.VisitId == visitId)
.SumAsync(x => x.Quantity);
}
}

Dapper Version

public class DapperStockMoveReader : IStockMoveReader
{
private readonly DbConnection _connection;
public DapperStockMoveReader(DbConnection connection)
{
_connection = connection;
}
public Task GetTotalQuantityAsync(long visitId)
{
const string sql = @"
SELECT SUM(Quantity)
FROM StockMoves
WHERE VisitId = @visitId";
return _connection.ExecuteScalarAsync(sql, new { visitId });
}
}

This gave us:

  • side-by-side validation
  • easy rollback
  • confidence before switching implementations

Adapters enabled safe experimentation.

5. Characterization Tests: Testing What Is, Not What Should Be

Characterization tests are uncomfortable at first.

They answer:
> “What does the system do today?”

Not:
> “What is the correct design?”

Example Characterization Test

[Fact]
public async Task CalculateQuantity_ReturnsExpectedValue_ForExistingBehavior()
{
var reader = new FakeStockMoveReader(
new[]
{
new StockMove { Quantity = 5 },
new StockMove { Quantity = -2 }
});
var service = new StockMoveService(reader);
var result = await service.GetTotalQuantity(1);
Assert.Equal(3, result);
}

Is negative quantity strange?
Maybe.
But the system depends on it.

That behavior is now locked.

6. Fakes vs Mocks vs Stubs (Legacy Context)

In legacy systems, mocks often cause more harm than good.

Why We Preferred Fakes

public class FakeStockMoveReader : IStockMoveReader
{
private readonly IEnumerable _data;
public FakeStockMoveReader(IEnumerable data)
{
_data = data;
}
public Task GetTotalQuantityAsync(long visitId)
{
return Task.FromResult(_data.Sum(x => x.Quantity));
}
}

Benefits:

  • No mocking framework dependency
  • Stable tests
  • Behavior is explicit
  • Easy to reason about

Mocks are useful for:

  • verifying side effects
  • external integrations

But for core logic, fakes scale better.

7. Handling Time, Config, and External Systems

Time is a hidden dependency in legacy code.

Bad (Common)

if (DateTime.Now.Hour > 17)
{
// logic
}

Adapter Approach

public interface IClock
{
DateTime Now { get; }
}
public class SystemClock : IClock
{
public DateTime Now => DateTime.Now;
}
public class FakeClock : IClock
{
public DateTime Now { get; set; }
}

This pattern:

  • avoids global state
  • makes tests deterministic
  • does not require large refactors

8. Dependency Injection Without a Big Bang

We didn’t rewrite everything to DI overnight.

Strategy:

  • Introduce adapters only where needed
  • Register implementations incrementally
  • Leave old code untouched unless required

Example registration:

services.AddScoped<IStockMoveReader, EfStockMoveReader>();

Later, we could switch:

services.AddScoped<IStockMoveReader, DapperStockMoveReader>();

No consumer code changed.

9. When NOT to Add Adapters

Adapters are not free.

Do not add them when:

  • Code is already isolated
  • Behavior is trivial
  • Risk is low
  • Tests add little value

Legacy modernization fails when teams:

  • over-abstract
  • chase “clean architecture”
  • lose sight of purpose

Remember:
> Adapters are scaffolding, not the building.

10. What This Gave Us (Real Benefits)

Over time, we achieved:

  • Test coverage on critical paths
  • Safe refactoring
  • EF ↔ Dapper parity validation
  • Faster onboarding
  • Confidence to change old code

Not perfection.
Control.

Final Thoughts

Legacy code does not need to be clean to be testable.
It needs seams.

Adapters and characterization tests allowed us to:

  • respect existing behavior
  • avoid risky rewrites
  • modernize incrementally

This is not textbook unit testing.
This is production-grade pragmatism.

Picture of Hoc Nguyen Thai

Hoc Nguyen Thai

Leave a Comment

Suggested Article

Discover more from NashTech Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading