Dependency Injection
Before you start
You need: configuration (Article 03) and interfaces (Track 03 Article 04).
Time: about 45 minutes, plus the practice.
Learning objective
Choose the correct lifetime for any service, and recognise a captive dependency before it corrupts data.
Topics
- Why dependency injection
- The three lifetimes
- Registering services
- Constructor injection
- Captive dependencies
- Scopes in background work
- Registering several implementations
- Keyed services
- Diagnosing DI errors
Why
// Without DI — the controller decides how everything is built
public class StudentsController : ControllerBase
{
private readonly StudentService _service = new StudentService(
new StudentRepository("Server=.;Database=NexCodingSchool;..."));
}
Three problems, and the third is the one that matters: the connection string is hardcoded, the controller cannot be tested without a database, and changing how StudentRepository is built means editing every class that constructs one.
// With DI — the controller declares what it needs
public class StudentsController : ControllerBase
{
private readonly IStudentService _service;
public StudentsController(IStudentService service)
{
_service = service;
}
}
The controller depends on an abstraction. The container decides the implementation, its dependencies, and its lifetime — so a test supplies a fake and the production code is unchanged.
The three lifetimes
| Lifetime | One instance per | Use for |
|---|---|---|
| Transient | Every injection | Lightweight, stateless services |
| Scoped | HTTP request | The default for most services — repositories, DbContext, unit of work |
| Singleton | Application | Caches, configuration, expensive stateless objects |
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();
builder.Services.AddScoped<IStudentRepository, StudentRepository>();
builder.Services.AddScoped<IStudentService, StudentService>();
builder.Services.AddSingleton<IClassListCache, ClassListCache>();
Scoped is the right default. A request is a natural unit of work: everything in it shares one database connection, one transaction boundary, one tenant context, and everything is released together at the end.
// Scoped: both get the SAME instance within one request
public StudentService(IStudentRepository repository, IAuditRepository audit)
// Transient: each injection gets a NEW instance, even in one request
Transient looks safer but is not free — a transient injected in five places allocates five objects per request, and any per-request state it holds is not shared.
Singleton is the one that requires care. A singleton must be thread-safe, because every concurrent request shares it:
// Broken — Dictionary is not safe for concurrent writes
public class ClassListCache
{
private readonly Dictionary<int, List<string>> _cache = new();
}
// Correct
public class ClassListCache
{
private readonly ConcurrentDictionary<int, List<string>> _cache = new();
}
A non-thread-safe singleton usually works in development with one user and corrupts under load.
Registration
// Interface to implementation
builder.Services.AddScoped<IStudentRepository, StudentRepository>();
// Concrete type only
builder.Services.AddScoped<StudentService>();
// Factory — when construction needs logic
builder.Services.AddScoped<IStudentRepository>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
var connectionString = configuration.GetConnectionString("SchoolDb")!;
return new StudentRepository(connectionString);
});
// An existing instance — always singleton
builder.Services.AddSingleton(new HttpClient());
// Only if not already registered
builder.Services.TryAddScoped<IStudentRepository, StudentRepository>();
TryAdd matters when writing a library: it registers a default without overriding whatever the consuming application already chose.
The last registration wins for a plain Add:
builder.Services.AddScoped<IStudentRepository, SqlStudentRepository>();
builder.Services.AddScoped<IStudentRepository, CachedStudentRepository>();
// Resolving IStudentRepository gives CachedStudentRepository
Useful for overriding a service in tests, and a source of confusion when two files both register the same interface.
Grouping registrations
public static class DataServiceCollectionExtensions
{
public static IServiceCollection AddSchoolData(
this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("SchoolDb")
?? throw new InvalidOperationException("Connection string 'SchoolDb' is not configured.");
services.AddSingleton<ISqlConnectionFactory>(new SqlConnectionFactory(connectionString));
services.AddScoped<IStudentRepository, StudentRepository>();
services.AddScoped<IFeeRepository, FeeRepository>();
services.AddScoped<IExamRepository, ExamRepository>();
return services;
}
}
builder.Services.AddSchoolData(builder.Configuration);
builder.Services.AddSchoolServices();
Keeps Program.cs readable once there are more than a handful of registrations.
Constructor injection
public class StudentService : IStudentService
{
private readonly IStudentRepository _repository;
private readonly IFeeRepository _feeRepository;
private readonly ILogger<StudentService> _logger;
private readonly SchoolPortalOptions _options;
public StudentService(
IStudentRepository repository,
IFeeRepository feeRepository,
ILogger<StudentService> logger,
IOptions<SchoolPortalOptions> options)
{
_repository = repository;
_feeRepository = feeRepository;
_logger = logger;
_options = options.Value;
}
}
Constructor injection is the only form to use. The constructor lists every dependency, so a class that needs eight things announces it — and that is a signal the class is doing too much, not a reason to hide them.
ILogger<T> is registered automatically. So are IConfiguration, IWebHostEnvironment, IHttpContextAccessor (when added), and IOptions<T> for anything you configured.
[FromServices] injects into an action method rather than the constructor, for a dependency only one action needs:
public async Task<IActionResult> Export([FromServices] IExportService exportService)
Captive dependencies
A service cannot safely depend on one with a shorter lifetime.
// Singleton capturing a scoped service
public class ClassListCache // singleton
{
private readonly IStudentRepository _repository; // scoped
public ClassListCache(IStudentRepository repository)
{
_repository = repository; // captured for the application's lifetime
}
}
The singleton is built once, so it holds one repository forever — with its database connection, its request context and its tenant. Every subsequent request uses that first request's scope.
In a multi-tenant system that is a data leak: the cache resolved with school 1's context serves school 2's requests.
ASP.NET Core catches this at startup in Development:
System.AggregateException: Some services are not able to be constructed
Cannot consume scoped service 'IStudentRepository' from singleton 'ClassListCache'.
builder.Services.AddControllers() enables scope validation in Development automatically. To enable it everywhere:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
ValidateOnBuild checks every registration at startup rather than on first resolution — so a broken graph fails the deployment instead of the first request that touches it.
The fix: IServiceScopeFactory
public class ClassListCache : IClassListCache
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ConcurrentDictionary<int, List<string>> _cache = new();
public ClassListCache(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task<List<string>> GetClassNamesAsync(int schoolId, CancellationToken ct)
{
if (_cache.TryGetValue(schoolId, out var cached))
{
return cached;
}
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IStudentRepository>();
var names = await repository.GetClassNamesAsync(schoolId, ct);
_cache[schoolId] = names;
return names;
}
}
The singleton creates a fresh scope per operation, resolves the scoped service inside it, and disposes it. Nothing is captured.
Scopes in background work
A hosted service is a singleton, so the same rule applies.
public class FeeReminderService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<FeeReminderService> _logger;
public FeeReminderService(
IServiceScopeFactory scopeFactory, ILogger<FeeReminderService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var feeService = scope.ServiceProvider.GetRequiredService<IFeeService>();
await feeService.SendOverdueRemindersAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Fee reminder run failed");
}
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}
builder.Services.AddHostedService<FeeReminderService>();
Two things this gets right: a new scope per iteration, and a try/catch inside the loop. An unhandled exception in ExecuteAsync stops the background service permanently, with nothing in the logs to say the hourly job silently stopped weeks ago.
Several implementations
builder.Services.AddScoped<INotificationSender, EmailNotificationSender>();
builder.Services.AddScoped<INotificationSender, SmsNotificationSender>();
public class NotificationService
{
private readonly IEnumerable<INotificationSender> _senders;
public NotificationService(IEnumerable<INotificationSender> senders)
{
_senders = senders;
}
public async Task NotifyAsync(string message, CancellationToken ct)
{
foreach (var sender in _senders)
{
await sender.SendAsync(message, ct);
}
}
}
Injecting IEnumerable<T> gives every registration. Injecting T gives only the last one registered.
Keyed services
builder.Services.AddKeyedScoped<INotificationSender, EmailNotificationSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsNotificationSender>("sms");
public class FeeReminderHandler
{
private readonly INotificationSender _sender;
public FeeReminderHandler([FromKeyedServices("sms")] INotificationSender sender)
{
_sender = sender;
}
}
Cleaner than a factory when the choice is known at compile time.
Diagnosing DI errors
| Error | Cause |
|---|---|
Unable to resolve service for type 'IX' while attempting to activate 'Y' | IX was never registered |
Cannot consume scoped service 'IX' from singleton 'Y' | Captive dependency |
Cannot resolve scoped service 'IX' from root provider | Resolving a scoped service outside a scope |
A circular dependency was detected | A depends on B depends on A |
Unable to activate type 'Y'. Multiple constructors accepting all given argument types | Ambiguous constructors — keep one |
| Silently getting the wrong implementation | Registered twice; the last wins |
The first is the most common, and the message names both the missing service and the class that needed it — so the fix is always one builder.Services.Add... line.
For a circular dependency, the fix is design, not configuration: extract the shared behaviour into a third service that both depend on.
// Resolving manually — from a scope, never the root provider
using var scope = app.Services.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IStudentService>();
GetRequiredService<T> throws with a clear message when the service is missing. GetService<T> returns null, and the resulting NullReferenceException points somewhere unhelpful. Prefer GetRequiredService.
Layering
Controller → IStudentService → IStudentRepository → SQL Server
| Layer | Knows about | Never knows about |
|---|---|---|
| Controller | HTTP, services | SQL, connection strings |
| Service | Business rules, repositories | HTTP, HttpContext |
| Repository | SQL, mapping | Business rules, HTTP |
public interface IStudentService
{
Task<PagedResult<StudentDto>> SearchAsync(
int schoolId, string? term, int page, int pageSize, CancellationToken ct);
Task<Guid> CreateAsync(int schoolId, StudentCreateRequest request, CancellationToken ct);
}
A service should not take HttpContext or return an IActionResult. Both tie business logic to the web layer, so it cannot be reused by a background job or a console tool, and it cannot be tested without a request.
The tenant id is passed as a parameter, resolved from claims by the controller. That keeps the service usable from anywhere and makes the dependency explicit.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
Unable to resolve service for type 'IStudentRepository' | Not registered | builder.Services.AddScoped<...> |
Cannot consume scoped service from singleton | Captive dependency | Match the lifetimes |
A second operation was started on this context | DbContext injected into a singleton | DbContext must be scoped |
Some services are not able to be constructed | A dependency of a dependency is missing | Read the full chain in the message |
| The same instance is reused across requests | Registered as Singleton | Use Scoped for per-request state |
A captive dependency is the subtle one. A singleton holding a scoped service keeps the first instance forever — and in a web application that means one request's data leaking into every later one.
Common mistakes
- Scoped services injected into a singleton
- A hosted service resolving scoped services without a scope
- Non-thread-safe state in a singleton
- Transient used everywhere "to be safe"
- Registering the same interface twice by accident
GetServicewhereGetRequiredServicewas meant- Resolving from the root provider instead of a scope
- Injecting
IServiceProviderand resolving manually — the service locator anti-pattern - No
try/catchinside a background service loop, so it stops silently - A service depending on
HttpContext - Constructors with many dependencies, hiding a class that does too much
Practice
- Build
IStudentRepository→IStudentService→ controller with constructor injection throughout. - Register everything as scoped. Log a
Guidgenerated in each constructor and confirm one instance per request. - Change the repository to transient and confirm several instances per request.
- Change it to singleton and confirm one across all requests.
- Create a singleton cache injecting a scoped repository. Record the startup exception.
- Fix it with
IServiceScopeFactoryand confirm the application starts. - Give the singleton a plain
Dictionaryand hit it concurrently. Then useConcurrentDictionaryand compare. - Write a
BackgroundServiceresolving a scoped service correctly per iteration. - Remove its
try/catch, throw on the second iteration, and confirm the service stops silently. - Register two
INotificationSenderimplementations. InjectT, thenIEnumerable<T>, and compare. - Use keyed services to inject a specific one.
- Forget to register a service and read the exception. Note that it names both the service and the consumer.
- Create a circular dependency and record the error.
- Enable
ValidateOnBuildand confirm a broken graph now fails at startup rather than on first request.
Exercises 5, 7 and 9 correspond to three real production failures.
You can now
- Choose the correct lifetime for any service
- Recognise a captive dependency
- Register interfaces and resolve them by constructor injection
- Read a resolution error and find the missing registration
- Say why
DbContextmust be scoped
Review questions
- Why is scoped the right default for most services?
- What is a captive dependency, and why is it dangerous in a multi-tenant system?
- How does a singleton or a background service use a scoped dependency safely?
- Why should a service not depend on
HttpContext?
Next: Routing and controllers