Back to blog
Backend Systemsbeginner

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.

Asma HafeezApril 17, 20264 min read
dotnetdependency-injectiontestingcsharpclean-code
Share:𝕏

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

C#
// 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

C#
// 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

C#
[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

C#
// 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

C#
// 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

C#
// 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

C#
// 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

C#
// 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

  1. Inject interfaces, not concrete types — this is what makes swapping/mocking possible
  2. Constructor injection is the default — it makes dependencies explicit and required
  3. Use AddScoped for most services, AddSingleton for stateless shared services
  4. The DI container resolves the whole object graph — you only register, not construct
  5. In tests, ConfigureTestServices lets you replace any real dependency with a fake

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.