Why Dependency Injection Is Built Into .NET Core
Dependency Injection (DI) is a first-class citizen in .NET Core, not a bolted-on library: every ASP.NET Core app, worker service, and generic Host builds an IServiceCollection during startup, registers services into it, and builds an IServiceProvider that resolves those services by constructor injection wherever they're needed. Instead of a class calling 'new SqlOrderRepository()' internally, it declares a dependency on an interface like IOrderRepository in its constructor, and the DI container supplies a concrete instance at runtime, decoupling the consumer from the concrete implementation and making that dependency swappable and testable.
Cricket analogy: This is like a franchise's team management deciding a bowling all-rounder role for a match, then supplying whichever player fits (a genuine all-rounder), rather than a fixed player being hardwired into the XI regardless of conditions, the way DI supplies whichever IOrderRepository implementation is registered instead of hardcoding SqlOrderRepository.
Registering Services and Choosing a Lifetime
Services are registered on IServiceCollection using AddSingleton, AddScoped, or AddTransient, and the lifetime you choose has real behavioral consequences. Singleton creates one instance for the entire application lifetime and shares it across all requests, which is efficient but dangerous if the service holds mutable, request-specific state. Scoped creates one instance per HTTP request (or per scope you create manually), which is the right default for anything touching a DbContext or user-specific data. Transient creates a brand-new instance every single time it's requested, which is correct for lightweight, stateless services but wasteful for anything expensive to construct.
Cricket analogy: This is like the difference between a franchise's fixed head coach for an entire season (Singleton), a specialist bowling consultant brought in fresh for each match (Scoped), and a throwdown specialist who sets up a new station for every single net session (Transient) — same organization, three very different lifetimes.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddTransient<IOrderNumberGenerator, OrderNumberGenerator>();
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
var app = builder.Build();
app.MapPost("/orders", (IOrderRepository repo, IOrderNumberGenerator gen) =>
{
var order = new Order(gen.Next());
repo.Add(order);
return Results.Created($"/orders/{order.Id}", order);
});
app.Run();
Constructor Injection and the Captive Dependency Pitfall
The framework resolves dependencies through constructor injection: a class declares what it needs as constructor parameters, and the container recursively resolves each one, building the full object graph automatically. A common pitfall is the 'captive dependency' — injecting a Scoped or Transient service into a Singleton, which captures that shorter-lived instance for the Singleton's entire lifetime, effectively turning a per-request DbContext into a single shared, thread-unsafe instance for the whole app. The DI container's built-in scope validation (enabled by default in the ASP.NET Core generic host in Development) throws at startup if it detects this kind of lifetime mismatch.
Cricket analogy: This is like accidentally assigning a single net bowler (meant to be swapped in fresh each session, i.e. Scoped) permanently to the senior squad's full-time coaching staff, so that stale bowler never rotates out for the rest of the season, mirroring a captive Scoped dependency trapped inside a Singleton.
Never inject a Scoped service (like a DbContext) directly into a Singleton's constructor. If a Singleton genuinely needs scoped data, inject IServiceScopeFactory instead and create a new scope on demand, disposing it when the operation completes.
- ASP.NET Core builds an IServiceCollection at startup and an IServiceProvider that resolves dependencies via constructor injection.
- Classes depend on interfaces, not concrete types, so implementations can be swapped and mocked in tests.
- AddSingleton creates one instance for the app's lifetime; AddScoped creates one per request/scope; AddTransient creates a new instance every resolution.
- Choosing the wrong lifetime for stateful or expensive-to-construct services causes real bugs or performance problems.
- A captive dependency happens when a Scoped or Transient service gets trapped inside a Singleton's lifetime.
- Injecting a DbContext into a Singleton is a classic captive-dependency bug because DbContext is not thread-safe.
- Use IServiceScopeFactory inside a Singleton when it genuinely needs to create scoped services on demand.
Practice what you learned
1. What object does ASP.NET Core build during startup to register application services?
2. Which lifetime is the correct default for a service wrapping a DbContext in a web app?
3. What is a 'captive dependency'?
4. How should a Singleton service safely obtain a Scoped dependency when needed?
5. How does constructor injection resolve a class's dependencies?
Was this page helpful?
You May Also Like
Related Reading
Related Study Notes in Microsoft Technologies
Browse all study notesWindows 10 / UWP Development Study Notes
.NET · 30 topics
Microsoft TechnologiesWindows Batch Scripting Study Notes
Batch · 30 topics
Microsoft TechnologiesMFC (Microsoft Foundation Classes) Study Notes
C++ · 30 topics
Microsoft TechnologiesSilverlight Study Notes
.NET · 30 topics
Microsoft TechnologiesXAML Study Notes
.NET · 30 topics
Microsoft TechnologiesWPF (Windows Presentation Foundation) Study Notes
.NET · 30 topics