Dependency Injection shows up in nearly every ASP.NET Core project, usually as a few lines in Program.cs that get copy-pasted without much thought about what they actually do. This post goes all the way down to the real mechanics, what IServiceCollection actually is, what BuildServiceProvider does at the moment it runs, and exactly where and how a dependency gets resolved and handed to a class, using the Product API from earlier posts as the working example throughout.
The Core Idea, With an Analogy
Dependency Injection, DI, is a technique where a class receives the objects it depends on from an external source, rather than creating them itself. The class declares what it needs, usually via its constructor, and something else, the DI container, is responsible for actually supplying it.
Think of a lamp and a power outlet. A lamp doesn't generate its own electricity, and it doesn't wire itself directly into the building's breaker box, it just has a standard plug, and trusts that something on the other end of the wall will supply power the moment it's plugged in. The lamp never needs to know where that electricity actually comes from, or how it got there. A class using DI is the lamp; its constructor is the plug; the DI container is the electrical system supplying whatever gets requested, from wherever it's actually sourced.
This analogy carries through the entire rest of this post, every piece of DI's mechanics maps onto some part of this same electrical picture.
One more piece worth adding here: the power actually arriving at that outlet could originate from a solar farm, a hydroelectric dam, or a coal plant, the lamp has no way to tell, and no reason to care. Swap the power source behind the scenes, and every lamp in the building keeps working identically, completely unaware anything changed. This is precisely what happens when a registration is changed from AddScoped() to AddScoped(), ProductService, the "lamp" in this picture, keeps working exactly the same either way, because it only ever depended on the standard plug shape, the IProductRepository interface, never on which specific power plant was actually wired in behind it.
The Three Lifetimes: Transient, Scoped, Singleton
When you register a dependency, you also specify how long a single instance of it should live before a new one gets created. .NET's DI container offers three lifetimes, each genuinely different.
Transient means a brand new instance is created every single time it's requested, even multiple times within the same HTTP request.
Scoped means one instance is created per HTTP request, per "scope," and that same instance is reused for every class that asks for it within that one request.
Singleton means one instance is created the first time it's requested, and that exact same instance is reused for the entire remaining lifetime of the application, across every request, forever, until the app restarts.
Continuing the power outlet analogy:
Transient is like a disposable battery pack, plugged in fresh, used once, discarded, with a brand new one supplied the next time anything needs power.
Scoped is like the electricity supplied to one specific conference room for the duration of a single meeting, everyone in that room draws from the same circuit while the meeting is happening, but a new meeting gets its own fresh circuit session.
Singleton is like the building's one main electrical panel itself, there's exactly one, installed when the building was built, and every single outlet draws from that same one panel for the building's entire lifetime.
// Transient - genuinely stateless helpers, like a
// simple calculator or formatter with no stored state
builder.Services.AddTransient<IPriceFormatter, PriceFormatter>();
// Scoped - anything that should be consistent WITHIN
// one request, but not shared ACROSS requests - most
// commonly, anything wrapping a DbContext
builder.Services.AddScoped<IProductRepository, ProductRepository>();
// Singleton - genuinely shared, thread-safe state that
// should exist exactly once for the whole app, like
// an in-memory cache
builder.Services.AddSingleton<IMemoryCache, MemoryCache>();
IServiceCollection: The Registry Being Built
builder.Services in Program.cs is an instance of IServiceCollection, think of it as a list of entries, each one saying "here's an interface, and here's what concrete class, and lifetime, to use when something asks for it." Critically, at this point, nothing has actually been built yet, it's purely a registry being assembled before the application starts running.
In the outlet analogy, this is the building's electrical blueprint, drawn up before construction even starts, "this room gets its own circuit, that one shares the main panel." No wire is actually live yet. It's a plan an electrician is still writing down.
var builder = WebApplication.CreateBuilder(args);
// Every one of these lines ADDS AN ENTRY to the
// IServiceCollection - none of them actually CREATE
// an object yet
builder.Services.AddDbContext<ProductDbContext>(options =>
options.UseSqlServer(connectionString));
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddControllers();
// At this exact point in the code, IServiceCollection
// holds four registration entries - and that's all it
// is: a plan, not yet a functioning container
AddTransient, AddScoped, AddSingleton: What the Call Actually Does
Each of these methods is an extension method on IServiceCollection that adds one entry to that registry, pairing an interface, or a concrete type, with how to construct it, and which of the three lifetimes to use.
builder.Services.AddScoped<IProductRepository, ProductRepository>();
// This single line records, roughly:
// "When something asks for an IProductRepository,
// construct a ProductRepository to satisfy it,
// and reuse that same instance for the rest of
// THIS SPECIFIC request, but build a fresh one
// for the next request"
// A second, related overload lets you provide the
// construction logic directly, useful when a
// dependency needs manual wiring
builder.Services.AddScoped<IProductRepository>(provider =>
{
var context = provider.GetRequiredService<ProductDbContext>();
return new ProductRepository(context);
});
// Here, "provider" is itself the DI container, being
// used to resolve ANOTHER dependency (ProductDbContext)
// needed to construct this one - DI resolving DI
BuildServiceProvider: The Moment the Registry Becomes Real
IServiceCollection is just a plan. Calling .Build() on the WebApplicationBuilder, which internally calls something equivalent to BuildServiceProvider(), takes that entire registry and turns it into a working IServiceProvider, an actual, functioning container capable of constructing real objects on demand.
In the outlet analogy, this is the moment construction finishes and the power actually gets turned on at the main breaker. The blueprint becomes a genuinely live electrical system, every outlet in the building is now capable of actually supplying power the instant something gets plugged into it.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddControllers();
// Before this next line: IServiceCollection, a LIST
// of registration entries. Nothing can be resolved yet.
var app = builder.Build();
// After this line: an actual, LIVE IServiceProvider
// now exists internally, genuinely capable of
// constructing a ProductService (and everything IT
// depends on) the moment something asks for one
Why this distinction matters: you can add as many registrations as you want before Build() is called, but once it's called, the container is effectively locked in for that running application. This is exactly why every builder.Services.Add...() call needs to happen before builder.Build(), never after.
Constructor Injection: Where Resolution Actually Happens
You never write new ProductService() anywhere in application code. Instead, a class simply declares what it needs via its constructor, and the DI container automatically resolves and supplies it the moment that class itself needs to be constructed, typically, for a controller, the moment an HTTP request arrives for one of its actions.
In the outlet analogy, the constructor parameter is the lamp's plug, a standard shape, declaring exactly what kind of power it expects. The lamp itself never asks where the electricity comes from, never traces the wire back to its source. It just plugs in, and power arrives, fully resolved.
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
// This constructor parameter is the ENTIRE
// mechanism - nothing else needs to be written
// anywhere for this to work correctly
}
}
Here's what actually happens, step by step, when a request for GET /api/products arrives.
ASP.NET Core needs to construct a ProductsController to handle this request
It looks at the constructor and sees it needs an IProductService
It asks the IServiceProvider (built earlier from the registry) to resolve IProductService
The provider sees IProductService was registered as ProductService (Scoped) - it needs an IProductRepository to construct THAT
The provider recursively resolves IProductRepository too, which itself needs a ProductDbContext, which gets resolved the same way
The ENTIRE chain gets built, in the correct order, automatically, and the fully-constructed ProductsController is handed back, ready to handle the request
This recursive resolution, a dependency needing its own dependencies, resolved automatically in turn, is the actual mechanism behind everything covered in the earlier Repository Pattern and SOLID posts on this blog. Dependency Inversion is the design principle, depend on abstractions; this resolution process is literally what Dependency Injection means by "injection."
Manual Resolution: IServiceProvider.GetService, and Why You Rarely Need It
Constructor injection handles resolution automatically for nearly everything in a typical ASP.NET Core app. Occasionally, code needs to resolve something manually and directly, rather than through a constructor.
// Inside Program.cs, AFTER builder.Build(), for a
// one-off manual resolution (genuinely uncommon in
// normal request-handling code, but useful to know)
using (var scope = app.Services.CreateScope())
{
var productService = scope.ServiceProvider.GetRequiredService<IProductService>();
var products = await productService.GetAllAsync();
}
// CreateScope() is specifically important here - it
// creates a genuine SCOPE, so a Scoped dependency
// resolved inside it behaves correctly (one instance
// for this scope), rather than accidentally resolving
// a Scoped service directly from the root provider,
// which is a common and genuinely risky mistake
The Captive Dependency Bug, Explained at the Mechanics Level
An earlier post on this blog named the captive dependency problem, AddSingleton used for something wrapping a DbContext. Now that the actual mechanics are clear, here's precisely why it happens.
builder.Services.AddDbContext<ProductDbContext>(options => ...);
// AddDbContext registers ProductDbContext as SCOPED
// by default - one per request
builder.Services.AddSingleton<IProductRepository, ProductRepository>();
// MISTAKE: when the FIRST request ever comes in, the
// container builds a ProductRepository, which needs a
// ProductDbContext - it resolves ONE, and because
// ProductRepository itself is Singleton, that SAME
// ProductRepository instance (holding that SAME
// ProductDbContext instance) is reused for EVERY
// subsequent request, forever - the Scoped DbContext
// is permanently trapped inside a Singleton, used far
// beyond the single request it was actually designed
// to live for
This is precisely why .NET's DI container actually validates this at runtime in many configurations, and why matching a dependency's lifetime to what it actually wraps, Scoped for anything touching a per-request resource, is a correctness requirement, not just a style preference.
Key Lessons
IServiceCollection, builder.Services, is a registry of entries, pairing an interface, a concrete implementation, and a lifetime, built up before the application starts, not yet a working container.
AddTransient, AddScoped, and AddSingleton all add an entry to that same registry, differing only in which lifetime they specify.
BuildServiceProvider, triggered by builder.Build(), is the exact moment the registry transforms into a live, working IServiceProvider capable of actually constructing objects.
Constructor injection is the entire mechanism for resolution in normal application code, a class declares what it needs, and the container resolves the complete dependency chain recursively and automatically.
Manual resolution via IServiceProvider.GetService, or GetRequiredService, exists for genuine edge cases, and should always happen within a proper scope, CreateScope(), to avoid resolving a Scoped service incorrectly.
The captive dependency bug happens because a Singleton resolves its dependencies exactly once, permanently, trapping a Scoped dependency, like a DbContext, far beyond the single request it was designed to live for.
Summary
Dependency Injection in .NET has three distinct phases: building a registry, IServiceCollection, via AddTransient, AddScoped, and AddSingleton, turning that registry into a working container, BuildServiceProvider, triggered by builder.Build(), and resolving dependencies automatically through constructor injection every time a class needs to be constructed. That third phase is recursive, a dependency's own dependencies get resolved the same way, all the way down, which is the actual mechanism behind everything the SOLID and Repository Pattern posts on this blog described more conceptually. Understanding these three phases precisely is what turns "add this line to Program.cs because the tutorial said so" into a genuine, mechanical understanding of what's actually happening underneath.
More from TechStack Blog: C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
CS Fundamentals: https://www.techstackblog.com/category.html?cat=cs-fundamentals
Top comments (0)