Skip to main content
ANVISoftware Solutions
Lesson 9 of 12Intermediate22 min

Dependency Injection

By the end of this lesson

Register and resolve services, and choose the right lifetime for each.

Start with the plain idea, because the name makes it sound larger than it is. A class states what it needs in its constructor, and something else supplies it. That is all dependency injection means.

The alternative is a class that constructs its own collaborators. That seems simpler, and it costs you three things. The class decides how its collaborators are built, so changing that decision means editing the class. Nothing can be substituted, so a test needs a real database and a real mail server. And the same collaborator gets constructed repeatedly in different places, each with its own idea of the correct settings.

The container is the piece that makes handing things over practical. You register what implements what, once, and it constructs objects on demand — including the objects those objects need, all the way down. You never write the wiring.

The same service, constructing its own dependencies and then receiving them
C#
// Before: this class decides everything, and nothing here can be substituted
public sealed class EmployeeService
{
    public async Task PromoteAsync(int employeeId, string newTitle)
    {
        var store = new PostgresEmployeeStore("Host=prod-db;Database=employees;...");
        var email = new SmtpNotificationSender("smtp.internal", 25);

        Employee employee = await store.GetAsync(employeeId);
        await store.UpdateTitleAsync(employeeId, newTitle);
        await email.SendAsync(employee.Email, "Your new job title");
    }
}

// After: the class states its needs and is handed them
public sealed class EmployeeService(
    IEmployeeStore store,
    INotificationSender notifications)
{
    public async Task PromoteAsync(int employeeId, string newTitle)
    {
        Employee employee = await store.GetAsync(employeeId);
        await store.UpdateTitleAsync(employeeId, newTitle);
        await notifications.SendAsync(employee.Email, "Your new job title");
    }
}
  • The first version has a connection string and an SMTP host compiled into a business method. Every environment needs a different one, so this class cannot run anywhere but the machine it was written for.
  • It also cannot be tested without sending an email. There is no seam: the method builds its collaborators itself, so no test can intercept them.
  • The second version names what it needs as interfaces. It has no opinion about which database or which mail server, and a test constructs it with two in-memory doubles and no infrastructure at all.
  • The parameters on the class declaration are a primary constructor, and the parameters are usable directly in the methods below. The older form with explicit fields assigned in a constructor body behaves identically.
  • Note what has not happened: the class does not ask a container for anything. It does not reference the container at all. Being handed your dependencies and asking a global object for them are different designs, and only the first is testable.
Registration, and what the container does with it
C#
var builder = Host.CreateApplicationBuilder(args);

// One instance for the whole process
builder.Services.AddSingleton<IClock, SystemClock>();

// One instance per unit of work — a web request, or a scope you create
builder.Services.AddScoped<IEmployeeStore, PostgresEmployeeStore>();
builder.Services.AddScoped<EmployeeService>();

// A new instance every time one is asked for
builder.Services.AddTransient<EmployeeValidator>();

// A factory, for when construction needs more than the container can work out
builder.Services.AddSingleton<INotificationSender>(provider =>
{
    var options = provider.GetRequiredService<IOptions<SmtpOptions>>().Value;
    return new SmtpNotificationSender(options.Host, options.Port);
});

using IHost host = builder.Build();
  • Each Add call records a mapping from a requested type to a way of creating it. Nothing is constructed here — registration is a list, not a set of objects.
  • When something asks for EmployeeService, the container looks at its constructor, resolves an IEmployeeStore and an INotificationSender, resolves anything those need, and then constructs the service. That recursion is the part you would otherwise write by hand.
  • AddScoped<EmployeeService>() with no interface is a legitimate registration. You are not required to invent an interface for every class — register the concrete type when nothing needs to substitute it.
  • The factory overload handles construction the container cannot infer, such as a constructor taking a host name and a port. Prefer options binding over reading configuration inside a factory, so the values are still validated at start-up.
  • The container disposes what it creates: scoped and transient IDisposable instances when their scope ends, singletons when the host shuts down. Objects you construct yourself with new and hand over are not the container's to dispose.

Three lifetimes. The question a lifetime answers is how long one instance should live, and the answer follows from what the object holds:

Singleton
One instance for the life of the process, shared by everything and used concurrently. Suitable for objects that are stateless or hold state that is safe to share: a clock, a cache, a configured HTTP message handler. It must be thread-safe, because nothing serialises access to it.
Scoped
One instance per unit of work. In a web application a scope is created for each request and disposed when the response finishes. Outside one, you create scopes yourself. This is the lifetime for anything that represents work in progress — a database session, a transaction, a per-request identity.
Transient
A new instance every time one is requested, including several times within one operation. Correct for small, cheap, stateless objects such as validators and mappers. It is the least surprising default and the most wasteful if the object is expensive to construct.
What a scope actually is
A boundary you open and close. The web framework opens one per request, which is why scoped feels like per-request. A console tool, a message consumer or a background worker has no requests, so it opens a scope around each unit of work — each message, each file, each cycle.
A long-running worker creating a scope per unit of work
C#
public sealed class EmployeeSyncWorker(
    IServiceScopeFactory scopeFactory,
    IOptionsMonitor<EmployeeOptions> options,
    ILogger<EmployeeSyncWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            int intervalMinutes = options.CurrentValue.SyncIntervalMinutes;

            using (IServiceScope scope = scopeFactory.CreateScope())
            {
                var service = scope.ServiceProvider.GetRequiredService<EmployeeService>();
                int updated = await service.SyncFromPayrollAsync(stoppingToken);
                logger.LogInformation("Synced {UpdatedCount} employees", updated);
            }

            await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), stoppingToken);
        }
    }
}
  • The worker takes IServiceScopeFactory rather than EmployeeService. Its own lifetime is the whole process, so anything scoped it held in a constructor would be captive for hours.
  • CreateScope opens the boundary. Everything resolved inside it — the service, its store, the database session underneath — belongs to this one cycle.
  • The using block closes and disposes the scope at the end of each cycle. That is what returns the session to the pool and releases the entities it tracked. Without it you have rebuilt the captive dependency by hand.
  • IOptionsMonitor rather than IOptionsSnapshot, for the same reason: the worker is a singleton, and a snapshot is scoped.
  • The cancellation token is passed to both the work and the delay, so a stop signal interrupts the wait instead of leaving the host to time out. The lifecycle lesson covers what happens on that signal.

Summary

  • A class states what it needs in its constructor and is handed it; the container does the recursive construction
  • Singleton is one per process and must be thread-safe; scoped is one per unit of work; transient is one per request for it
  • A scope is a boundary — the web framework opens one per request, and a worker opens one per message or cycle
  • A singleton holding a scoped service keeps it alive beyond its scope, producing growing memory, stale reads and intermittent concurrency errors — inject IServiceScopeFactory and resolve inside a scope you create and dispose
  • The costs are run-time wiring errors, harder navigation, and a standing invitation to add interfaces nothing substitutes

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Cause the captive dependency on purpose

Register a scoped service that prints a message when it is constructed. Register a singleton whose constructor takes it. Resolve the singleton twice in Development, then again with the environment set to Production.

Watch when the constructor message appears, and note what the host does in each environment.

Show solution

In Development the host validates scopes and you get an exception telling you a scoped service cannot be consumed from the root provider. That message is the container catching the bug for you.

Outside Development the validation is off, the singleton is built, and the scoped service is constructed exactly once — its message appears a single time no matter how much work flows through afterwards. That is the bug in its natural habitat: nothing throws, nothing logs, and the object lives far longer than it should.

The reason to see both is to stop relying on the first. Validation is a development-time net, so the habit that protects you is reasoning about lifetimes rather than waiting to be told.

Think about it

Choose a lifetime for each

Assign singleton, scoped or transient to each of these in the employees API, and give the reason: a clock that returns the current time; a database session; a validator with no state; an in-memory cache of department names; an object holding the identity of the current caller.

Show solution

The clock is a singleton. It holds nothing, it is safe to call from many threads, and one instance is enough. Injecting it rather than calling DateTime.UtcNow directly is also what makes time-dependent logic testable.

The database session is scoped. It represents work in progress, it is not safe for concurrent use, and it should be disposed when the unit of work ends.

The validator is transient. It is cheap, stateless, and nothing is gained by sharing it. Singleton would also work; transient is the lower-risk default for something this small.

The cache is a singleton — sharing it is the entire point. It must be thread-safe, which means a concurrent collection or explicit locking, not a plain dictionary.

The caller identity is scoped. It is meaningful only within one unit of work, and a singleton holding it would mean every request seeing whoever arrived first, which is a data leak rather than a bug.

The pattern behind the answers: the lifetime follows from what the object holds. No state and thread-safe suggests singleton. State belonging to one operation means scoped. Small and disposable means transient.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A singleton service takes a scoped database session in its constructor. In production, what are the likely symptoms?
A background service needs a scoped service to do each unit of work. What is the correct approach?

Saved in this browser only.