Levelwise
English
C# and .NET basics

Dependency Injection and service lifetimes

A class does not build its own dependencies. It gets them from its constructor, and a container builds them. The most important point is lifetime. A service must only depend on services that live as long as it does, or longer.

Not reviewedWritten with AI helpReading time: 14 minOnline shop exampleC# and .NET 10 code

Author: bezzad

The problem: a class that builds everything by itself

In our online shop, the order checkout service is written like this:

public sealed class CheckoutService
{
    private readonly ShopDb _db = new(/* connection string? */);
    private readonly CardGateway _gateway = new("api-key-123");

    public async Task PayAsync(Guid orderId, CancellationToken ct) { /* ... */ }
}

This code has three problems:

  1. It is hard to test. In a unit test, we cannot swap the real payment gateway for a fake one. Every test really takes money.
  2. It is hard to change. If the payment gateway changes tomorrow, we must change this class and every class like it.
  3. Nobody owns the lifetime of the objects. Who disposes ShopDb? And when?

The idea: receive dependencies, do not build them

The class only says what it needs. It gets its dependencies from its constructor. Another part, called the Container, builds them and gives them to the class.

public sealed class CheckoutService(ShopDb db, IPaymentGateway gateway, TimeProvider clock)
{
    public async Task PayAsync(Guid orderId, CancellationToken ct)
    {
        var order = await db.Orders.SingleAsync(o => o.Id == orderId, ct);
        await gateway.ChargeAsync(order.Id, order.Total, ct);
        order.MarkPaid(clock.GetUtcNow());
        await db.SaveChangesAsync(ct);
    }
}

Now in a test, we give the class a fake gateway and a fixed clock. The class code does not change at all.

We register the services in the Program file:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ShopDb>(o => o.UseSqlServer(connectionString)); // Scoped
builder.Services.AddScoped<CheckoutService>();
builder.Services.AddScoped<IPaymentGateway, CardGateway>();
builder.Services.AddTransient<PriceCalculator>();
builder.Services.AddSingleton<CatalogCache>();
builder.Services.AddSingleton(TimeProvider.System);

Three kinds of lifetime

For each service, the Container must know: how often should it build a new instance?

CatalogCache #1One instance, shared by all requestsRequest 1Request 2ShopDb #1ShopDb #2Price #1Price #2Price #3Price #4Asked twice, two instancesAsked twice, two instancesSingletonAlways the sameScopedOne per requestTransientNew every time
A Singleton instance is shared by everyone. A Scoped instance is one per request. A Transient instance is new every time.
Kind How many instances? When is it disposed? Good example in the shop
Singleton One for the whole app When the app shuts down Catalog cache, settings, TimeProvider
Scoped One for each scope (on the web, each request) At the end of that request ShopDb, cart service
Transient A new one each time it is asked for At the end of the scope it came from A light calculator with no state
One simple question to choose: Does this service have state? If it has no state and is thread-safe, Singleton is usually best. If it has state for one request (like DbContext), use Scoped. If it is light and you do not want anything shared between two uses, use Transient.

Live example

Send a few requests. Then turn on the “bad code” option and send requests again:

Live example: which instances does the container build?

In each request, the cart service and the payment service both ask for a PriceCalculator. Both also use ShopDb.

RequestSingletonScopedTransient
No requests yet.

    Captive Dependency

    The most important DI mistake is this: a long-lived service depends on a short-lived service.

    builder.Services.AddSingleton<CatalogCache>();
    
    public sealed class CatalogCache(ShopDb db) // ShopDb is Scoped!
    {
        public Task<List<Product>> LoadAsync(CancellationToken ct)
            => db.Products.ToListAsync(ct);
    }
    Request 1Request 2Request 3CatalogCacheLives as long as the appShopDbHeld captiveNever releasedTwo queries: errorOld data in memoryRule: depend only on services that live as long or longer
    A Singleton service is built only once. So the ShopDb inside it is also the same one forever.

    What happens? Step by step:

    1. The Container builds CatalogCache only once. At that moment it also builds one ShopDb and gives it to it.
    2. CatalogCache lives until the app ends. So that ShopDb is never disposed either.
    3. All requests now share one ShopDb.
    4. DbContext is not built for use at the same time. When two requests run queries together, we get an error.
    5. Everything this DbContext reads stays in its Change Tracker. Memory slowly grows, and the user sometimes sees old data.

    Simple rule: a service must only depend on services that live as long as it does, or longer.

    Service Can depend on
    Singleton Only Singleton
    Scoped Singleton and Scoped
    Transient All three, but it must not get captured in a Singleton

    Why do we get an error in Development but not in Production?

    In the Development environment, ASP.NET Core turns on two checks. Their names are ValidateScopes and ValidateOnBuild. These checks find this exact mistake when a service is built. In other environments they are off by default. If you want them on all the time:

    builder.Host.UseDefaultServiceProvider(options =>
    {
        options.ValidateScopes = true;
        options.ValidateOnBuild = true;
    });

    The right way in a Singleton

    If a Singleton service really needs the database, it should build a short-lived DbContext each time and then dispose it:

    builder.Services.AddDbContextFactory<ShopDb>(o => o.UseSqlServer(connectionString));
    
    public sealed class CatalogCache(IDbContextFactory<ShopDb> dbFactory)
    {
        public async Task<List<Product>> LoadAsync(CancellationToken ct)
        {
            await using var db = await dbFactory.CreateDbContextAsync(ct);
            return await db.Products.AsNoTracking().ToListAsync(ct);
        }
    }

    Background service and Scope

    A BackgroundService also lives until the app ends, like a Singleton. So it cannot get ShopDb from its constructor. The right way: create a new scope in each round of work.

    OrderCleanupWorkerLives as long as the appRound 1CreateAsyncScopeShopDb #1End: releasedRound 2CreateAsyncScopeShopDb #2End: releasedRound 3CreateAsyncScopeShopDb #3End: releasedEach round starts with a new, empty instance
    Each round has a new scope and a new ShopDb. At the end of the round, both are disposed.
    public sealed class OrderCleanupWorker(IServiceScopeFactory scopes) : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken ct)
        {
            while (!ct.IsCancellationRequested)
            {
                await using (var scope = scopes.CreateAsyncScope())
                {
                    var db = scope.ServiceProvider.GetRequiredService<ShopDb>();
                    await db.Orders
                        .Where(o => o.Status == OrderStatus.Draft)
                        .Where(o => o.CreatedAt < DateTime.UtcNow.AddDays(-7))
                        .ExecuteDeleteAsync(ct);
                } // the scope and ShopDb are disposed here
    
                await Task.Delay(TimeSpan.FromHours(1), ct);
            }
        }
    }
    One scope forever is also wrong. If you create the scope only once, outside the loop, the same Captive problem comes back. The DbContext lives until the app ends, and memory grows.

    A few useful tools

    Several implementations of one interface

    The shop has two payment gateways: bank card and wallet. From .NET 8, you can register each one with a key. This is called Keyed Services:

    builder.Services.AddKeyedScoped<IPaymentGateway, CardGateway>("card");
    builder.Services.AddKeyedScoped<IPaymentGateway, WalletGateway>("wallet");
    
    public sealed class RefundService([FromKeyedServices("card")] IPaymentGateway gateway)
    {
        // ...
    }

    The HttpClient class

    Never create a new HttpClient with new for each request. Too many connections open, and the system runs out of ports. Register it with the AddHttpClient method. This method manages the connections and renews them from time to time:

    builder.Services.AddHttpClient<PriceClient>(c =>
        c.BaseAddress = new Uri("https://prices.shop.local/"));

    Important rules

    1. Get dependencies from the constructor. When all dependencies are in the constructor, one look shows what the class needs.
    2. A dependency must live as long as you, or longer. This is the most important DI rule.
    3. Do not inject IServiceProvider everywhere. This is called a Service Locator. Dependencies get hidden, and errors show up only at run time. Use the scope factory only at the edges, like in a BackgroundService.
    4. Do not get a Disposable service from the root container. If you get a Transient service that is IDisposable straight from the root container, the container keeps it until the app ends, so it can dispose it at the end. This means a memory leak.
    5. A Singleton service must be thread-safe. All requests use it at the same time.
    6. A constructor with ten dependencies is a bad smell. It usually means the class does too much. Make it smaller.

    Common mistakes

    Mistake Result Right way
    Injecting DbContext into a Singleton Concurrency errors, old data, memory leak. Use IDbContextFactory.
    Creating a scope only once in a BackgroundService The DbContext lives forever. A new scope in each round.
    A Singleton service with normal state (like a List) Two requests at the same time break the state. A thread-safe type, or a Scoped lifetime.
    Injecting IServiceProvider into every class Dependencies get hidden. Get dependencies from the constructor.
    Creating HttpClient with new for each request Running out of ports (Socket Exhaustion). The AddHttpClient method.
    Checks turned off in all environments A lifetime mistake is not seen until Production. Turn on ValidateScopes.

    Summary in six lines

    1. A class does not build its dependencies. It gets them from its constructor.
    2. A Singleton service is one for the whole app. Scoped is one for each request. Transient is new every time.
    3. A service must only depend on services that live as long or longer.
    4. A Scoped service gets trapped inside a Singleton. This is called a Captive Dependency.
    5. In a Singleton or a BackgroundService, create a new scope or DbContext for each job.
    6. The ValidateScopes check finds this mistake early. It is on only in Development, unless you turn it on yourself.