Dependency Injection in ASP.NET Core
By the end of this lesson
Register services and pick lifetimes that suit a per-request model.
A class that creates its own dependencies decides for everyone: which database, which email sender, which clock. A class that receives them decides nothing, and the application decides once, in one place. That is dependency injection, and ASP.NET Core has a container for it built in and switched on.
In a web application there is an extra question that barely arises in a console program: how long should an instance live? A request is a natural boundary, and choosing the wrong side of that boundary is the most common way this goes wrong.
Three lifetimes, described by what they mean for a request:
- Transient
- A new instance every time one is asked for. Two classes in the same request that both need it get two different objects. Suitable for small stateless helpers, and wasteful for anything expensive to build.
- Scoped
- One instance per request, shared by everything handling that request. This is the default worth reaching for: a database context, a unit of work, a repository, anything that represents work being done for the current caller.
- Singleton
- One instance for the lifetime of the process, shared by every request on every thread. Suitable for stateless services and for caches you deliberately want to share, and it has to be safe to use concurrently.
// Program.cs — builder phase
builder.Services.AddScoped<IEmployeeRepository, SqlEmployeeRepository>();
builder.Services.AddScoped<EmployeeService>();
builder.Services.AddSingleton<IDepartmentCodeCache, DepartmentCodeCache>();
builder.Services.AddTransient<IPayslipFormatter, PayslipFormatter>();
// EmployeeService.cs
public sealed class EmployeeService
{
private readonly IEmployeeRepository _employees;
private readonly ILogger<EmployeeService> _logger;
public EmployeeService(IEmployeeRepository employees, ILogger<EmployeeService> logger)
{
_employees = employees;
_logger = logger;
}
public async Task<Employee?> FindAsync(int id, CancellationToken ct)
{
var employee = await _employees.GetByIdAsync(id, ct);
if (employee is null)
{
_logger.LogInformation("Employee {EmployeeId} was not found", id);
}
return employee;
}
}- Each Add call maps a service type to an implementation and states a lifetime. The interface is what consumers ask for, so the implementation can be replaced — in a test, or when the data store changes — without touching them.
- The repository and the service are both scoped. Everything in one request that needs the repository gets the same instance, which is what makes a shared transaction or a shared set of tracked entities possible.
- EmployeeService declares its needs in its constructor and nothing else. It never asks the container for anything, so reading the constructor tells you the complete list of what this class depends on.
- The logger is registered for you by the host. Any class can ask for ILogger of itself without registering anything.
- Nothing in this file constructs anything. The container creates instances when a request asks for them, working out the order from the constructors it can see.
public sealed class DepartmentCodeCache : IDepartmentCodeCache
{
private readonly IServiceScopeFactory _scopeFactory;
private Dictionary<string, string>? _codes;
public DepartmentCodeCache(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task<IReadOnlyDictionary<string, string>> GetCodesAsync(CancellationToken ct)
{
if (_codes is not null)
{
return _codes;
}
using var scope = _scopeFactory.CreateScope();
var departments = scope.ServiceProvider.GetRequiredService<IDepartmentRepository>();
_codes = await departments.GetCodeMapAsync(ct);
return _codes;
}
}- IServiceScopeFactory is itself a singleton, so a singleton may hold it safely.
- CreateScope produces a scope that this method owns, with the same rules a request scope has. The scoped repository is resolved inside it.
- The using declaration disposes the scope at the end of the method, and the repository with it. Nothing scoped is retained beyond the call.
- What the cache keeps is plain data, not another component's instance. Holding data in a singleton is fine; holding a shorter-lived service is not.
- One honest weakness: two requests arriving together can both find the field null and both load the map. The result is correct and the work is done twice. A production cache would guard the load, and pretending that detail away would teach the wrong thing.
Choosing a lifetime, in the order these questions are worth asking:
- Does it touch the database or represent work for the current caller? Scoped.
- Is it stateless, or does it hold data you deliberately want shared across requests? Singleton, and make it safe to use from several threads at once.
- Is it a small stateless helper? Transient is fine, and check that constructing it is actually cheap.
- A service may depend on one with an equal or longer lifetime. Never on a shorter one.
- Register the interface rather than the concrete type whenever you expect to substitute it, including in tests.
- Resolving services yourself with GetRequiredService inside your own classes works, and it hides dependencies a constructor would have declared. Use it for the scope case above, not as a habit.
Summary
- Transient is new every time, scoped is one per request, singleton is one per process
- Scoped is the default for anything doing work on behalf of the current caller
- A service may depend on an equal or longer lifetime, never on a shorter one
- A singleton holding a scoped service is a captive dependency, caught at startup in Development and not in production
- When a singleton genuinely needs scoped work, create a scope with IServiceScopeFactory and dispose it
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
See the three lifetimes
Write a counter service with an integer field and a method that increments and returns it. Register it, then inject it into two different classes that both run during one request, and log both values.
Run the same test three times, registering the service as transient, then scoped, then singleton. Record the numbers you see across two requests.
Show solution
Transient gives 1 and 1 on the first request and 1 and 1 on the second, because each injection point got its own object.
Scoped gives 1 and 2 on the first request, then 1 and 2 again on the second. The two classes shared one instance, and the next request started fresh.
Singleton gives 1 and 2, then 3 and 4. The instance outlives the request, which is exactly what you want for a cache and exactly what you do not want for anything holding one caller's data.
Seeing the numbers is worth more than reading the definitions, because it makes the request boundary concrete. It is also a fair warning about singleton state: those increments are happening from many threads at once, and an int field is not safe for that without care.
Think about it
Is a scoped logging service wrong?
Your team has an audit service that appends a line to a file. It is registered as scoped. A reviewer says it should be a singleton.
Who is right, and what do you need to know before you can answer?
Show solution
There is not enough information, and saying so is the correct answer. The deciding question is whether the service holds anything belonging to the current request.
If it accumulates entries and writes them when the request ends, scoped is right: the state is per request and disposal is the natural moment to flush. If it is a thin wrapper that writes immediately and holds nothing, singleton avoids repeated construction, and the file handle has to be safe for concurrent writes.
The reviewer's instinct is worth taking seriously for a different reason: file handles are expensive to open per request. That is a cost argument rather than a correctness one, and the two should be separated when you discuss it.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.