Skip to main content
Published / updated

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

LifetimeOne instance perUse for
TransientEvery injectionLightweight, stateless services
ScopedHTTP requestThe default for most services — repositories, DbContext, unit of work
SingletonApplicationCaches, 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

ErrorCause
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 providerResolving a scoped service outside a scope
A circular dependency was detectedA depends on B depends on A
Unable to activate type 'Y'. Multiple constructors accepting all given argument typesAmbiguous constructors — keep one
Silently getting the wrong implementationRegistered 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
LayerKnows aboutNever knows about
ControllerHTTP, servicesSQL, connection strings
ServiceBusiness rules, repositoriesHTTP, HttpContext
RepositorySQL, mappingBusiness 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

MessageCauseFix
Unable to resolve service for type 'IStudentRepository'Not registeredbuilder.Services.AddScoped<...>
Cannot consume scoped service from singletonCaptive dependencyMatch the lifetimes
A second operation was started on this contextDbContext injected into a singletonDbContext must be scoped
Some services are not able to be constructedA dependency of a dependency is missingRead the full chain in the message
The same instance is reused across requestsRegistered as SingletonUse 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
  • GetService where GetRequiredService was meant
  • Resolving from the root provider instead of a scope
  • Injecting IServiceProvider and resolving manually — the service locator anti-pattern
  • No try/catch inside 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

  1. Build IStudentRepositoryIStudentService → controller with constructor injection throughout.
  2. Register everything as scoped. Log a Guid generated in each constructor and confirm one instance per request.
  3. Change the repository to transient and confirm several instances per request.
  4. Change it to singleton and confirm one across all requests.
  5. Create a singleton cache injecting a scoped repository. Record the startup exception.
  6. Fix it with IServiceScopeFactory and confirm the application starts.
  7. Give the singleton a plain Dictionary and hit it concurrently. Then use ConcurrentDictionary and compare.
  8. Write a BackgroundService resolving a scoped service correctly per iteration.
  9. Remove its try/catch, throw on the second iteration, and confirm the service stops silently.
  10. Register two INotificationSender implementations. Inject T, then IEnumerable<T>, and compare.
  11. Use keyed services to inject a specific one.
  12. Forget to register a service and read the exception. Note that it names both the service and the consumer.
  13. Create a circular dependency and record the error.
  14. Enable ValidateOnBuild and 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 DbContext must be scoped

Review questions

  1. Why is scoped the right default for most services?
  2. What is a captive dependency, and why is it dangerous in a multi-tenant system?
  3. How does a singleton or a background service use a scoped dependency safely?
  4. Why should a service not depend on HttpContext?

Next: Routing and controllers