Dependency Injection in ASP.NET Core: Lifetimes Explained – Web API | Lesson 69
Introduction
In Lesson 68, StudentsController asked for a StudentService in its constructor, and one simply
arrived. Nobody wrote new StudentService(...). That is dependency injection: objects receive what
they need from outside instead of creating it themselves.
Lesson 62 did this by hand, in a console Program.cs. ASP.NET Core does it for you with a built-in
container, a registry that knows how to create every service and how long to keep it.
By the end of this lesson, you will be able to:
- Register services with
AddSingleton,AddScoped, andAddTransient. - Explain the three lifetimes, and see them with real object ids.
- Choose the right lifetime for a DbContext, a repository, and a configuration object.
- Spot and fix a captive dependency.
- Group registrations in an extension method such as
AddSchoolData.
Why this matters
Every ASP.NET Core app is built on dependency injection: controllers, middleware, logging, EF Core, and configuration all come through it. Using the wrong lifetime causes some of the hardest bugs to find: data from one parent's request appearing in another's, or a database connection shared by threads.
"Explain singleton, scoped, and transient" is one of the most common .NET interview questions, at every level.
The concept
Example: registration and resolution
Dependency injection has two steps. Registration, in Program.cs, tells the container: "when
something asks for IStudentRepository, give it an EfStudentRepository". Resolution, at run time,
is the container building the objects a controller needs, and everything those objects need, in turn.
To build a StudentsController, the container needs a StudentService. That needs an
IStudentRepository, which needs a SchoolDbContext, which needs its options. The container builds the
whole chain, in the right order, for every request.
Example: the three lifetimes
A lifetime says how long the container keeps an object and reuses it. Think of the school's supply room. The projector is shared by the whole school all year, each classroom gets its own markers for the day, and a paper cup is new every time someone takes one.
| Lifetime | Registered with | One object per | Like | SMS examples |
|---|---|---|---|---|
| Singleton | AddSingleton | The whole app, until it stops | The projector | SqlConnectionFactory, configuration |
| Scoped | AddScoped | Each request | A classroom's markers | SchoolDbContext, repositories, services |
| Transient | AddTransient | Each time it is asked for | Paper cups | Small, cheap helpers with no state |
A scope in ASP.NET Core is one HTTP request. Everything scoped is created once for that request, shared within it, and disposed when the response is sent.
Example: choosing a lifetime
| Question | If yes |
|---|---|
| Does it hold per-request data, or something not thread-safe, such as a DbContext? | Scoped |
| Is it stateless, or does it hold only fixed data, and is it safe for many threads at once? | Singleton |
| Is it cheap, stateless, and needed briefly in many places? | Transient |
| Does it depend on a scoped service? | It must be scoped or transient, never singleton |
A DbContext tracks the entities one unit of work loaded (Lesson 59) and is not thread-safe. Scoped
gives each request its own, which is why AddDbContext registers it as scoped by default.
Apply it to the SMS
Step 1: let Data register itself
Program.cs should not need to know every class inside SchoolApp.Data. An extension method in Data
registers them all:
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SchoolApp.Core.Interfaces;
using SchoolApp.Data.EfCore;
namespace SchoolApp.Data;
// Lesson 69: one call registers everything SchoolApp.Data offers, so the API's Program.cs does not need
// to know the classes inside this project: builder.Services.AddSchoolData(connectionString).
public static class DependencyInjection
{
public static IServiceCollection AddSchoolData(this IServiceCollection services, string connectionString)
{
// Singleton: one SqlConnectionFactory for the whole app; it only holds the connection string.
services.AddSingleton(new SqlConnectionFactory(connectionString));
// Scoped (AddDbContext's default): one SchoolDbContext per request, shared by everything in it.
services.AddDbContext<SchoolDbContext>(options => options.UseSqlServer(connectionString));
// Scoped: repositories use the request's DbContext, so they live exactly as long as it does.
services.AddScoped<IStudentRepository, EfStudentRepository>();
services.AddScoped<IFeeInvoiceRepository, EfFeeInvoiceRepository>();
return services;
}
}
Each registration maps an interface to a class. Services that need an IStudentRepository never learn
that it is an EfStudentRepository; changing to the Dapper version from Lesson 62 would be one line
here.
Step 2: Program.cs
// Lesson 66: the start of SchoolApp.Api. Program.cs has two halves:
// 1. Register services (the "builder" part), 2. build the app and describe how requests are handled.
using SchoolApp.Api.Diagnostics;
using SchoolApp.Api.Middleware;
using SchoolApp.Core.Services;
using SchoolApp.Data;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Services: things the app can use. AddControllers finds every controller class in this project.
builder.Services.AddControllers();
builder.Services.AddOpenApi(); // describes the API as an OpenAPI document (Lesson 71)
// Lesson 69: the dependency injection container. Each line says "when something asks for X, give it Y,
// and keep it for this long". SchoolApp.Data registers its own classes.
builder.Services.AddSchoolData(SqlConnectionFactory.FromEnvironment().ConnectionString);
builder.Services.AddScoped<StudentService>();
builder.Services.AddScoped<FeePaymentService>();
// Lesson 69: the three lifetimes, side by side (see DiagnosticsController).
builder.Services.AddSingleton<SingletonExample>(); // one object for the whole app
builder.Services.AddScoped<ScopedExample>(); // one object per request
builder.Services.AddTransient<TransientExample>(); // a new object every time one is asked for
builder.Services.AddScoped<LifetimeReporter>();
WebApplication app = builder.Build();
// The request pipeline: what happens to every request, in this order (Lesson 67).
// Each Use... adds one middleware; a request goes down the list, and the response comes back up.
app.UseRequestTiming(); // first, so its time includes everything below it
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/v1.json, only while developing
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers(); // send each request to the matching controller action
app.Run(); // start the web server and wait for requests
The three lines from Lesson 68 became one call, AddSchoolData(...), plus the two services from Core.
The minimal API comparison endpoint is gone: SchoolApp uses controllers from here on.
Step 3: see the lifetimes
Three identical classes get a random id when created, and are registered with the three lifetimes.
The controller asks for each one, and so does LifetimeReporter, so the endpoint can compare them:
namespace SchoolApp.Api.Diagnostics;
// Lesson 69: three identical classes, registered with the three lifetimes, to see how many objects
// dependency injection creates. Each object gets a short random id when it is created.
public abstract class LifetimeExample
{
public string Id { get; } = Guid.NewGuid().ToString()[..8];
}
public class SingletonExample : LifetimeExample { }
public class ScopedExample : LifetimeExample { }
public class TransientExample : LifetimeExample { }
// Asks for the same three types, so its objects can be compared with the controller's.
public class LifetimeReporter
{
public LifetimeReporter(SingletonExample singleton, ScopedExample scoped, TransientExample transient)
{
Singleton = singleton;
Scoped = scoped;
Transient = transient;
}
public SingletonExample Singleton { get; }
public ScopedExample Scoped { get; }
public TransientExample Transient { get; }
}
Show src/SchoolApp.Api/Controllers/DiagnosticsController.cs
using Microsoft.AspNetCore.Mvc;
using SchoolApp.Api.Diagnostics;
namespace SchoolApp.Api.Controllers;
// Lesson 69: a teaching endpoint that shows which objects dependency injection reused.
// It answers only in Development; elsewhere it pretends not to exist.
[ApiController]
[Route("api/diagnostics")]
public class DiagnosticsController : ControllerBase
{
private readonly IWebHostEnvironment _environment;
private readonly SingletonExample _singleton;
private readonly ScopedExample _scoped;
private readonly TransientExample _transient;
private readonly LifetimeReporter _reporter;
public DiagnosticsController(IWebHostEnvironment environment, SingletonExample singleton,
ScopedExample scoped, TransientExample transient, LifetimeReporter reporter)
{
_environment = environment;
_singleton = singleton;
_scoped = scoped;
_transient = transient;
_reporter = reporter;
}
// GET /api/diagnostics/lifetimes: each type as the controller got it, and as LifetimeReporter got it.
[HttpGet("lifetimes")]
public IActionResult Lifetimes()
{
if (!_environment.IsDevelopment())
{
return NotFound();
}
return Ok(new
{
singleton = new[] { _singleton.Id, _reporter.Singleton.Id },
scoped = new[] { _scoped.Id, _reporter.Scoped.Id },
transient = new[] { _transient.Id, _reporter.Transient.Id }
});
}
}
Call it twice:
curl.exe http://localhost:5080/api/diagnostics/lifetimes
curl.exe http://localhost:5080/api/diagnostics/lifetimes
{"singleton":["02ad6fc9","02ad6fc9"],"scoped":["7512db2b","7512db2b"],"transient":["ee2c2c72","94dce629"]}
{"singleton":["02ad6fc9","02ad6fc9"],"scoped":["953093d9","953093d9"],"transient":["b40cbf32","8d71410b"]}
Your ids will differ, but the pattern will not.
Example: reading the ids
Singleton: 02ad6fc9 everywhere, in both requests. One object served the whole app.
Scoped: the controller and the reporter shared 7512db2b in the first request, and 953093d9 in
the second. One object per request, shared inside it.
Transient: four different ids. Every request for the class created a new object, even twice in the same request.
The endpoint checks the environment and answers 404 outside Development, so this teaching tool
never shows anything on a real server.
Example: the captive dependency
What if a singleton needs a scoped service? Imagine a StudentCountCache registered as a singleton,
whose constructor takes a SchoolDbContext. The singleton lives forever, so it would keep the first
request's DbContext forever too, shared by every request and every thread.
This is a captive dependency: a short-lived object trapped inside a long-lived one. To see what ASP.NET Core does about it, that class was added for one run. The app refused to start:
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: SchoolApp.Api.Diagnostics.StudentCountCache Lifetime: Singleton ImplementationType: SchoolApp.Api.Diagnostics.StudentCountCache': Cannot consume scoped service 'SchoolApp.Data.EfCore.SchoolDbContext' from singleton 'SchoolApp.Api.Diagnostics.StudentCountCache'.)
In Development, ASP.NET Core checks every registration at startup and stops on this mistake. That
check is off in Production by default, to start faster, so a captive dependency that slips past
development runs quietly there and fails under load. Always run the app in Development before
release.
The fix is to make the cache scoped, or, when a long-lived service really needs the database, to create
a scope for each piece of work with IServiceScopeFactory.
Example: a forgotten registration
For one more experiment, the line builder.Services.AddScoped<StudentService>(); was removed. The app
started without complaint, and GET /api/school still worked. Then a student request failed:
HTTP/1.1 500 Internal Server Error
System.InvalidOperationException: Unable to resolve service for type 'SchoolApp.Core.Services.StudentService' while attempting to activate 'SchoolApp.Api.Controllers.StudentsController'.
Why did startup validation not catch it, as it caught the captive dependency? Controllers are not registered in the container themselves; ASP.NET Core builds them only when a request needs one. So the missing service is found only when that controller is first used.
The error message says exactly what is missing and who wanted it. Read it from the end: the
StudentsController needed a StudentService, and nobody registered one. This is the most common
dependency injection error.
A test in Lesson 96 catches it before any user does, by creating every controller the same way ASP.NET Core does.
Example: constructor injection, not looking things up
Every SchoolApp class lists what it needs in its constructor. The alternative, asking
HttpContext.RequestServices or an IServiceProvider for services inside methods, is called the
service locator pattern.
It hides dependencies: you cannot tell from the constructor what a class uses, and tests must set up a whole container. Use constructor injection everywhere except in rare framework code.
Example: who disposes services
SchoolDbContext holds a database connection, so it must be disposed (Lesson 55). With dependency
injection, the container does it: scoped and transient objects it created are disposed when the request
ends, and singletons when the app stops.
So never write using around a service you received through the constructor. You did not create it,
and other objects in the same request may still be using it.
Common mistakes
- A DbContext as a singleton. One context shared by every request: wrong data and thread errors.
Fix:
AddDbContext, which is scoped. - A singleton depending on a scoped service. A captive dependency. Fix: match lifetimes, or
create scopes with
IServiceScopeFactory. - State in a singleton without thread safety. Many requests change it at once (Lesson 54). Fix: keep singletons stateless, or use thread-safe types.
newfor services inside controllers. Dependencies are hidden and cannot be swapped. Fix: ask for them in the constructor.- Forgetting to register a service. The request fails with "Unable to resolve service for type".
Fix: register it, ideally in the project's own
AddXyzmethod.
In real projects
A school portal sometimes showed one parent's children on another parent's screen. It happened rarely, only at busy times, and never on a developer's machine.
A "current user" helper that stored the signed-in parent had been registered as a singleton. Two parents signing in at the same moment overwrote each other's value in that one shared object.
Changing one word, AddSingleton to AddScoped, fixed it. Since then, every registration in our code
reviews gets one question: "how long should this live, and who else can see it?"
Best practices
- Use constructor injection, and depend on interfaces where an implementation may change.
- Register a DbContext and anything using it as scoped.
- Keep singletons stateless or thread-safe; never let them hold scoped services.
- Group each project's registrations in an
AddXyzextension method. - Run in
Developmentbefore release, so scope validation can catch lifetime mistakes.
Recap
- Dependency injection hands objects what they need; the container builds the whole chain.
- Singleton: one per app. Scoped: one per request. Transient: one per request for the service.
- The demo endpoint showed the same singleton id everywhere, one scoped id per request, and new transient ids each time.
- A singleton must not depend on a scoped service; Development scope validation stops the app if it does.
AddSchoolDatakeeps Data's registrations inside Data.
Homework
Library module task: Write a fine calculator that depends on an IClock interface for today's
date. Register a FixedClock set to 2026-08-01, and add GET /api/fines/{dueOn}. Then explain which one
line you would change to use the real date.
A correct result looks like:
{"dueOn":"2026-07-20","today":"2026-08-01","daysLate":12,"fine":24}
{"dueOn":"2026-08-15","today":"2026-08-01","daysLate":0,"fine":0}
Show the solution
// Library homework, Lesson 69: fines that depend on today's date, through an interface, so the date
// can be fixed for testing by changing one registration.
// Run with: dotnet run library-assignment/069-library-di.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Microsoft.AspNetCore.Mvc;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Swap these two lines to use the real date. Nothing else changes.
builder.Services.AddSingleton<IClock>(new FixedClock(new DateOnly(2026, 8, 1)));
// builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<FineCalculator>();
WebApplication app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5069");
public interface IClock
{
DateOnly Today { get; }
}
public class SystemClock : IClock
{
public DateOnly Today => DateOnly.FromDateTime(DateTime.Now);
}
public class FixedClock : IClock
{
public FixedClock(DateOnly today)
{
Today = today;
}
public DateOnly Today { get; }
}
// The rule: 2 per day late. It asks for an IClock and never knows which one it gets.
public class FineCalculator
{
private readonly IClock _clock;
public FineCalculator(IClock clock)
{
_clock = clock;
}
public FineQuote Quote(DateOnly dueOn)
{
int daysLate = Math.Max(0, _clock.Today.DayNumber - dueOn.DayNumber);
return new FineQuote(dueOn, _clock.Today, daysLate, daysLate * 2m);
}
}
public record FineQuote(DateOnly DueOn, DateOnly Today, int DaysLate, decimal Fine);
[ApiController]
[Route("api/fines")]
public class FinesController : ControllerBase
{
private readonly FineCalculator _calculator;
public FinesController(FineCalculator calculator)
{
_calculator = calculator;
}
// GET /api/fines/2026-07-20
[HttpGet("{dueOn}")]
public FineQuote Get(DateOnly dueOn) => _calculator.Quote(dueOn);
}
curl.exe http://localhost:5069/api/fines/2026-07-20 and .../2026-08-15 produce the two lines.
FineCalculator never changes; only the registration chooses the clock. That is also how tests control
time without waiting for real days to pass.
Quiz
Which lifetime gives one object per HTTP request?
Show answer
Answer: B. Scoped The scoped id was shared inside a request and changed between requests.
Why is a DbContext registered as scoped?
Show answer
Answer: B. It tracks one unit of work and is not thread-safe Each request gets its own context.
The transient ids in one request were different. Why?
Show answer
Answer: B. Transient creates a new object every time one is asked for The controller and the reporter each got their own.
What is a captive dependency?
Show answer
Answer: B. A short-lived service held by a longer-lived one A singleton holding a scoped DbContext keeps it forever.
When does ASP.NET Core check for captive dependencies at startup by default?
Show answer
Answer: B. In Development Validation is off in Production by default, to start faster.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.
Fresher (0-2 yrs)
F What is dependency injection? concept
A technique where an object receives the services it needs, usually through its constructor, instead
of creating them itself. In ASP.NET Core a built-in container creates them: StudentsController asks
for a StudentService and gets one, with its repository and DbContext built for it.
F What is the difference between singleton, scoped, and transient lifetimes? concept
Singleton: one object for the whole application. Scoped: one object per request, shared inside it. Transient: a new object every time one is requested. In the demo, the singleton id never changed, the scoped id changed per request, and transient ids differed even within one request.
F Which lifetime should a DbContext have, and why? concept
Scoped, which AddDbContext uses by default. A DbContext tracks the entities of one unit of work and
is not thread-safe, so each request needs its own, shared by the repositories and services handling
that request, and disposed when the request ends.
Mid (3-5 yrs)
M What is a captive dependency, and how does ASP.NET Core help you catch it? concept
A shorter-lived service held by a longer-lived one, such as a singleton whose constructor takes a scoped DbContext: the context then lives forever and is shared by every request. In Development, scope validation stops the app at startup with "Cannot consume scoped service ... from singleton". It is off in Production by default.
M Why is the 'Unable to resolve service' error for a controller found only at request time? scenario
Controllers are not registered in the container; ASP.NET Core creates them when a request needs one, resolving their constructor parameters then. So startup validation does not see that a controller's dependency is missing. Tests that call every endpoint catch it before users do.
M Why prefer constructor injection over resolving services from IServiceProvider inside methods? concept
Constructor injection makes every dependency visible in one place, lets the container validate them,
and lets tests pass fakes directly. Resolving from IServiceProvider or HttpContext.RequestServices
(the service locator pattern) hides dependencies and couples code to the container.
Senior (6-9 yrs)
S Parents occasionally see another parent's data at busy times. You suspect dependency injection. What do you look for? scenario
Singletons that hold per-request state, such as a current-user helper or a cache keyed by nothing, and singletons that capture scoped services. Check registrations for state and lifetimes, make per-request state scoped, keep singletons stateless or thread-safe, and add a concurrency test that runs two users in parallel.
How to answer: Explain why the bug appears only under concurrency, list the suspicious registration patterns, and give the fix and a way to prove it.
S A singleton background service needs to read the database. How do you give it a DbContext safely? coding
Inject IServiceScopeFactory, create a scope for each piece of work with CreateAsyncScope(), resolve
the DbContext or repository from that scope, and dispose the scope when the work ends. Each run then
gets a fresh context, as a request would, instead of one captive context for the life of the app.
How to answer: Name the captive dependency risk, show the scope-per-unit-of-work pattern, and mention
IDbContextFactory as an alternative when only a context is needed.
Architect / Lead (10+ yrs)
A How would you organise service registration in a solution with many projects? design
Each project registers its own types through one extension method, such as AddSchoolData(connection),
so the host's Program.cs composes projects, not individual classes. Register interfaces to
implementations, choose lifetimes deliberately, keep scope validation on in development and CI, and
test that every endpoint resolves.
How to answer: Cover ownership of registrations, the composition root, how lifetimes are reviewed, and automated checks. Mention keeping Core free of container packages if it should stay framework-neutral.
Resources
- Code for this lesson: git tag
lesson-69-dependency-injection(code repository coming soon) - Files:
src/SchoolApp.Data/DependencyInjection.cs,src/SchoolApp.Api/Program.cs,src/SchoolApp.Api/Diagnostics/LifetimeExamples.cs,src/SchoolApp.Api/Controllers/DiagnosticsController.cs,library-assignment/069-library-di.cs - Video: coming soon
What's next
The connection string still comes from an environment variable read in Program.cs. Next: ASP.NET
Core configuration, with appsettings.json, environments, user secrets, and strongly typed options.
Lesson 70: Configuration and environments
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.