Dependency Injection

Intro

Dependency Injection (DI) is a design pattern where objects receive dependencies from an external source instead of creating them internally, which is a practical form of Inversion of Control (IoC). It matters because it improves testability, keeps components loosely coupled, and makes systems composable as they grow. In modern .NET, DI is not optional architecture flavor: ASP.NET Core uses the built-in container as the default composition root for wiring the application.

How It Works

The container lifecycle is three steps: register, resolve, dispose.

1) Registration (builder.Services.Add*)

You describe what the container can build and the service lifetime.

var builder = WebApplication.CreateBuilder(args);

// Registration
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

The container stores service descriptors (service type, implementation, lifetime). Most services are not instantiated at registration time.

2) Resolution (constructor injection, [FromServices], IServiceProvider)

At runtime, the container builds an object graph and injects dependencies.

public class OrderService(IOrderRepository repo, IClock clock)
{
    public async Task<Order> PlaceOrder(CreateOrderDto dto)
    {
        var order = new Order(dto.CustomerId, clock.UtcNow);
        await repo.SaveAsync(order);
        return order;
    }
}
app.MapGet("/time", ([FromServices] IClock clock) => Results.Ok(clock.UtcNow));

Constructor injection is the default for business logic because dependencies stay explicit. IServiceProvider is acceptable in factories/middleware/scope-bound infrastructure code, but not as a default style for domain/application services.

3) Disposal

The container manages IDisposable and IAsyncDisposable based on lifetime boundaries:

This is why manually disposing injected services in controllers/services is usually wrong.

Service Lifetimes (Mechanics + Usage)

Transient

AddTransient<TService, TImpl>(): new instance every resolution.

Use for:

Mechanism details:

Scoped

AddScoped<TService, TImpl>(): one instance per scope.

Use for:

Mechanism details:

Singleton

AddSingleton<TService, TImpl>(): one instance for app lifetime.

Use for:

Mechanism details:

Lifetime Scope Diagram

flowchart TD
    Root[Root Provider]
    Singleton[Singleton instance]
    ReqA[Request Scope A]
    ReqB[Request Scope B]
    ScopedA[Scoped instance A]
    ScopedB[Scoped instance B]
    TransientA[Transient instance]
    TransientB[Transient instance]
    TransientC[Transient instance]

    Root --> Singleton
    Root --> ReqA
    Root --> ReqB
    ReqA --> ScopedA
    ReqB --> ScopedB
    ReqA --> TransientA
    ReqA --> TransientB
    ReqB --> TransientC

Singleton is rooted once, scoped is request-local, transient is created fresh each resolution.

Captive Dependency (Critical Pitfall)

Captive dependency happens when a long-lived service (usually singleton) captures a shorter-lived service (usually scoped).

Why it is dangerous:

In ASP.NET Core Development, this usually surfaces as InvalidOperationException when scope validation is enabled (ValidateScopes).

Anti-pattern: singleton directly depends on scoped service

public sealed class CacheWarmupService(AppDbContext db) : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // BAD: hosted service is singleton, AppDbContext is scoped
        var count = await db.Orders.CountAsync(cancellationToken);
    }

    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Fix: inject IServiceScopeFactory and resolve scoped inside explicit scope

public sealed class CacheWarmupService(IServiceScopeFactory scopeFactory) : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var count = await db.Orders.CountAsync(cancellationToken);
    }

    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Service Locator Anti-pattern

Service Locator means pulling dependencies from IServiceProvider inside business logic (GetService<T>() / GetRequiredService<T>()) instead of declaring constructor dependencies.

Why it is a problem:

public sealed class CheckoutService(IServiceProvider provider)
{
    public async Task ProcessAsync()
    {
        var repo = provider.GetRequiredService<IOrderRepository>();
        var sender = provider.GetRequiredService<IEmailSender>();
        await repo.SaveChangesAsync();
        await sender.SendAsync("done");
    }
}

Prefer explicit constructor dependencies in application/business services.

When acceptable:

Keyed Services (.NET 8+)

Keyed services support multiple implementations for one abstraction with explicit keys.

builder.Services.AddKeyedScoped<ICache, RedisCache>("redis");
builder.Services.AddKeyedScoped<ICache, MemoryCacheAdapter>("memory");

app.MapGet("/cache/ping", ([FromKeyedServices("redis")] ICache cache) =>
{
    return Results.Ok(new { cache = cache.GetType().Name, status = "ok" });
});

Use this when selection is explicit and stable; avoid turning keys into hidden runtime condition trees in core domain code.

Pitfalls

1) Captive dependency

2) Service Locator anti-pattern

3) Registering DbContext as singleton

4) Circular dependencies (A -> B -> A)

Tradeoffs

Interview Questions

References


Whats next