Welcome back. In the previous lesson, you traced how an async method runs until it reaches an incomplete await, returns a Task, and later resumes without blocking a request thread during asynchronous I/O. This lesson moves to another part of ASP.NET Core’s runtime machinery: how the framework obtains the objects that handle that request.
Priority: Must know. You will practice explaining constructor injection and tracing a dependency graph from an incoming request to a controller, service, repository, and DbContext. You will also distinguish the three lifetimes well enough to predict instance reuse and identify a common senior-level lifetime bug.
The central idea: declare what you need; do not assemble it everywhere
A dependency is an object that a class needs in order to do its work. An OrderService may need a repository, a validator, a clock, a logger, or an API client. The poor design is not simply using new; creating short-lived domain objects, DTOs, or value objects directly is perfectly normal. The problem begins when business or endpoint code directly constructs its collaborators:
public sealed class OrderService
{
private readonly SqlOrderRepository _repository =
new SqlOrderRepository(
new OrdersDbContext(/* configuration */));
public Task<Order> GetAsync(int id)
{
return _repository.GetAsync(id);
}
}
This code has hidden construction decisions inside OrderService. Replacing the repository, configuring the database context, managing disposal, or writing an isolated unit test all become harder. The service is coupled to a particular implementation and to the details required to create it.
With constructor injection, the class instead declares its required collaborators in its constructor:
public sealed class OrderService : IOrderService
{
private readonly IOrderRepository _repository;
private readonly IOrderValidator _validator;
private readonly IClock _clock;
public OrderService(
IOrderRepository repository,
IOrderValidator validator,
IClock clock)
{
_repository = repository;
_validator = validator;
_clock = clock;
}
public async Task<Order> GetAsync(int id)
{
await _validator.ValidateIdAsync(id);
Order order = await _repository.GetAsync(id);
return order;
}
}
The constructor makes the service’s requirements visible and enforceable. OrderService owns its business behavior; the application’s startup code owns the decision of which implementations satisfy those requirements. This separation is an instance of inversion of control: object creation is controlled by the application composition root rather than by every consuming class.
ASP.NET Core provides the built-in DI container that takes on this creation work.
ASP NET Core dependency injection tutorial
Watch “ASP NET Core dependency injection tutorial” by kudvenkat for a compact visual walkthrough of constructor injection, an unregistered-service failure, registration, and the three core lifetimes.
Watch constructor injection to see why a controller can request an interface without constructing it. Continue with registration, focusing on the mapping from a contract to a concrete implementation and the meaning of the “Unable to resolve service” exception. Then watch service lifetimes. The video uses the older Startup.ConfigureServices style; in modern .NET applications, the same registrations normally appear in Program.cs as builder.Services....
The Microsoft documentation below is worth using as the more precise reference, especially for request scopes, disposal, and design guidance.
Dependency injection in ASP.NET Core
Read “Dependency injection in ASP.NET Core” from Microsoft Learn. It establishes the reason for DI, shows how the container resolves a chained object graph, and explains the lifetime and design rules that interviewers commonly test.
In “Overview of dependency injection,” begin where the article explains why direct construction is problematic. Read the problem and solution, then continue through the explanation of the dependency tree or object graph. In “Service lifetimes,” skip the specialized keyed-services material for now. Read the lifetime examples and then find the paragraph beginning “Output from the preceding examples shows.” Review the instance behavior summary, paying particular attention to the difference between “per resolution,” “per request,” and “per application.” Finally, in “Design services for dependency injection,” read the guidance on request services, small services, and disposal. Focus on the request-scope explanation, then read the following recommendations without treating HttpContext.RequestServices as the preferred everyday access pattern.
Registration is the application’s assembly plan
In a modern ASP.NET Core API, registration usually lives in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddTransient<IOrderValidator, OrderValidator>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddDbContext<OrdersDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("Orders")));
var app = builder.Build();
Each registration is a recipe that tells the container what it may supply when a type is requested.
| Registration | Meaning |
|---|---|
AddScoped<IOrderService, OrderService>() | When IOrderService is requested, construct or reuse an OrderService within the current scope. |
AddTransient<IOrderValidator, OrderValidator>() | Create a fresh OrderValidator for each resolution. |
AddSingleton<IClock, SystemClock>() | Create and reuse one SystemClock for the application lifetime. |
AddDbContext<OrdersDbContext>(...) | Register EF Core’s context and the configuration it needs; the context is scoped by default. |
At startup, builder.Services is an IServiceCollection: a collection of service descriptors, not yet the active resolver. Calling builder.Build() builds the application and its root service provider. In a typical MVC or Web API application, ASP.NET Core then creates a scope per HTTP request.
A registration does not usually mean that the instance is immediately created at startup. The container typically creates it when something first requests it. An exception is a registration where you have already constructed and supplied an instance yourself, such as AddSingleton(new MyService()).
What constructor injection looks like at the endpoint boundary
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
private readonly IOrderService _orders;
public OrdersController(IOrderService orders)
{
_orders = orders;
}
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> Get(int id)
{
Order order = await _orders.GetAsync(id);
return Ok(new OrderDto(order.Id, order.Status));
}
}
The controller does not call new OrderService(...). When ASP.NET Core activates the controller for a matching endpoint, it sees the IOrderService constructor parameter and asks the request’s service provider for that service.
This is not “magic”; it is a centrally configured lookup followed by constructor calls.
Interview pause: trace the object graph before reading on
Assume the remaining implementation is:
public sealed class SqlOrderRepository : IOrderRepository
{
public SqlOrderRepository(OrdersDbContext db)
{
}
}
public sealed class OrderValidator : IOrderValidator
{
}
public sealed class SystemClock : IClock
{
}
Pause for two minutes and explain aloud:
- What does the container do when
GET /api/orders/42reachesOrdersController? - Which objects are created or reused in the first request?
- What changes in a second request?
Aim to explain it in dependency order, rather than just saying “DI injects everything.”
Model answer
The request receives its own DI scope. After routing selects the controller action, ASP.NET Core activates OrdersController and needs an IOrderService.
The container finds the scoped registration for IOrderService and sees that it must create OrderService. Before it can construct OrderService, it resolves each constructor parameter:
- For
IOrderRepository, it selectsSqlOrderRepository. SqlOrderRepositoryrequiresOrdersDbContext. EF Core’sAddDbContextregistration supplies a scoped context configured for the Orders database.- For
IOrderValidator, it creates a newOrderValidator, because that service is transient. - For
IClock, it createsSystemClockif it has not yet been requested during this application run, or returns the existingSystemClocksingleton if it has.
Only after all constructor arguments are available can the container construct OrderService. It can then construct OrdersController with that completed service instance.
The resulting object graph for this request is conceptually:
OrdersController
IOrderService: OrderService
IOrderRepository: SqlOrderRepository
OrdersDbContext
IOrderValidator: OrderValidator
IClock: SystemClock
For the second request:
- A new request scope is created.
- A new scoped
OrderService,SqlOrderRepository, andOrdersDbContextare created for that scope. - A new transient
OrderValidatoris created each time it is resolved. In this particular graph it is resolved once, so there is one validator instance for the request. - The same application-wide
SystemClocksingleton is reused.
A concise senior-level explanation is:
The container recursively resolves constructor parameters. It follows registrations until it can construct the leaf dependencies, then builds the parent objects. Scoped instances are cached in the current request scope, transients are new for each resolution, and singletons are cached for the application lifetime.
Lifetimes are a correctness decision, not a performance switch
The three standard lifetimes determine instance ownership and reuse. They are not merely a way to reduce allocations.
| Lifetime | Instance behavior in an ASP.NET Core API | Typical examples | Main risk |
|---|---|---|---|
| Transient | A new instance every time the container resolves it | Stateless, lightweight validator or formatter | Excessive allocation or accidental use of expensive resources |
| Scoped | One instance per request scope; the same instance is returned within that request | EF Core DbContext, repository, request-level business service | Using it after the request ends, or injecting it into a singleton |
| Singleton | One shared instance for the running application | Stateless clock, immutable configuration-related service, shared cache client | Shared mutable state, thread-safety failures, captured request state |
The supplied Mastering Dependency Injection in ASP.NET Core diagram shows the overall registration, resolution, and injection cycle, alongside the three lifetimes.

Two corrections matter in interviews:
- Transient does not mean “one per request.” It means one new instance each time it is resolved.
- Scoped means “one per scope,” not universally “one per user.” In a conventional MVC or API application, the framework creates a scope per request. Other hosting models can define scopes differently.
The key lifetime rule: dependencies must not outlive what they depend on
A singleton survives for the lifetime of the application. A scoped DbContext exists only for the request scope that created it. Therefore this is invalid:
public sealed class DailySummaryCache : IDailySummaryCache
{
public DailySummaryCache(OrdersDbContext db)
{
}
}
builder.Services.AddSingleton<IDailySummaryCache, DailySummaryCache>();
The singleton would capture a request-scoped DbContext. That context could be disposed after the first request, and the singleton might also try to use it concurrently across requests. In development, scope validation commonly reports an error similar to:
Cannot consume scoped service 'OrdersDbContext'
from singleton 'DailySummaryCache'.
The normal remedy is to make DailySummaryCache scoped if it genuinely needs request-scoped data. If the component is truly a long-running singleton, such as a hosted background service, it should create a short-lived scope for each unit of work through IServiceScopeFactory; it must not retain one scoped context in its constructor. You will revisit this pattern in the background-work scenario lesson.
A useful rule of thumb:
A long-lived object must not capture a shorter-lived object. Any dependency held by a singleton must be safe to share for the singleton’s entire lifetime.
Constructor injection is the default; service location hides dependencies
This design is explicit:
public sealed class OrderService : IOrderService
{
public OrderService(
IOrderRepository repository,
IOrderValidator validator,
IClock clock)
{
}
}
This design hides dependencies:
public sealed class OrderService : IOrderService
{
private readonly IServiceProvider _services;
public OrderService(IServiceProvider services)
{
_services = services;
}
public Task<Order> GetAsync(int id)
{
var repository =
_services.GetRequiredService<IOrderRepository>();
// ...
throw new NotImplementedException();
}
}
The second approach is the service locator pattern. It makes the constructor appear simple while moving required dependencies into arbitrary method bodies. That weakens discoverability and testing, and failures can occur only when a particular branch runs.
Use constructor injection for ordinary required dependencies. There are legitimate framework-level exceptions, such as creating an explicit scope in a background service, but they should be purposeful and localized.
Similarly, do not automatically create an interface for every class. Injecting an abstraction is valuable where a dependency represents a boundary, has multiple meaningful implementations, needs substitution in tests, or should remain decoupled from its implementation. A small internal helper with no such need may reasonably be injected as its concrete type. The principle is to avoid unnecessary coupling, not to maximize interface count.
A constructor should communicate one unambiguous dependency set
Prefer one public constructor for DI-managed services. The built-in container can select the public constructor with the most parameters it can resolve, but multiple equally resolvable constructors create ambiguity and can cause activation failures.
Avoid this:
public sealed class PricingService
{
public PricingService(ILogger<PricingService> logger)
{
}
public PricingService(IOptions<PricingOptions> options)
{
}
}
If both logging and options are registered, the container cannot choose between the constructors reliably. A single constructor makes the design clear:
public sealed class PricingService
{
public PricingService(
ILogger<PricingService> logger,
IOptions<PricingOptions> options)
{
}
}
A constructor with many dependencies is also architectural feedback. It may signal that the class has accumulated too many responsibilities. Do not treat DI as a way to make a large class acceptable; refactor toward focused services where appropriate.
Diagnosing common resolution failures
When an API fails during startup or on its first relevant request, read the complete exception chain. The named type that “cannot be resolved” is often a dependency inside the top-level service you first requested.
| Symptom | Likely cause | Practical response |
|---|---|---|
“Unable to resolve service for type IX while attempting to activate Y” | IX was never registered, or its implementation has an unresolvable nested dependency | Register the intended implementation, then inspect its constructor dependencies too |
| “Cannot consume scoped service from singleton” | A singleton captures a scoped service, directly or indirectly | Change the design or lifetime; create a scope only in an appropriate long-running boundary |
| Circular dependency exception | A needs B, and B eventually needs A | Revisit responsibilities and introduce a better boundary rather than masking the cycle |
Container cannot resolve string, int, or a connection setting | Raw configuration values are not services by default | Bind configuration using the options pattern or use a focused factory registration |
| Different implementation arrives than expected | Several registrations exist for the same service type | For a single service resolution, the last registration normally wins; use deliberate multi-implementation or keyed-service designs when the domain genuinely requires them |
The container disposes IDisposable and IAsyncDisposable services that it creates when the relevant scope ends. Therefore, do not manually dispose an injected DbContext, repository, or other container-created dependency. Conversely, if you explicitly create an instance and register it with AddSingleton(instance), you own the responsibility for disposing that instance.
Senior interview drill: identify the design risk
Answer aloud before reading the guidance.
A developer registers
InvoiceProcessoras a singleton to avoid constructing it for every request. Its constructor takes an EF CoreBillingDbContextand obtains the current caller’s identity when processing an invoice. What concerns would you raise, and what design would you propose instead?
Model answer
The immediate lifetime problem is that BillingDbContext is normally scoped, while InvoiceProcessor is singleton. A singleton would retain a request-bound object beyond the request that owns it; scope validation should reject this configuration. It would also be unsafe to share an EF Core context across concurrent requests because DbContext is not intended for that usage.
I would first ask whether InvoiceProcessor represents request-level business logic. If it does, I would register it as scoped and inject the scoped BillingDbContext normally. I would pass the relevant caller or authorization context explicitly into the business operation where possible, rather than placing mutable caller data in a singleton.
If this processor is actually a long-running background worker, I would keep the worker singleton as required by hosted-service lifetime, inject IServiceScopeFactory, and create a new scope for each independent unit of work. Inside that scope, it can resolve a fresh scoped processor and DbContext. I would also make the background identity model explicit: a queued job should contain the actor or system identity information required for auditing and authorization decisions, rather than relying on an HTTP request that no longer exists.
This answer demonstrates more than memorizing “scoped versus singleton”: it connects lifetime, concurrency, disposal, request boundaries, and auditability.
A 60-second interview answer
For the question, “How does dependency injection work in ASP.NET Core?”, a clear answer is:
ASP.NET Core has a built-in DI container. At startup, I register services in
Program.cs, usually mapping abstractions to implementations and choosing a lifetime. A class declares its required collaborators in its constructor rather than constructing them directly. When the framework activates a controller or another service, the container recursively resolves its constructor parameters, constructs the object graph, and supplies the completed object. In a typical Web API, scoped services are reused within one request, transient services are new for each resolution, and singletons are shared across the application. I avoid a singleton depending on a scoped service, avoid service-locator style resolution in business classes, and let the container dispose services it creates.
Key takeaways
- Constructor injection makes a class’s dependencies explicit while centralizing implementation and lifetime choices in
Program.cs. - ASP.NET Core resolves an object graph recursively: it resolves constructor dependencies before constructing the object that needs them.
- In a Web API, a request scope normally owns scoped services such as
DbContext; a second request gets new scoped instances. - Transient means a new instance per resolution. Singleton means one shared application instance and therefore requires thread safety and no request-specific mutable state.
- A singleton must not capture a scoped dependency. This is both a disposal and concurrency problem.
- Prefer constructor injection over using
IServiceProvideras a general-purpose service locator. Let the container dispose services it created.
Next, you will follow a request at a broader level: middleware, routing, model binding, validation, the endpoint, and the response.
Can't find a good explanation? Sign up and we'll make it for you
Sign up