Why Dependency Injection

How a class asking for what it needs, instead of building it, makes code testable and easy to swap.

Syntaxpublic Greeter(IWriter writer) { _writer = writer; }

A class usually needs other objects to do its job. An object it needs is called a dependency. There are two ways a class can get one. It can build the dependency itself with new, or someone can hand the dependency to it. Handing it in is called dependency injection.

What goes wrong with new

Suppose a class writes new SmtpEmailSender() inside itself. That class has now chosen that exact sender for ever. To use a different sender you must edit the class. To test the class you cannot, because the test would send real email. The decision is buried where no caller can reach it.

Asking instead of building

The fix is to declare the dependency as a constructor parameter. A constructor is the code that runs when an object is created. The parameter type is usually an interface: a list of members that a class promises to provide. An interface is the job description, not the person hired.

The class now says what it needs and says nothing about which implementation it gets. In a test you pass a fake that records messages. In production you pass the real sender. The class itself is unchanged in both cases.

What trips people up

Injection moves the decision, it does not delete it. Somewhere one piece of code still picks the concrete class. That place is called the composition root. In an ASP.NET Core app it is Program.cs. Later lessons let a container do the wiring, but the idea is exactly the same as passing the object in by hand.

Example

Example · csharp
// Greeter needs somewhere to write. It asks for that in its constructor
// instead of choosing a writer itself.
IWriter console = new ConsoleWriter();
Greeter greeter = new Greeter(console);
greeter.Greet("Ada");

// The same Greeter class, handed a different writer. Greeter did not change.
RecordingWriter recorder = new RecordingWriter();
Greeter quiet = new Greeter(recorder);
quiet.Greet("Grace");

Console.WriteLine($"recorded: {recorder.Lines.Count} line(s)");
Console.WriteLine(recorder.Lines[0]);

interface IWriter
{
    void Write(string text);
}

class ConsoleWriter : IWriter
{
    public void Write(string text) => Console.WriteLine(text);
}

// A fake writer. It keeps the text so a test can look at it.
class RecordingWriter : IWriter
{
    public List<string> Lines { get; } = new();

    public void Write(string text) => Lines.Add(text);
}

class Greeter
{
    private readonly IWriter _writer;

    public Greeter(IWriter writer)
    {
        _writer = writer;
    }

    public void Greet(string name) => _writer.Write($"hello, {name}");
}

// Output:
// hello, Ada
// recorded: 1 line(s)
// hello, Grace

When to use it

  • Write a test for a class that sends email, without sending any email.
  • Replace a real payment gateway with a stub while the real account is still being approved.
  • Point the same service class at a local file store in development and at cloud storage in production.

More examples

The same class written longhand and shorthand

Both forms produce a class with one public constructor taking one IClock, which is all the container looks at. Reach for the primary constructor in new code; you will still read the longhand in most existing projects, and in any class that needs to validate or transform a parameter before storing it. One real difference: a primary constructor parameter can be reassigned inside the class body, while a readonly field cannot.

Example · csharp
public interface IClock
{
    DateOnly Today { get; }
}

// Longhand: a constructor parameter copied into a private readonly field.
public class ReportService
{
    private readonly IClock _clock;

    public ReportService(IClock clock)
    {
        _clock = clock;
    }

    public string Title() => $"Report for {_clock.Today:yyyy-MM-dd}";
}

// Shorthand: a primary constructor, added in C# 12. The parameter is in
// scope for the whole class body, so no field is written by hand.
// These two classes are the same thing.
public class ReportServicePrimary(IClock clock)
{
    public string Title() => $"Report for {clock.Today:yyyy-MM-dd}";
}

The payoff in a test

The test constructs WelcomeService by hand and passes a fake sender, so no email leaves the machine and the test can assert what was asked for. If WelcomeService had written new SmtpEmailSender() inside itself, this test could not exist. This is the single most common reason teams adopt injection.

Example · csharp
using System.Collections.Generic;
using Xunit;

public interface IEmailSender
{
    void Send(string to, string body);
}

public class WelcomeService(IEmailSender sender)
{
    public void Welcome(string email) => sender.Send(email, "Welcome aboard.");
}

public class FakeSender : IEmailSender
{
    public List<string> SentTo { get; } = new();

    public void Send(string to, string body) => SentTo.Add(to);
}

public class WelcomeServiceTests
{
    [Fact]
    public void Welcome_sends_one_message()
    {
        FakeSender sender = new FakeSender();
        WelcomeService service = new WelcomeService(sender);

        service.Welcome("[email protected]");

        Assert.Single(sender.SentTo);
        Assert.Equal("[email protected]", sender.SentTo[0]);
    }
}

One line decides the implementation

This is the shortest shape of a real composition root. A POST to /signup/[email protected] returns 202 Accepted with an empty body, and WelcomeService is built for you with whichever IEmailSender the commented pair of lines selects. Keeping that choice in one file is what makes a production swap, such as writing mail to disk in a staging environment, a one-line change.

Example · csharp
// Program.cs of an ASP.NET Core app: the composition root.
using Microsoft.Extensions.DependencyInjection;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Swap this single line to change what every class that asks for
// IEmailSender receives. No other file is touched.
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
// builder.Services.AddSingleton<IEmailSender, FileEmailSender>();

builder.Services.AddScoped<WelcomeService>();

WebApplication app = builder.Build();

app.MapPost("/signup/{email}", (string email, WelcomeService service) =>
{
    service.Welcome(email);
    return Results.Accepted();
});

app.Run();

Discussion

  • Be the first to comment on this lesson.