03 · Dependency Injection Deep Dive¶
ASP.NET Core has a dependency injection (DI) container built in — IServiceProvider
backed by IServiceCollection. Module 01 used it in passing; this module covers
service lifetimes, registration patterns, and the pitfalls that bite in real apps.
The three lifetimes¶
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ICounter, Counter>(); // one instance, whole app lifetime
builder.Services.AddScoped<IRequestId, RequestId>(); // one instance per HTTP request
builder.Services.AddTransient<IGreeter, Greeter>(); // new instance every time it's resolved
interface ICounter { int Next(); }
class Counter : ICounter
{
private int _value;
public int Next() => Interlocked.Increment(ref _value);
}
interface IRequestId { Guid Id { get; } }
class RequestId : IRequestId
{
public Guid Id { get; } = Guid.NewGuid();
}
interface IGreeter { string Greet(); }
class Greeter : IGreeter
{
private readonly Guid _instanceId = Guid.NewGuid();
public string Greet() => $"Hello from instance {_instanceId}";
}
- Singleton — created once, shared by every consumer for the life of the app.
Must be thread-safe if it holds mutable state (
CounterusesInterlockedto stay safe under concurrent requests). - Scoped — created once per scope. In ASP.NET Core, a scope is created per
HTTP request, so every service resolved while handling one request that asks
for
IRequestIdgets the sameRequestIdinstance. - Transient — a fresh instance every time it's requested, even within the same request if asked for twice.
var app = builder.Build();
app.MapGet("/lifetimes", (ICounter counter, IRequestId reqA, IRequestId reqB, IGreeter a, IGreeter b) =>
{
return Results.Ok(new
{
counter = counter.Next(),
sameRequestId = reqA.Id == reqB.Id, // true — same scope
sameGreeter = ReferenceEquals(a, b), // false — transient
});
});
app.Run();
Constructor injection is the default pattern¶
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IClock _clock;
public OrderService(IOrderRepository repository, IClock clock)
{
_repository = repository;
_clock = clock;
}
public Order Place(string customer, decimal total)
{
var order = new Order(Guid.NewGuid(), customer, total, _clock.UtcNow);
_repository.Save(order);
return order;
}
}
public record Order(Guid Id, string Customer, decimal Total, DateTime PlacedAt);
public interface IOrderRepository { void Save(Order order); }
public interface IClock { DateTime UtcNow { get; } }
Classes depend on interfaces, never on concrete implementations. The container
supplies the implementation at resolution time — nothing in OrderService
mentions SqlOrderRepository or SystemClock directly, so swapping either
for a test double or a different backing store requires no change to
OrderService itself.
builder.Services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<OrderService>();
class InMemoryOrderRepository : IOrderRepository
{
private readonly List<Order> _orders = new();
public void Save(Order order) => _orders.Add(order);
}
class SystemClock : IClock { public DateTime UtcNow => DateTime.UtcNow; }
Registering with a factory¶
Sometimes construction needs logic — reading configuration, choosing an implementation conditionally:
builder.Services.AddSingleton<IEmailSender>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var provider = config["Email:Provider"] ?? "console";
return provider switch
{
"smtp" => new SmtpEmailSender(config["Email:Host"]!),
_ => new ConsoleEmailSender(),
};
});
interface IEmailSender { void Send(string to, string body); }
class ConsoleEmailSender : IEmailSender
{
public void Send(string to, string body) => Console.WriteLine($"[email to {to}]: {body}");
}
class SmtpEmailSender : IEmailSender
{
private readonly string _host;
public SmtpEmailSender(string host) => _host = host;
public void Send(string to, string body) => Console.WriteLine($"[smtp:{_host}] to {to}: {body}");
}
The factory lambda receives the IServiceProvider, so it can pull in other
registered services (IConfiguration here) to decide what to build.
Options pattern for strongly-typed configuration¶
public class SmtpOptions
{
public string Host { get; set; } = "";
public int Port { get; set; } = 25;
}
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
app.MapGet("/smtp-config", (IOptions<SmtpOptions> options) =>
Results.Ok(new { options.Value.Host, options.Value.Port }));
IOptions<T> binds a configuration section to a POCO once and shares it as a
singleton. Use IOptionsSnapshot<T> instead for scoped config that can be
reloaded per request (rarely needed outside long-running config-reload
scenarios), and IOptionsMonitor<T> for live-reload notifications.
The captive dependency trap¶
builder.Services.AddSingleton<ReportCache>(); // singleton
builder.Services.AddScoped<IOrderRepository, InMemoryOrderRepository>(); // scoped
class ReportCache
{
// BUG: capturing a scoped service inside a singleton's constructor
// "captures" it beyond its intended lifetime — the same repository
// instance from whichever request first built ReportCache gets reused
// forever, which is almost never what you want.
public ReportCache(IOrderRepository repository) { }
}
The container throws InvalidOperationException: Cannot consume scoped
service ... from singleton at startup if validation is enabled (it is, by
default, in the Development environment) — that's the framework catching the
bug for you. The fix is to depend on IServiceScopeFactory and create a
scope on demand instead:
class ReportCache
{
private readonly IServiceScopeFactory _scopeFactory;
public ReportCache(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public int CountOrders()
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
return repository is InMemoryOrderRepository r ? r.Count : 0;
}
}
How It Actually Works¶
- The container resolves a dependency graph by walking constructor
parameters via reflection, once per resolution (with caching for the
metadata, not the instances).
GetRequiredService<OrderService>()reflects overOrderService's constructor, sees it needsIOrderRepositoryandIClock, recursively resolves each of those by consulting the registration table (a dictionary from service type to a descriptor holding the implementation type/factory and lifetime), and only then callsnew OrderService(repo, clock)via a compiled expression tree the container builds and caches after the first resolution — subsequent resolutions of the same service type reuse that compiled factory rather than re-reflecting every time, the same "build once, reuse" pattern asSystem.Text.Json's serialization plan. - Lifetimes are implemented as three different storage/lookup strategies
inside the container, not a label. Singleton instances are stored once
in the root
IServiceProvider's own instance cache and handed out for every future request, from any scope. Scoped instances live in a dictionary owned by the currentIServiceScope— ASP.NET Core creates exactly one such scope per incoming HTTP request (wired in during request processing, as Module 01 covered) and disposes it — runningDispose()on everyIDisposablescoped instance it created — when the request finishes. Transient services are never cached anywhere; every resolution runs the constructor-injection factory fresh, which is exactly whyaandbabove are different objects even resolved in the same request. - The captive-dependency check is a real graph traversal run at
app.Build()(in Development) or the first resolution, not a documentation warning. ASP.NET Core's validation walks every registered singleton's constructor dependencies transitively and flags any path that reaches a scoped or transient-holding-scoped registration — this is a genuine static analysis over the DI registration graph, catching the bug before a single request runs, rather than relying on you noticing stale data in production.IServiceScopeFactory.CreateScope()sidesteps the rule legitimately by creating an independent, short-lived scope on demand — its own separate dictionary of scoped instances, disposed when theusingblock ends, exactly like the request-scoped one but created manually instead of by the framework. IOptions<T>binding uses reflection once at startup to map configuration keys to POCO properties (case-insensitively, walking nested sections for nested objects) and caches the bound instance as a singleton —IOptionsSnapshot<T>/IOptionsMonitor<T>differ only in when that binding re-runs (per-scope, or on a file-change/reload notification), not in the underlying reflection-based binding mechanism itself.
Exercise¶
Build a console-hosted DI container (Host.CreateApplicationBuilder(args))
registering an INotifier interface with two implementations
(ConsoleNotifier, LoggingNotifier that wraps another INotifier and
writes to ILogger before/after delegating). Register a service that depends
on INotifier as scoped, resolve it twice within one manually-created scope
(IServiceScopeFactory.CreateScope()), and confirm you get the same
instance both times using ReferenceEquals.