Dependency Injection and Service Lifetimes
Who creates your objects, and how long they live
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
Two different classes handle the same HTTP request and both inject a Scoped service. How many instances are created?
Real-world analogy
What is it?
Dependency Injection (DI) means a class receives the objects it needs from outside instead of creating them. ASP.NET Core has a built-in container: you register an interface with an implementation and a lifetime, and the framework hands the right instance to every constructor that asks. The lifetime — Transient, Scoped or Singleton — decides whether the container reuses an instance or builds a new one.
Real-world relevance
In a real API you register the DbContext and your repositories as Scoped, stateless helpers as Transient, and things like a cache client or configuration as Singleton. The classic production bug is a Singleton that injects the DbContext directly. Parallel requests then share one context and throw "A second operation was started on this context". The Development startup check catches it, but only when you run in Development.
Key points
- Ask, do not create — A class lists what it needs in its constructor. It never writes new SqlOrderRepository() itself. That is the whole idea.
- The container does the wiring — You register each service once in Program.cs. At runtime the container builds the object and everything it depends on.
- Transient: new every time — Each time something asks, it gets a fresh object. Good for small stateless helpers. Two classes in the same request get two different instances.
- Scoped: one per request — One instance is shared by everything inside the same HTTP request, then disposed. DbContext is Scoped by default, so one request shares one unit of work.
- Singleton: one for the app — Created once and shared by every request and every thread. It must be thread-safe and must not hold per-user data.
- Captive dependency bug — A Singleton that takes a Scoped service keeps it alive forever. A DbContext trapped this way serves stale data and breaks under parallel requests.
- Scopes outside a request — A background service has no HTTP request. Create a scope yourself with IServiceScopeFactory to use Scoped services there.
- Why teams bother — Tests can pass a fake IEmailSender. You can swap an implementation in one line. Classes stop knowing how their helpers are built.
Quick check
Answer from memory — scrolling up first makes it stick less.
What goes wrong when a Singleton service takes a DbContext in its constructor?
Which lifetime is the default for a DbContext registered with AddDbContext?
Code example
var builder = WebApplication.CreateBuilder(args);
// One line per service: interface, implementation, lifetime
builder.Services.AddTransient<IPriceCalculator, PriceCalculator>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<OrderService>();
var app = builder.Build();
app.MapGet("/orders/{id}", async (int id, OrderService service) =>
await service.GetTotalAsync(id));
app.Run();
public class OrderService
{
private readonly IOrderRepository _orders;
private readonly IPriceCalculator _prices;
// The container sees this constructor and supplies both
public OrderService(IOrderRepository orders, IPriceCalculator prices)
{
_orders = orders;
_prices = prices;
}
public async Task<decimal> GetTotalAsync(int id)
{
var order = await _orders.FindAsync(id);
return _prices.Total(order);
}
}Line-by-line walkthrough
- 1. The three Add... lines tell the container: when someone asks for this interface, build this class, and reuse it this long.
- 2. AddTransient gives a new PriceCalculator on every request for one. AddScoped gives one SqlOrderRepository per HTTP request. AddSingleton gives one SystemClock for the whole app.
- 3. In MapGet the parameter OrderService is not created by you. The container builds it for this request.
- 4. The OrderService constructor only names interfaces. It does not know or care which concrete classes arrive.
- 5. GetTotalAsync just uses the two helpers. In a unit test you pass fakes into the same constructor.
Spot the bug
public class ReportWorker : BackgroundService
{
private readonly AppDbContext _db;
public ReportWorker(AppDbContext db) => _db = db;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var count = await _db.Orders.CountAsync(ct);
await Task.Delay(TimeSpan.FromMinutes(5), ct);
}
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Dependency injection in ASP.NET Core (Microsoft Learn)
- .NET dependency injection (Microsoft Learn)
Lock it in