Using DI Well

Constructor injection as the default, why a service locator is a step backwards, and resolving scoped services from background work.

Syntaxusing IServiceScope scope = scopeFactory.CreateScope();

Once a container is wiring your application, a few habits decide whether the code stays readable. Three matter most.

Constructor injection is the default

List what a class needs as constructor parameters. The constructor becomes an honest list of dependencies: a reader sees them without opening a method, and the compiler stops the class being built without them. If that list grows past four or five, the class is probably doing several jobs; split it rather than reaching for a shortcut.

Why the service locator is worse

The alternative is to inject IServiceProvider and call GetRequiredService<T>() whenever something is needed. That is the service locator pattern, and it loses what injection bought you. The dependencies are now invisible from the outside, so a missing registration becomes a crash at runtime instead of a startup error. A test must build a container rather than pass two fakes. Injecting a factory interface whose only job is to resolve services is the same anti-pattern in different clothes.

Background work needs its own scope

A scope is the per-request boundary that scoped services live in. Background work has no request, so there is no scope to join. A background service is a singleton, so it must not take a scoped service in its constructor. Instead inject IServiceScopeFactory, call CreateScope() for each unit of work, resolve from scope.ServiceProvider, and dispose the scope when that unit finishes. One scope per item, not one for the whole loop; a scope held open for hours keeps every instance in it alive.

Example

Example · csharp
// Package: Microsoft.Extensions.DependencyInjection
using Microsoft.Extensions.DependencyInjection;

ServiceCollection services = new ServiceCollection();
services.AddScoped<OrderCounter>();
services.AddSingleton<BatchRunner>();

using ServiceProvider provider = services.BuildServiceProvider();

BatchRunner runner = provider.GetRequiredService<BatchRunner>();
runner.RunBatch();
runner.RunBatch();

// Scoped: one instance per scope, so its count starts again each time.
class OrderCounter
{
    private int _count;

    public int Record()
    {
        _count++;
        return _count;
    }
}

// A singleton. Taking OrderCounter in the constructor would hold one
// instance for the life of the app, so take the scope factory instead and
// open a scope per batch.
class BatchRunner(IServiceScopeFactory scopeFactory)
{
    public void RunBatch()
    {
        using IServiceScope scope = scopeFactory.CreateScope();
        OrderCounter counter = scope.ServiceProvider.GetRequiredService<OrderCounter>();
        Console.WriteLine($"recorded {counter.Record()}, {counter.Record()}");
    }
}

// Output:
// recorded 1, 2
// recorded 1, 2

When to use it

  • Refactor a class that resolves its own dependencies so its needs are visible in the constructor.
  • Run a queue consumer that writes to the database through a scoped repository.
  • Review a pull request that injects IServiceProvider and explain the cost.
  • Keep a long-running worker from accumulating tracked entities for the life of the process.

More examples

Service locator against constructor injection

Both classes work, which is why the first one keeps appearing. The difference shows up in maintenance: the second class can be read, constructed by hand, and unit tested with no infrastructure. The one legitimate use of IServiceProvider is deciding at runtime which of many registrations to use, and keyed services usually cover that case better.

Example · csharp
using Microsoft.Extensions.DependencyInjection;

// Avoid. Nothing in the signature says what this class needs, and a missing
// registration for IClock throws the first time Run is called, in production.
public class ReportJobLocator(IServiceProvider services)
{
    public string Run()
    {
        IClock clock = services.GetRequiredService<IClock>();
        IStore store = services.GetRequiredService<IStore>();
        return $"{store.Count()} rows on {clock.Today:yyyy-MM-dd}";
    }
}

// Prefer. The constructor lists the dependencies, the container checks them
// when the class is built, and a test passes two fakes with no container.
public class ReportJob(IClock clock, IStore store)
{
    public string Run() => $"{store.Count()} rows on {clock.Today:yyyy-MM-dd}";
}

A background service that uses a scoped DbContext

This is the standard shape for any timer, queue consumer or outbox pump that touches a database. Note what is inside the scope and what is outside: the scope covers one unit of work only, and the await Task.Delay happens after it is disposed. Creating the scope once outside the while loop is the common bug, and it turns a per-item DbContext into one that accumulates tracked entities for as long as the process runs.

Example · csharp
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

// BackgroundService is registered as a singleton, so scoped services such as
// an EF Core DbContext cannot be constructor parameters here.
public sealed class OutboxWorker(
    IServiceScopeFactory scopeFactory,
    ILogger<OutboxWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // One scope per pass. It is disposed before the delay, so the
            // DbContext inside it does not stay open while we wait.
            using (IServiceScope scope = scopeFactory.CreateScope())
            {
                IOutbox outbox = scope.ServiceProvider.GetRequiredService<IOutbox>();
                int sent = await outbox.FlushAsync(stoppingToken);
                logger.LogInformation("Flushed {Count} messages.", sent);
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

// builder.Services.AddHostedService<OutboxWorker>();

Where a minimal API gets its services

A GET to /orders/7 returns the JSON for order 7, and /orders returns an object with a count and a currency. Handler parameters are resolved from the per-request scope, so a scoped service is safe there even though the lambda itself belongs to a long-lived app. Once a handler grows past a few lines, move the work into a class with constructor injection and register it, which is what OrderQueries shows.

Example · csharp
using Microsoft.Extensions.DependencyInjection;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderStore, OrderStore>();
builder.Services.AddSingleton<PriceList>();
builder.Services.AddScoped<OrderQueries>();

WebApplication app = builder.Build();

// Any parameter the router cannot match to a route value, query string or
// body is resolved from the request's scope, like a constructor parameter.
app.MapGet("/orders/{id:int}", (int id, IOrderStore store) => store.Find(id));

// Shorthand and longhand are the same request: this handler and the class
// below both receive the scoped IOrderStore for this request.
app.MapGet("/orders", (OrderQueries queries) => queries.All());

app.Run();

public class OrderQueries(IOrderStore store, PriceList prices)
{
    public object All() => new { count = store.Count(), currency = prices.Currency };
}

Discussion

  • Be the first to comment on this lesson.