Dependency Injection — the #1 Way to Make Code Testable
Learn how Dependency Injection transforms untestable code into testable code. See DI in action with C# examples, constructor injection, interface abstractions, and the built-in .NET DI container.
Dependency Injection — the #1 Way to Make Code Testable
The single biggest reason code is hard to test is hard-coded dependencies. DI fixes this by passing dependencies in rather than creating them inside the class.
The Problem: Hard-Coded Dependencies
// BAD — impossible to test without hitting the real database and email
public class UserService
{
private readonly AppDbContext _db = new AppDbContext(); // hard-coded
private readonly SmtpClient _smtp = new SmtpClient("smtp.example.com"); // hard-coded
public async Task<User> RegisterAsync(string email, string name)
{
var user = new User { Email = email, Name = name };
_db.Users.Add(user);
await _db.SaveChangesAsync();
await _smtp.SendMailAsync("noreply@app.com", email, "Welcome!", $"Hi {name}!");
return user;
}
}To test RegisterAsync you need a real database and a real SMTP server. Change the SMTP host? You must recompile. This class is tightly coupled to its dependencies.
The Fix: Inject Abstractions
// Step 1 — define interfaces for the dependencies
public interface IUserRepository
{
Task<User> SaveAsync(User user);
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
// Step 2 — inject through the constructor
public class UserService(IUserRepository users, IEmailSender email)
{
public async Task<User> RegisterAsync(string emailAddr, string name)
{
var user = new User { Email = emailAddr, Name = name };
await users.SaveAsync(user);
await email.SendAsync(emailAddr, "Welcome!", $"Hi {name}!");
return user;
}
}Now UserService has no idea how data is saved or how emails are sent. You can swap either dependency without changing the service.
Testable by Default
[Fact]
public async Task RegisterAsync_SavesUserAndSendsEmail()
{
// Arrange — fake lightweight implementations, no DB or SMTP needed
var users = Substitute.For<IUserRepository>();
var email = Substitute.For<IEmailSender>();
users.SaveAsync(Arg.Any<User>())
.Returns(c => c.Arg<User>());
var service = new UserService(users, email);
// Act
var user = await service.RegisterAsync("alice@example.com", "Alice");
// Assert
user.Email.Should().Be("alice@example.com");
await users.Received(1).SaveAsync(Arg.Any<User>());
await email.Received(1).SendAsync("alice@example.com", "Welcome!", Arg.Any<string>());
}No database. No network. Test runs in milliseconds.
Real Implementations
// EF Core implementation
public class UserRepository(AppDbContext db) : IUserRepository
{
public async Task<User> SaveAsync(User user)
{
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
}
// SMTP implementation
public class SmtpEmailSender(IOptions<SmtpSettings> settings) : IEmailSender
{
public async Task SendAsync(string to, string subject, string body)
{
using var client = new SmtpClient(settings.Value.Host);
await client.SendMailAsync("noreply@app.com", to, subject, body);
}
}The .NET DI Container
// Program.cs
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<UserService>();
// Configure settings
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));The container resolves the whole tree. When UserService is requested, it automatically resolves UserRepository (which resolves AppDbContext) and SmtpEmailSender (which resolves SmtpSettings).
Service Lifetimes
// Singleton — one instance for the app's lifetime
builder.Services.AddSingleton<ICache, MemoryCache>();
// Scoped — one instance per HTTP request (most services)
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<AppDbContext>();
// Transient — new instance every time it's requested
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();Rule of thumb: AddScoped for anything that uses DbContext. Never inject a Scoped service into a Singleton — it creates a "captive dependency" that holds a request-scoped object alive too long.
Overriding in Tests
// In integration tests — replace real deps with fakes
public class TestWebFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Remove the real email sender
services.RemoveAll<IEmailSender>();
// Register an in-memory fake
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
}
}
public class FakeEmailSender : IEmailSender
{
public List<(string To, string Subject)> Sent { get; } = [];
public Task SendAsync(string to, string subject, string body)
{
Sent.Add((to, subject));
return Task.CompletedTask;
}
}Common DI Mistakes
// MISTAKE 1 — injecting the concrete type, not the interface
public class OrderService(UserRepository users) { } // BAD — can't mock
public class OrderService(IUserRepository users) { } // GOOD
// MISTAKE 2 — resolving services manually (Service Locator anti-pattern)
public class OrderService(IServiceProvider sp) // BAD
{
public void Do() => sp.GetService<IUserRepository>(); // hidden dependency
}
// MISTAKE 3 — constructor doing real work
public class OrderService(IUserRepository users) // GOOD
{
// do nothing in constructor except store the dependency
}Key Takeaways
- Inject interfaces, not concrete types — this is what makes swapping/mocking possible
- Constructor injection is the default — it makes dependencies explicit and required
- Use
AddScopedfor most services,AddSingletonfor stateless shared services - The DI container resolves the whole object graph — you only register, not construct
- In tests,
ConfigureTestServiceslets you replace any real dependency with a fake
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.