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
DbContextusage 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.
📘 Series navigation
⬅️ Previous:
Part 2 – Evolving Data Access in a Legacy .NET System
➡️ Next:
Part 4 – Introducing Event-Driven Architecture to a Legacy System