Hello, and welcome to the first lesson of the capstone API module. This module refreshes the .NET practices that most often distinguish a merely functional ASP.NET Core API from one that behaves predictably under real traffic: dependency injection, middleware, async execution, HTTP semantics, configuration, and outbound calls.
You already have substantial ASP.NET Core experience, so this lesson focuses on diagnosing the failures that emerge from lifetime mismatches: request data leaking between users, an EF Core DbContext surviving far too long, middleware accidentally retaining request-scoped state, and services that appear transient but are effectively held for the application lifetime.
By the end, you should be able to inspect a request path, reason about each service’s lifetime, identify invalid dependency directions, and correct the registrations or boundaries in a portfolio-tracker API.
1. The lifetime question is really a boundary question
ASP.NET Core’s DI container creates objects according to three lifetimes:
| Lifetime | Instance boundary in a web API | Typical use |
|---|---|---|
| Transient | New instance each time the container resolves it | Small, short-lived, per-use helpers |
| Scoped | One instance for one HTTP request | EF Core contexts, repositories, application services, request state |
| Singleton | One instance for the process lifetime | Stateless thread-safe services, shared caches, immutable configuration-derived objects |
The essential mental model is this:
- The application starts and creates a root service provider.
- Each incoming HTTP request receives a request scope.
- Services resolved during that request are created from that scope.
- At the end of the request, the framework disposes that scope and the disposable services it owns.
- Singleton services remain alive until application shutdown.
A scoped service is therefore not simply “a service registered with AddScoped.” It is an object whose validity is tied to a particular scope. In a normal ASP.NET Core API, that scope is the request.
Service lifetimes (dependency injection) - .NET | Microsoft Learn
Read Microsoft Learn’s “Service lifetimes” for the official definitions and, most importantly, the warning about resolving scoped services from singleton services.
In the “Transient” subsection, read the transient definition and note the allocation and disposal implications. Then read the “Scoped” subsection, beginning with the request-scope explanation. Continue through the singleton warning. Finish with the “Singleton” subsection, especially its thread-safety guidance.
A singleton is shared by concurrent requests. That has two immediate consequences:
- It must be thread-safe.
- It must not retain request-specific data such as a user ID, authorization result,
HttpContext, EF Core entity,DbContext, or mutable “current portfolio” field.
For example, this is a severe but plausible defect:
public sealed class CurrentPortfolioContext : ICurrentPortfolioContext
{
public Guid? PortfolioId { get; set; }
}
If registered as a singleton, one request can write PortfolioId, and another concurrent request can read or overwrite it. This is both a race condition and a potential data-isolation issue.
builder.Services.AddScoped<ICurrentPortfolioContext, CurrentPortfolioContext>();
Scoped is appropriate here because the object represents request-local state. Better still, avoid mutable request state where the data can be passed explicitly, but when a request context is justified, it must not outlive the request.
2. See lifetimes inside one portfolio request
Consider a simplified portfolio API request:
GET /api/portfolios/42
The endpoint calls PortfolioService, which calls PortfolioRepository, which uses PortfolioDbContext. The endpoint and service may also use a request marker for correlation or audit context.
A defensible baseline registration is:
builder.Services.AddDbContext<PortfolioDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("PortfolioDb"));
});
builder.Services.AddScoped<IPortfolioRepository, EfPortfolioRepository>();
builder.Services.AddScoped<IPortfolioService, PortfolioService>();
builder.Services.AddScoped<IRequestPortfolioContext, RequestPortfolioContext>();
builder.Services.AddSingleton<IAllocationCalculator, AllocationCalculator>();
builder.Services.AddTransient<IImportRowParser, ImportRowParser>();
The choice behind each line matters more than memorizing the method name:
PortfolioDbContextis scoped byAddDbContextby default. ADbContextrepresents a unit of work and is not thread-safe.- A repository that depends on that context should normally also be scoped.
- The portfolio application service should be scoped when it coordinates request-specific database work and authorization context.
- A pure allocation calculator can be singleton if it has no mutable shared state and all its operations are thread-safe.
- A parser may be transient when each resolution should have its own short-lived parsing state. It should not quietly retain expensive resources or request data.
Observe the behavior rather than trusting the registration
A short development-only diagnostic is useful when reviewing an unfamiliar application. Add a marker and compare the instance used directly by the endpoint with the one received by the scoped application service.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class PortfolioService
{
private readonly RequestMarker _marker;
public PortfolioService(RequestMarker marker)
{
_marker = marker;
}
public Guid MarkerId => _marker.Id;
}
builder.Services.AddScoped<RequestMarker>();
builder.Services.AddScoped<PortfolioService>();
app.MapGet("/diagnostics/lifetime",
(RequestMarker endpointMarker, PortfolioService portfolioService) =>
Results.Ok(new
{
EndpointMarker = endpointMarker.Id,
ServiceMarker = portfolioService.MarkerId
}));
Call the endpoint twice.
- Within one response,
EndpointMarkerandServiceMarkershould be the same. - Across two separate requests, the marker should change.
Now temporarily change RequestMarker to transient:
builder.Services.AddTransient<RequestMarker>();
The two values within one request will differ, because the endpoint and PortfolioService each receive a separate resolution. Change it to singleton, and the value will persist across requests. This is a compact way to make lifetime behavior visible.
Keep such endpoints out of production, but the technique is valuable in a local branch or a focused automated test.
3. The dependency-direction rule: long-lived consumers must not capture short-lived state
The practical rule is:
A service must not hold a dependency that outlives the dependency’s valid boundary.
The most dangerous case is unambiguous:
public sealed class PortfolioCache : IPortfolioCache
{
private readonly PortfolioDbContext _dbContext;
public PortfolioCache(PortfolioDbContext dbContext)
{
_dbContext = dbContext;
}
}
builder.Services.AddScoped<PortfolioDbContext>();
builder.Services.AddSingleton<IPortfolioCache, PortfolioCache>();
This singleton tries to capture a scoped DbContext. In Development, scope validation should fail with an error similar to:
Cannot consume scoped service from singleton.
If validation is disabled or bypassed, the result can be worse: a database context effectively kept for the entire application process. That can produce stale tracking state, unsafe concurrent use, exhausted resources, and unpredictable data behavior.

A useful review matrix
| Consumer lifetime | Dependency lifetime | Assessment |
|---|---|---|
| Singleton | Singleton | Valid if the dependency is thread-safe |
| Singleton | Scoped | Invalid: request-scoped object would be captured for process lifetime |
| Singleton | Transient | Technically permitted, but the singleton captures one transient instance; review carefully |
| Scoped | Singleton | Valid |
| Scoped | Scoped | Valid; both use the same request scope |
| Scoped | Transient | Valid, but that transient is retained by the scoped consumer for that request |
| Transient | Singleton | Valid |
| Transient | Scoped | Valid when resolved from an active request scope |
| Transient | Transient | Valid; each resolution can be distinct |
There is an important nuance for senior-level interviews: “never inject transient into scoped” is too broad.
A scoped service can depend on a transient service. However, the transient is created when the scoped service is created, so it is held for the scoped service’s lifetime. If your intent was “create a fresh helper every individual operation,” this does not achieve that intent. The registration is legal, but the behavior may not match the design.
Similarly, singleton-consuming-transient is technically legal, but the transient is constructed once when the singleton is constructed. It is effectively retained as long as the singleton. That is often a sign the dependency should itself be singleton and thread-safe, or that the design should avoid storing it.
Correct the design, not merely the exception
For the bad PortfolioCache example, the usual correction is not to make the DbContext singleton. Instead, separate cached data from database access:
public sealed class PortfolioService : IPortfolioService
{
private readonly IPortfolioRepository _repository;
public PortfolioService(IPortfolioRepository repository)
{
_repository = repository;
}
public Task<PortfolioSummary> GetSummaryAsync(
Guid portfolioId,
CancellationToken cancellationToken)
{
return _repository.GetSummaryAsync(portfolioId, cancellationToken);
}
}
builder.Services.AddScoped<IPortfolioService, PortfolioService>();
builder.Services.AddScoped<IPortfolioRepository, EfPortfolioRepository>();
Later, if measured performance warrants caching, a singleton cache should store immutable, thread-safe cached values such as PortfolioSummary records. It should not own an EF Core context or repository. Caching, invalidation, and concurrency deserve their own design discussion rather than being hidden inside a service lifetime workaround.
4. Conventional middleware has a lifetime trap
Middleware added through app.UseMiddleware<T>() is normally created once and reused. In practical terms, treat constructor dependencies in conventional middleware as singleton-compatible.
This is wrong:
public sealed class PortfolioAuditMiddleware
{
private readonly RequestDelegate _next;
private readonly IRequestPortfolioContext _requestContext;
public PortfolioAuditMiddleware(
RequestDelegate next,
IRequestPortfolioContext requestContext)
{
_next = next;
_requestContext = requestContext;
}
public Task InvokeAsync(HttpContext context)
{
return _next(context);
}
}
IRequestPortfolioContext is scoped, but the middleware instance would retain it. Put stable dependencies in the constructor and request-scoped dependencies in InvokeAsync instead:
public sealed class PortfolioAuditMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<PortfolioAuditMiddleware> _logger;
public PortfolioAuditMiddleware(
RequestDelegate next,
ILogger<PortfolioAuditMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(
HttpContext context,
IRequestPortfolioContext requestContext)
{
_logger.LogInformation(
"Handling request {TraceIdentifier} for portfolio {PortfolioId}",
context.TraceIdentifier,
requestContext.PortfolioId);
await _next(context);
}
}
app.UseMiddleware<PortfolioAuditMiddleware>();
ASP.NET Core resolves requestContext for each call to InvokeAsync, from the active request scope.
Dependency injection in ASP.NET Core
Use this Microsoft Learn page to reinforce the request-scope model and the special handling required by conventional middleware.
In the “Service lifetimes” material, read the middleware guidance. Focus on why constructor injection of a scoped service fails and why method injection is correct. Then locate the “Request Services” subsection and read the request-services explanation. Prefer explicit constructor or method injection in application code; do not use HttpContext.RequestServices as a general service-locator substitute.
There is an alternative when constructor injection of scoped dependencies is desirable: implement IMiddleware and register that middleware as scoped. ASP.NET Core then activates it per request. For most cross-cutting middleware, however, injecting scoped services into InvokeAsync is simple and clear.
5. Root-provider resolution is not request resolution
A common source of defects is resolving scoped services from app.Services, the root provider:
var dbContext = app.Services.GetRequiredService<PortfolioDbContext>();
app.Services is not the current request scope. It is the root service provider. Resolving a scoped service from it violates the intended boundary and can make the instance live until application shutdown.
There are two legitimate situations for an explicit scope:
- one-time startup work, such as applying local development migrations or seeding test data;
- background processing, where no HTTP request scope exists.
For startup-only work:
await using var scope = app.Services.CreateAsyncScope();
var dbContext = scope.ServiceProvider
.GetRequiredService<PortfolioDbContext>();
await dbContext.Database.MigrateAsync();
The explicit scope creates a bounded owner for the DbContext; disposing the scope disposes the services created within it.
Do not create a new scope casually inside an HTTP endpoint or application service merely to make an error disappear. The request already has a scope. Creating another scope can give one request multiple DbContext instances, fragmenting the expected unit of work and complicating transactions and tracking.
A background worker is different because it is usually hosted as a singleton-like service and has no ambient request scope. The correct pattern there is to inject IServiceScopeFactory, create one scope for a defined work unit, resolve scoped services within it, and dispose it. You will apply that pattern later when building a cancellable BackgroundService.
6. Make lifetime errors fail early
Scope validation turns an obscure production bug into an immediate startup failure. For the capstone, enable it explicitly during development:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
ValidateScopes detects major mistakes such as a singleton depending on a scoped service or resolving scoped services from the root provider. ValidateOnBuild asks the container to validate constructible registrations while building the application.
Validation is valuable but not magical:
- It cannot infer whether a singleton’s mutable fields are properly synchronized.
- It may not expose a problem hidden behind runtime service-location or an opaque factory.
- It does not decide whether your “legal” transient registration is semantically appropriate.
Therefore, combine validation with a design review of each service:
- What state does it hold? Request, user, transaction, cache, or none?
- Who shares it? One resolution, one request, or every concurrent request?
- What resources does it own? Database context, stream, socket, disposable object?
- Can concurrent calls safely mutate it?
- Does its constructor retain a dependency whose lifetime is shorter?
For your portfolio tracker, make this a practical review pass today:
- Keep
PortfolioDbContext, repositories, and request-oriented application services scoped. - Search for
AddSingletonregistrations and inspect their fields for mutable state or dependencies on scoped services. - Search for
app.Services.GetRequiredServiceand verify every use occurs inside a clearly bounded explicit scope. - Inspect conventional middleware constructors; move scoped dependencies to
InvokeAsync. - Enable scope validation and run the API before committing the changes.
A concise interview answer
When asked, “How do you choose service lifetimes in ASP.NET Core?”, a strong answer is:
I start from the ownership boundary rather than defaulting everything to transient. Singleton services are shared across the process, so they must be thread-safe and cannot retain request or user state. Scoped services are one per HTTP request, which is appropriate for EF Core
DbContext, repositories, and request-level application services. Transients are created on each resolution and fit small per-use helpers, though I consider allocation and whether a longer-lived consumer would capture one. I enable scope validation, avoid resolving scoped services from the root provider, and inject scoped dependencies into conventional middleware’sInvokeAsyncrather than its constructor. Outside an HTTP request, such as in startup work or a hosted service, I create and dispose an explicit scope.
Key takeaways
- Scoped means one instance per HTTP request in the normal ASP.NET Core request path;
DbContextis scoped by default for good reasons. - A singleton must not depend on a scoped service. It would retain request-bound state for the process lifetime.
- Singleton dependencies must be thread-safe and should generally be stateless or carefully synchronized.
- A transient dependency injected into a longer-lived consumer is legal but may no longer behave as “fresh for every operation.”
- In conventional middleware, inject scoped services into
InvokeAsync, not the constructor. - Resolve scoped services through the request scope. When no request exists, create a short, explicit scope and dispose it.
- Enable scope validation so incorrect graphs fail early.
Next, you will use this DI foundation to assemble authentication, authorization, routing, exception handling, and custom middleware in a defensible request-pipeline order.
Can't find a good explanation? Sign up and we'll make it for you
Sign up