Writing Testable Code in C# · Lesson 2 of 5
Interfaces as Seams — Designing for Replaceability
What Makes Code Untestable
Code is untestable when it creates its own dependencies:
public class PrescriptionApprovalService
{
public Result Approve(Guid prescriptionId)
{
var conn = new SqlConnection("Server=PROD;..."); // creates DB connection
var email = new SmtpEmailSender("mail.hospital.nhs"); // creates SMTP sender
var clock = DateTime.UtcNow; // reads system clock
// ...
}
}
Problems:
→ Cannot test without a real SQL Server
→ Cannot test without a real SMTP server
→ Cannot control the current time in tests
→ Cannot test failure paths (how do you make the DB return an error?)
→ To test Approve(), you must provision infrastructure
The fix: don't create dependencies — receive them.Before and After — Injecting Dependencies
// BEFORE: untestable — service creates its own dependencies
public sealed class PrescriptionApprovalService
{
public async Task<Result> ApproveAsync(Guid prescriptionId, decimal inrValue)
{
using var conn = new SqlConnection("Server=PROD;Database=Clinical;");
var prescription = await conn.QuerySingleOrDefaultAsync<Prescription>(
"SELECT * FROM prescriptions WHERE id = @id", new { id = prescriptionId });
if (prescription is null)
return Result.Failure(Error.NotFound("Prescription", prescriptionId));
// ... approval logic ...
var emailClient = new HttpClient();
await emailClient.PostAsJsonAsync("https://api.emailservice.com/send",
new { To = prescription.PrescriberEmail, Subject = "Approved" });
return Result.Success();
}
}
// AFTER: testable — all dependencies injected
public sealed class PrescriptionApprovalService
{
private readonly IPrescriptionRepository _repository;
private readonly INotificationService _notifications;
private readonly IClock _clock;
public PrescriptionApprovalService(
IPrescriptionRepository repository,
INotificationService notifications,
IClock clock)
{
_repository = repository;
_notifications = notifications;
_clock = clock;
}
public async Task<Result> ApproveAsync(Guid prescriptionId, decimal inrValue)
{
var prescription = await _repository.GetByIdAsync(
PrescriptionId.Of(prescriptionId), CancellationToken.None);
if (prescription is null)
return Result.Failure(Error.NotFound("Prescription", prescriptionId));
var result = prescription.Approve(inrValue, _clock.UtcNow, Guid.NewGuid());
if (result.IsFailure) return result;
await _repository.SaveAsync(prescription, CancellationToken.None);
await _notifications.SendApprovalAsync(prescription.Id, CancellationToken.None);
return Result.Success();
}
}Defining the Interfaces
// Interface in the domain or application layer
// Implementation in infrastructure
public interface IPrescriptionRepository
{
Task<Prescription?> GetByIdAsync(PrescriptionId id, CancellationToken ct = default);
Task SaveAsync(Prescription prescription, CancellationToken ct = default);
}
public interface INotificationService
{
Task SendApprovalAsync(PrescriptionId id, CancellationToken ct = default);
}
public interface IClock
{
DateTime UtcNow { get; }
}
// Real implementations:
public sealed class EfPrescriptionRepository : IPrescriptionRepository { /* EF Core */ }
public sealed class EmailNotificationService : INotificationService { /* SMTP/SendGrid */ }
public sealed class SystemClock : IClock { public DateTime UtcNow => DateTime.UtcNow; }
// Test implementations:
public sealed class InMemoryPrescriptionRepository : IPrescriptionRepository
{
private readonly List<Prescription> _store = new();
public Task<Prescription?> GetByIdAsync(PrescriptionId id, CancellationToken ct) =>
Task.FromResult(_store.FirstOrDefault(p => p.Id == id));
public Task SaveAsync(Prescription p, CancellationToken ct) { _store.Add(p); return Task.CompletedTask; }
}Unit Test with Mocks
// With NSubstitute — no real database, no real email, clock is fixed
[Fact]
public async Task ApproveAsync_ValidInr_ApprovesAndNotifies()
{
var repository = Substitute.For<IPrescriptionRepository>();
var notifications = Substitute.For<INotificationService>();
var clock = Substitute.For<IClock>();
var fixedTime = new DateTime(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc);
clock.UtcNow.Returns(fixedTime);
var prescription = Prescription.CreateDraft(
PatientId.Of(Guid.NewGuid()),
MedicationName.Of("Warfarin"),
DosageValue.Of(5m, "mg"));
repository.GetByIdAsync(prescription.Id, Arg.Any<CancellationToken>())
.Returns(prescription);
var service = new PrescriptionApprovalService(repository, notifications, clock);
var result = await service.ApproveAsync(prescription.Id.Value, 2.5m);
result.IsSuccess.Should().BeTrue();
await repository.Received(1).SaveAsync(
Arg.Is<Prescription>(p => p.Status == PrescriptionStatus.Approved),
Arg.Any<CancellationToken>());
await notifications.Received(1).SendApprovalAsync(
prescription.Id, Arg.Any<CancellationToken>());
}When NOT to Use an Interface
You do NOT need an interface for:
→ Classes that will never be mocked (domain logic, value objects, Result)
→ Classes with no external dependencies (pure functions, calculators)
→ Framework types you don't control (HttpContext, ILogger — use the interface already provided)
→ Classes instantiated with `new` internally — they're not dependencies
Signs you over-interfaced:
→ IWarfarinDosageCalculator with exactly one implementation: WarfarinDosageCalculator
and no test mocks it (it has no side effects, tests use the real one)
→ IStringFormatter: "so we can swap the formatter in the future"
(YAGNI — if no test needs it and no future need is defined, don't add it)
Interface per external boundary:
Database access → interface
HTTP calls → interface
File system → interface
System clock → interface
Business logic → usually no interface needed Production issue I've seen: A senior developer reviewed a PR and added interfaces to 23 classes — including
IWarfarinDoseRounder,IPatientNameFormatter, andIPrescriptionIdGenerator. None of these had external dependencies or needed mocking. The PR tripled in size. New developers were confused: "which of these interfaces has a real implementation and which is a test double?" The codebase had so many interfaces that every class required 3-4 constructor parameters and reading 2 files to understand one concept. Adding an interface only when required by a testability or inversion-of-control need — not "for flexibility" — keeps the codebase navigable.
Key Takeaway
Testable code receives dependencies rather than creating them. Define interfaces for external boundaries — databases, HTTP clients, file systems, clocks. Inject implementations via the constructor. Use mocks (NSubstitute) in unit tests to control and verify collaborators. Don't create interfaces for pure logic classes with no external dependencies — interfaces have a cost (indirection, extra files, confusion) that is only worth paying when testability or substitutability is the real need.