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.
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:
- 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.
- It is hard to change. If the payment gateway changes tomorrow, we must change this class and every class like it.
- 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?
| 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 |
Live example
Send a few requests. Then turn on the “bad code” option and send requests again:
In each request, the cart service and the payment service both ask for a PriceCalculator. Both also use ShopDb.
| Request | Singleton | Scoped | Transient |
|---|---|---|---|
| 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);
}
What happens? Step by step:
- The Container builds CatalogCache only once. At that moment it also builds one ShopDb and gives it to it.
- CatalogCache lives until the app ends. So that ShopDb is never disposed either.
- All requests now share one ShopDb.
- DbContext is not built for use at the same time. When two requests run queries together, we get an error.
- 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.
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);
}
}
}
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
- Get dependencies from the constructor. When all dependencies are in the constructor, one look shows what the class needs.
- A dependency must live as long as you, or longer. This is the most important DI rule.
- 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.
- 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.
- A Singleton service must be thread-safe. All requests use it at the same time.
- 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
- A class does not build its dependencies. It gets them from its constructor.
- A Singleton service is one for the whole app. Scoped is one for each request. Transient is new every time.
- A service must only depend on services that live as long or longer.
- A Scoped service gets trapped inside a Singleton. This is called a Captive Dependency.
- In a Singleton or a BackgroundService, create a new scope or DbContext for each job.
- The ValidateScopes check finds this mistake early. It is on only in Development, unless you turn it on yourself.