Skip to main content

Dependency Injection in ASP.NET Core: Lifetimes Explained – Web API | Lesson 69

Published / updated

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, and AddTransient.
  • 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.

LifetimeRegistered withOne object perLikeSMS examples
SingletonAddSingletonThe whole app, until it stopsThe projectorSqlConnectionFactory, configuration
ScopedAddScopedEach requestA classroom's markersSchoolDbContext, repositories, services
TransientAddTransientEach time it is asked forPaper cupsSmall, 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​

QuestionIf 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:

src/SchoolApp.Data/DependencyInjection.cs
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​

src/SchoolApp.Api/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:

src/SchoolApp.Api/Diagnostics/LifetimeExamples.cs
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
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:

Terminal 2
curl.exe http://localhost:5080/api/diagnostics/lifetimes
curl.exe http://localhost:5080/api/diagnostics/lifetimes
Output
{"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:

Output
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:

Output
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​

  1. A DbContext as a singleton. One context shared by every request: wrong data and thread errors. Fix: AddDbContext, which is scoped.
  2. A singleton depending on a scoped service. A captive dependency. Fix: match lifetimes, or create scopes with IServiceScopeFactory.
  3. State in a singleton without thread safety. Many requests change it at once (Lesson 54). Fix: keep singletons stateless, or use thread-safe types.
  4. new for services inside controllers. Dependencies are hidden and cannot be swapped. Fix: ask for them in the constructor.
  5. Forgetting to register a service. The request fails with "Unable to resolve service for type". Fix: register it, ideally in the project's own AddXyz method.

In real projects​

From 16 years of .NET 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 AddXyz extension method.
  • Run in Development before 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.
  • AddSchoolData keeps 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:

Output
{"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-assignment/069-library-di.cs
// 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​

  1. Which lifetime gives one object per HTTP request?

    1. Singleton
    2. Scoped
    3. Transient
    4. Static
    Show answer

    Answer: B. Scoped The scoped id was shared inside a request and changed between requests.

  2. Why is a DbContext registered as scoped?

    1. It is cheap to create
    2. It tracks one unit of work and is not thread-safe
    3. Singletons cannot use SQL
    4. Transient is not allowed
    Show answer

    Answer: B. It tracks one unit of work and is not thread-safe Each request gets its own context.

  3. The transient ids in one request were different. Why?

    1. A bug
    2. Transient creates a new object every time one is asked for
    3. The request was sent twice
    4. Scope validation
    Show answer

    Answer: B. Transient creates a new object every time one is asked for The controller and the reporter each got their own.

  4. What is a captive dependency?

    1. A service with no interface
    2. A short-lived service held by a longer-lived one
    3. A circular reference
    4. A private constructor
    Show answer

    Answer: B. A short-lived service held by a longer-lived one A singleton holding a scoped DbContext keeps it forever.

  5. When does ASP.NET Core check for captive dependencies at startup by default?

    1. Always
    2. In Development
    3. Only in Production
    4. Never
    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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.