C# Dependency Injection & Service Lifetimes

2025/03/0420 min read
bookmark this

C# Dependency Injection & Service Lifetimes

Table of Contents


What Is Dependency Injection?

  • Dependency Injection (DI) is a design pattern where a class receives its dependencies from external code rather than creating them itself
  • ASP.NET Core has a built-in DI container (IServiceCollection / IServiceProvider) — no third-party library required
  • DI enables: testability (mock dependencies), loose coupling (depend on abstractions), and centralized configuration (one place to wire everything)
// BAD — tightly coupled, creates its own dependency
public class OrderService
{
    private readonly SqlOrderRepository _repo = new(); // hard-coded dependency

    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
        => await _repo.GetByIdAsync(id, ct);
}

// GOOD — dependency is injected via constructor
public class OrderService(IOrderRepository repo)
{
    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
        => await repo.GetByIdAsync(id, ct);
}

The DI Container — IServiceCollection & IServiceProvider

  • IServiceCollection — the registration API; you tell the container what to create and how long it should live
  • IServiceProvider — the resolution API; the container creates instances when they are requested
// Program.cs — registration
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

var app = builder.Build();

// Resolution happens automatically via constructor injection
// The container builds the entire dependency graph

How Resolution Works (Dependency Graph)

Controller requested
├─ IOrderService (Scoped)
│  ├─ IOrderRepository (Scoped)
│  │  └─ AppDbContext (Scoped)
│  └─ ILogger<OrderService> (Singleton)

The container walks the graph, resolves each dependency,
and injects them into the constructors — all automatically.

Service Lifetimes

The three lifetimes determine when the container creates a new instance and when it reuses an existing one:

Lifetime Created Disposed Shared Within
Transient Every time it's requested When scope ends (if IDisposable) Never shared
Scoped Once per scope (HTTP request) When scope ends Same HTTP request
Singleton Once for the app lifetime When app shuts down Entire application

Transient — AddTransient<TService, TImplementation>()

A new instance is created every time the service is requested.

builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

// Every injection point gets its own instance
public class OrderService(IEmailSender email1)      // instance A
public class InvoiceService(IEmailSender email2)    // instance B (different)
  • Use for: lightweight, stateless services with no shared state
  • Use for: services that are cheap to create and hold no resources
  • Avoid for: services with expensive initialization or IDisposable resources (creates many instances)

Scoped — AddScoped<TService, TImplementation>()

A single instance is created per scope (in ASP.NET Core, one scope = one HTTP request).

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<AppDbContext>();

// Same request → same instance
public class OrderService(AppDbContext db)          // instance A
public class InventoryService(AppDbContext db)      // instance A (same request = same DbContext)

// Different request → different instance
// Request 2 gets instance B
  • Use for: EF Core DbContext — must be scoped (one unit of work per request)
  • Use for: services that hold per-request state (current user, tenant context)
  • Use for: repositories and services that depend on DbContext

Singleton — AddSingleton<TService, TImplementation>()

A single instance is created for the entire application lifetime.

builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IConnectionMultiplexer>(
    ConnectionMultiplexer.Connect(connectionString));

// Every request, every scope → same instance
// Created once at first request, lives until app shutdown
  • Use for: caches, configuration, connection pools, HttpClient factories
  • Use for: services with expensive initialization that should happen once
  • Must be thread-safe — multiple threads share the same instance concurrently
  • Never inject a Scoped or Transient service into a Singleton (captive dependency)

Service Lifetime Visualization

App Start ————————————————————————————————— App Shutdown
│                                               │
│  Singleton A ──────────────────────→ Disposed
│                                               │
│  Request 1 Scope ──────┐                     │
│  │  Scoped B ──────────┼─→ Disposed          │
│  │  Transient C → Disposed                   │
│  │  Transient C → Disposed  (new instance)   │
│  └──────────────────────┘                    │
│                                               │
│  Request 2 Scope ──────┐                     │
│  │  Scoped B ──────────┼─→ Disposed (new)    │
│  │  Transient C → Disposed                   │
│  └──────────────────────┘                    │

Real-World 3-Layer API Architecture

A typical .NET API has 3 layers, each with different responsibilities and therefore different lifetime needs. Understanding WHY each layer gets its lifetime is more important than memorizing rules.

The Architecture

┌─────────────────────────────────────────────────────────┐
│                    HTTP Request                         │
└─────────────────────────────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│  API Layer (Controllers / Minimal APIs)                 │
│  • Receives HTTP requests                               │
│  • Validates input (FluentValidation)                   │
│  • Maps DTOs → domain models                            │
│  • Returns HTTP responses                               │
│  • NO business logic here                               │
│                                                         │
│  Lifetime: Transient (controllers are transient by default)
└─────────────────────────────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│  Service Layer (Business Logic)                          │
│  • Orchestrates business rules                           │
│  • Coordinates between repositories and external services │
│  • Applies validation, calculations, transformations     │
│  • May access cache, queues, or other services           │
│                                                          │
│  Lifetime: Scoped (depends on Scoped repos + DbContext)  │
└─────────────────────────────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│  Data Layer (Data Access / External Proxies)             │
│  • Repositories (EF Core, Dapper)                        │
│  • External API proxy services (HttpClient)              │
│  • Cache providers (Redis, in-memory)                    │
│  • Message queue clients                                 │
│                                                          │
│  Lifetime: Depends on what it wraps (see below)          │
└─────────────────────────────────────────────────────────┘

Complete Customer API Example — All 3 Layers

Scenario: Build a Customer API with CRUD operations, caching, and an external credit-check proxy.

Step 1: Define Interfaces and Models

// Domain entity
public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public decimal CreditLimit { get; set; }
    public DateTime CreatedAt { get; set; }
}

// DTOs — immutable records
public record CustomerDto(int Id, string Name, string Email, decimal CreditLimit);
public record CreateCustomerRequest(string Name, string Email);
public record CreditCheckResult(bool Approved, decimal Limit);

Step 2: Data Layer Components

Repository — Scoped
// WHY Scoped: The repository uses DbContext, which MUST be Scoped.
// One DbContext per request = one unit of work per request.
// If Singleton: DbContext would be captive (stale, leaked).
// If Transient: Multiple instances get DIFFERENT DbContexts, breaking the pattern.

public interface ICustomerRepository
{
    Task<Customer?> GetByIdAsync(int id, CancellationToken ct);
    Task<IReadOnlyList<Customer>> GetAllAsync(CancellationToken ct);
    Task AddAsync(Customer customer, CancellationToken ct);
    Task SaveChangesAsync(CancellationToken ct);
}

public class CustomerRepository(AppDbContext db) : ICustomerRepository
{
    public async Task<Customer?> GetByIdAsync(int id, CancellationToken ct)
        => await db.Customers.FindAsync([id], ct);

    public async Task<IReadOnlyList<Customer>> GetAllAsync(CancellationToken ct)
        => await db.Customers.AsNoTracking().ToListAsync(ct);

    public async Task AddAsync(Customer customer, CancellationToken ct)
        => await db.Customers.AddAsync(customer, ct);

    public async Task SaveChangesAsync(CancellationToken ct)
        => await db.SaveChangesAsync(ct);
}
Cache Service — Singleton
// WHY Singleton: Cache is a shared, thread-safe data store.
// Redis connections are expensive; MemoryCache is a single dictionary.
// Every request should hit the SAME cache — that's the whole point.
//
// If Scoped: Each request gets its own empty cache — useless.
// If Transient: Even worse — cache is empty each time.

public interface ICacheService
{
    Task<T?> GetAsync<T>(string key, CancellationToken ct);
    Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct);
    Task RemoveAsync(string key, CancellationToken ct);
}

public class MemoryCacheService(IMemoryCache cache) : ICacheService
{
    public Task<T?> GetAsync<T>(string key, CancellationToken ct)
    {
        cache.TryGetValue(key, out T? value);
        return Task.FromResult(value);
    }

    public Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct)
    {
        cache.Set(key, value, expiry);
        return Task.CompletedTask;
    }

    public Task RemoveAsync(string key, CancellationToken ct)
    {
        cache.Remove(key);
        return Task.CompletedTask;
    }
}
External API Proxy — Transient (via AddHttpClient)
// WHY Transient: Registered with AddHttpClient<T>(), which is Transient by design.
// IHttpClientFactory manages handler pooling and DNS refresh internally.
//
// If Singleton: The HttpClient would be captured, defeating factory's handler rotation.

public interface ICreditCheckProxy
{
    Task<CreditCheckResult> CheckCreditAsync(string email, CancellationToken ct);
}

public class CreditCheckProxy(HttpClient client) : ICreditCheckProxy
{
    public async Task<CreditCheckResult> CheckCreditAsync(string email, CancellationToken ct)
    {
        var response = await client.GetFromJsonAsync<CreditCheckResult>(
            $"/api/credit-check?email={email}", ct);
        return response ?? new CreditCheckResult(false, 0);
    }
}

Step 3: Service Layer

// WHY Scoped: Depends on ICustomerRepository (Scoped).
// If Singleton: Repository would be captive — stale DbContext, memory leak.
// If Transient: Works but wastes resources — inconsistent instances per request.

public interface ICustomerService
{
    Task<CustomerDto?> GetByIdAsync(int id, CancellationToken ct);
    Task<IReadOnlyList<CustomerDto>> GetAllAsync(CancellationToken ct);
    Task<CustomerDto> CreateAsync(CreateCustomerRequest request, CancellationToken ct);
}

public class CustomerService(
    ICustomerRepository repo,
    ICacheService cache,
    ICreditCheckProxy creditCheck,
    ILogger<CustomerService> logger) : ICustomerService
{
    private const string CachePrefix = "customer:";
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);

    public async Task<CustomerDto?> GetByIdAsync(int id, CancellationToken ct)
    {
        // 1. Try cache first (Singleton — shared across all requests)
        string cacheKey = $"{CachePrefix}{id}";
        var cached = await cache.GetAsync<CustomerDto>(cacheKey, ct);
        if (cached is not null)
        {
            logger.LogDebug("Cache hit for customer {CustomerId}", id);
            return cached;
        }

        // 2. Cache miss — query database (Scoped repo → Scoped DbContext)
        logger.LogDebug("Cache miss for customer {CustomerId}", id);
        var customer = await repo.GetByIdAsync(id, ct);
        if (customer is null)
            return null;

        // 3. Map to DTO and cache
        var dto = new CustomerDto(customer.Id, customer.Name, customer.Email, customer.CreditLimit);
        await cache.SetAsync(cacheKey, dto, CacheDuration, ct);
        return dto;
    }

    public async Task<IReadOnlyList<CustomerDto>> GetAllAsync(CancellationToken ct)
    {
        const string allKey = $"{CachePrefix}all";
        var cached = await cache.GetAsync<IReadOnlyList<CustomerDto>>(allKey, ct);
        if (cached is not null)
            return cached;

        var customers = await repo.GetAllAsync(ct);
        var dtos = customers
            .Select(c => new CustomerDto(c.Id, c.Name, c.Email, c.CreditLimit))
            .ToList();

        await cache.SetAsync(allKey, (IReadOnlyList<CustomerDto>)dtos, CacheDuration, ct);
        return dtos;
    }

    public async Task<CustomerDto> CreateAsync(CreateCustomerRequest request, CancellationToken ct)
    {
        // 1. Check credit via external API (Transient HttpClient proxy)
        var creditResult = await creditCheck.CheckCreditAsync(request.Email, ct);
        logger.LogInformation(
            "Credit check for {Email}: Approved={Approved}, Limit={Limit}",
            request.Email, creditResult.Approved, creditResult.Limit);

        // 2. Create domain entity
        var customer = new Customer
        {
            Name = request.Name,
            Email = request.Email,
            CreditLimit = creditResult.Approved ? creditResult.Limit : 0,
            CreatedAt = DateTime.UtcNow
        };

        // 3. Persist (Scoped repo → Scoped DbContext)
        await repo.AddAsync(customer, ct);
        await repo.SaveChangesAsync(ct);

        // 4. Invalidate list cache — the "all" cache is now stale
        await cache.RemoveAsync($"{CachePrefix}all", ct);

        return new CustomerDto(customer.Id, customer.Name, customer.Email, customer.CreditLimit);
    }
}

Step 4: API Layer

// Controllers are Transient by default in ASP.NET Core.
// The framework creates a new controller instance for each HTTP request.
// You don't register them manually; AddControllers() handles it.

[ApiController]
[Route("api/[controller]")]
public class CustomersController(ICustomerService customerService) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<CustomerDto>> GetById(int id, CancellationToken ct)
    {
        var customer = await customerService.GetByIdAsync(id, ct);
        return customer is null ? NotFound() : Ok(customer);
    }

    [HttpGet]
    public async Task<ActionResult<IReadOnlyList<CustomerDto>>> GetAll(CancellationToken ct)
    {
        var customers = await customerService.GetAllAsync(ct);
        return Ok(customers);
    }

    [HttpPost]
    public async Task<ActionResult<CustomerDto>> Create(
        CreateCustomerRequest request, CancellationToken ct)
    {
        var customer = await customerService.CreateAsync(request, ct);
        return CreatedAtAction(nameof(GetById), new { id = customer.Id }, customer);
    }
}

Step 5: Registration in Program.cs

var builder = WebApplication.CreateBuilder(args);

// Infrastructure
builder.Services.AddControllers();
builder.Services.AddMemoryCache();                        // Singleton (built-in)

// Data Layer
builder.Services.AddDbContext<AppDbContext>(options =>     // Scoped (default)
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();  // Scoped

builder.Services.AddSingleton<ICacheService, MemoryCacheService>();     // Singleton

builder.Services.AddHttpClient<ICreditCheckProxy, CreditCheckProxy>(client =>  // Transient
{
    client.BaseAddress = new Uri(builder.Configuration["CreditCheck:BaseUrl"]!);
    client.Timeout = TimeSpan.FromSeconds(10);
});

// Service Layer
builder.Services.AddScoped<ICustomerService, CustomerService>();        // Scoped

// Validation
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;  // catch mismatched lifetimes at startup
});

var app = builder.Build();
app.MapControllers();
app.Run();

Full Dependency Graph for GET /api/customers/42

HTTP Request: GET /api/customers/42
│
├─ CustomersController (Transient — created by framework per request)
│  ├─ ICustomerService → CustomerService (Scoped — once per request)
│  │  ├─ ICustomerRepository → CustomerRepository (Scoped — once per request)
│  │  │  └─ AppDbContext (Scoped — same instance if multiple repos)
│  │  ├─ ICacheService → MemoryCacheService (Singleton — shared forever)
│  │  │  └─ IMemoryCache (Singleton — built-in)
│  │  ├─ ICreditCheckProxy → CreditCheckProxy (Transient via AddHttpClient)
│  │  │  └─ HttpClient (Transient — handler pooled by factory)
│  │  └─ ILogger<CustomerService> (Singleton — logging is shared)
│
Flow:
1. Controller calls customerService.GetByIdAsync(42)
2. Service checks cache (Singleton) → cache miss
3. Service queries repo (Scoped) → repo queries DbContext (Scoped) → SQL
4. Service caches result (Singleton) → next request gets cache hit
5. Controller returns Ok(dto)

Request ends → Scope disposed
   ✓ CustomerService disposed
   ✓ CustomerRepository disposed
   ✓ AppDbContext disposed (connection returned to pool)
   ✓ CreditCheckProxy disposed (handler stays in pool)
   ✗ MemoryCacheService NOT disposed (Singleton — lives on)

Lifetime Decisions Per Layer — Summary

Layer Component Lifetime Why
API Controllers Transient Framework creates per request; stateless
API Validators Transient Stateless, cheap, no Scoped deps
Service Application services Scoped Depends on Scoped repos; one UoW per request
Service Domain services Transient or Scoped Stateless → Transient; uses Scoped → Scoped
Data Repositories (EF Core) Scoped DbContext is Scoped; repo must match
Data DbContext Scoped One unit of work per request
Data Cache (Redis/Memory) Singleton Shared state; must persist across requests
Data External API proxy Transient Created via AddHttpClient<T>; handler pooled
Data Message queue client Singleton Connection is expensive; shared
Cross ILogger<T> Singleton Logging is thread-safe, shared
Cross IOptions<T> Singleton Config loaded once; doesn't change
Cross TimeProvider Singleton Stateless utility

The Captive Dependency Problem

The most dangerous DI mistake: injecting a shorter-lived service into a longer-lived one.

A Scoped service injected into a Singleton becomes effectively a Singleton — it's "captured" and never replaced. This causes: stale data, DbContext reuse across requests, thread-safety bugs, and memory leaks.

// DANGEROUS — Scoped DbContext captured by Singleton
builder.Services.AddSingleton<ICacheWarmer, CacheWarmer>();
builder.Services.AddScoped<AppDbContext>();

public class CacheWarmer(AppDbContext db) // db is created ONCE, used forever
{
    // This DbContext is NEVER disposed, NEVER refreshed
    // It accumulates tracked entities, leaks memory, returns stale data
    // Change tracking breaks, SaveChanges affects wrong entities
}

The Rules

Singleton can inject → Singleton
Scoped    can inject → Singleton, Scoped
Transient can inject → Singleton, Scoped, Transient

Singleton CANNOT inject → Scoped or Transient (captive dependency!)
Scoped    CANNOT inject → Transient (if Transient holds state)

Detection: ValidateScopes

ASP.NET Core enables this in Development automatically, but in Production it should be disabled.

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;  // catches at startup instead of first request
});

So, if you have code like the example below, where a singleton injects a scoped service, it will throw an exception.

public interface IMyScopedService { }
public class MyScopedService : IMyScopedService { }

public interface IMySingletonService { }
public class MySingletonService : IMySingletonService
{
    // Error: Singleton cannot hold a Scoped service!
    public MySingletonService(IMyScopedService scoped) { }
}

Or, if you define services as below, it will cause a circular reference.

public class EmailService(INotificationService notificationService) : IEmailService
{
    public async Task SendAsync(string message)
    {
        await notificationService.NotifyAsync($"Email: {message}");
    }
}
public class NotificationService(IEmailService emailService) : INotificationService
{
    public async Task NotifyAsync(string message)
    {
        await emailService.SendAsync($"Notification: {message}");
    }
}

For both scenarios, if the environment is Development, you will see the following exception:

System.AggregateException: 'Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: DependencyInjectionLearn.IEmailService Lifetime: Scoped ImplementationType: DependencyInjectionLearn.EmailService': A circular dependency was detected for the 

Fix: Use IServiceScopeFactory for Singletons

// CORRECT — Singleton creates a scope when it needs a Scoped service
public class CacheWarmer(IServiceScopeFactory scopeFactory) // Singleton
{
    public async Task WarmCacheAsync(CancellationToken ct)
    {
        using var scope = scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        var products = await db.Products.ToListAsync(ct);
        // db is properly scoped and disposed when the scope ends
    }
}

Registration Patterns

Below are a few ways to register services.

Interface → Implementation (Most Common)

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
// Requesting IOrderRepository → gets SqlOrderRepository

Concrete Type Only

You should almost never use this approach, as it is hard to test and tightly couples your code. Only use it for classes you cannot control.

builder.Services.AddScoped<OrderService>();
// Requesting OrderService → gets OrderService
// No abstraction — harder to test and swap

You would use it as shown below:

public class OrderController(OrderService orderService)
{
    public async Task GetOrder(int id)
    {
        var order = await orderService.GetOrderAsync(id);
    }
}

Factory Registration

builder.Services.AddSingleton<IEmailSender>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var apiKey = config["SendGrid:ApiKey"]
        ?? throw new InvalidOperationException("SendGrid API key not configured");
    return new SendGridEmailSender(apiKey);
});

Use when the constructor needs values not in DI (config values, computed parameters).

Instance Registration

var settings = new AppSettings { MaxRetries = 3, Timeout = TimeSpan.FromSeconds(30) };
builder.Services.AddSingleton(settings);
// Always Singleton — the same instance is used everywhere

Open Generic Registration

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

// The container closes the generic at resolution time:
// IRepository<Order>    → Repository<Order>
// IRepository<Customer> → Repository<Customer>

Extremely useful for generic patterns (repositories, validators, handlers).

Multiple Implementations

builder.Services.AddScoped<INotificationSender, EmailNotificationSender>();
builder.Services.AddScoped<INotificationSender, SmsNotificationSender>();
builder.Services.AddScoped<INotificationSender, PushNotificationSender>();

// Injecting INotificationSender → gets the LAST registered
// Injecting IEnumerable<INotificationSender> → gets ALL three
public class NotificationService(IEnumerable<INotificationSender> senders)
{
    public async Task NotifyAllAsync(string message, CancellationToken ct)
    {
        foreach (var sender in senders)
            await sender.SendAsync(message, ct);
    }
}

Keyed Services (.NET 8+)

builder.Services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
builder.Services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");

public class PaymentService(
    [FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor,
    [FromKeyedServices("paypal")] IPaymentProcessor paypalProcessor)
{
    public async Task ProcessAsync(string provider, Payment payment, CancellationToken ct)
    {
        var processor = provider switch
        {
            "stripe" => stripeProcessor,
            "paypal" => paypalProcessor,
            _ => throw new NotSupportedException($"Provider '{provider}' is not supported")
        };
        await processor.ChargeAsync(payment, ct);
    }
}

TryAdd* — Register Only If Not Already Registered

builder.Services.TryAddScoped<IOrderRepository, SqlOrderRepository>();

// Useful in libraries to provide defaults without overriding app registrations

Decorator / Wrapper Pattern

// Register base implementation
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

// Decorate with caching (requires Scrutor NuGet package)
builder.Services.Decorate<IOrderRepository, CachedOrderRepository>();

public class CachedOrderRepository(
    IOrderRepository inner,
    ICacheService cache) : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
    {
        string key = $"order:{id}";
        var cached = await cache.GetAsync<Order>(key, ct);
        if (cached is not null) return cached;

        var order = await inner.GetByIdAsync(id, ct);
        if (order is not null)
            await cache.SetAsync(key, order, TimeSpan.FromMinutes(5), ct);
        return order;
    }
}

Resolving Services

Constructor Injection (Preferred)

// The standard way — declare dependencies as constructor parameters
public class OrderService(
    IOrderRepository repo,
    ILogger<OrderService> logger,
    IEmailSender emailSender)
{
    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
    {
        logger.LogInformation("Fetching order {OrderId}", id);
        return await repo.GetByIdAsync(id, ct);
    }
}

IServiceProvider / Service Locator (Anti-Pattern)

// BAD — Service Locator hides dependencies, makes testing hard
public class OrderService(IServiceProvider sp)
{
    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
    {
        var repo = sp.GetRequiredService<IOrderRepository>(); // hidden dependency
        return await repo.GetByIdAsync(id, ct);
    }
}

// GOOD — explicit constructor injection
public class OrderService(IOrderRepository repo)
{
    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
        => await repo.GetByIdAsync(id, ct);
}

GetRequiredService<T>() vs GetService<T>()

// GetRequiredService<T>() — throws if not registered (fail fast ✓)
var repo = sp.GetRequiredService<IOrderRepository>();

// GetService<T>() — returns null if not registered (silent failure ✗)
var repo = sp.GetService<IOrderRepository>(); // might be null

Always prefer GetRequiredService<T>() — it fails loudly at startup.

Method Injection with [FromServices]

You might use this pattern if the constructor has too many dependencies, or if you only need a dependency at the method level.

// Minimal API — parameters are resolved from DI automatically
app.MapGet("/orders/{id}", async (
    int id,
    IOrderRepository repo,
    CancellationToken ct) =>
{
    var order = await repo.GetByIdAsync(id, ct);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

// Controller — [FromServices] for specific parameters
[HttpGet("{id}")]
public async Task<ActionResult<Order>> GetById(
    int id,
    [FromServices] IOrderRepository repo,
    CancellationToken ct)
{
    var order = await repo.GetByIdAsync(id, ct);
    return order is null ? NotFound() : Ok(order);
}

Lifetime Decision Guide

Service Type Recommended Lifetime Why
EF Core DbContext Scoped One unit of work per request; change tracking is request-scoped
Repositories Scoped Usually depend on DbContext (Scoped)
Application services Scoped Usually depend on repositories (Scoped)
HttpClient (via IHttpClientFactory) Transient (handler pooled) Factory manages handler lifetime internally
Cache (Redis, in-memory) Singleton Shared state, thread-safe, expensive to create
Configuration / IOptions<T> Singleton Read-only config loaded once
ILogger<T> Singleton Logging infrastructure is shared
Validators Transient or Scoped Stateless, lightweight
Background services Singleton IHostedService runs for the app lifetime
Email / notification senders Transient Stateless, fire-and-forget

IHttpClientFactory — The Right Way to Use HttpClient

Never create HttpClient with new in DI-managed code. HttpClient disposes HttpMessageHandler — but handlers should be reused (DNS, socket exhaustion).

If you use HttpClient by creating a new instance each time, a new handler is generated per instance and disposed immediately. This causes connections and sockets to remain in TIME_WAIT, resulting in slow performance. The recommended way is to use IHttpClientFactory, which reuses a shared handler and sockets, resulting in better performance.

Therefore, always use IHttpClientFactory with AddHttpClient<> and never register HttpClient as a scoped or singleton service.

// BAD — creates and disposes handlers; causes socket exhaustion
public class ApiClient
{
    public async Task<string> GetAsync(string url, CancellationToken ct)
    {
        using var client = new HttpClient(); // socket leak!
        return await client.GetStringAsync(url, ct);
    }
}

// GOOD — use IHttpClientFactory
builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
    client.Timeout = TimeSpan.FromSeconds(30);
});

public class ApiClient(HttpClient client) : IApiClient
{
    public async Task<string> GetAsync(string path, CancellationToken ct)
        => await client.GetStringAsync(path, ct);
}

Common Anti-Patterns

Service Locator

// BAD — hidden dependencies, untestable
public class OrderService(IServiceProvider sp)
{
    var repo = sp.GetRequiredService<IOrderRepository>();
}

// GOOD — use constructor injection
public class OrderService(IOrderRepository repo) { }

Captive Dependency

// BAD — Scoped DbContext in Singleton service → stale state
builder.Services.AddSingleton<ICacheWarmer, CacheWarmer>();
builder.Services.AddScoped<AppDbContext>();

// GOOD — use IServiceScopeFactory
public class CacheWarmer(IServiceScopeFactory scopeFactory) { }

Constructor Over-Injection

// BAD — too many dependencies = SRP violation
public class OrderService(
    IOrderRepository orderRepo,
    ICustomerRepository customerRepo,
    IInventoryService inventoryService,
    IPaymentProcessor paymentProcessor,
    IEmailSender emailSender,
    ISmsSender smsSender,
    IAuditService auditService,
    ILogger<OrderService> logger) { }

// GOOD — split into focused services
public class OrderService(
    IOrderRepository repo,
    IOrderNotifier notifier,
    ILogger<OrderService> logger) { }

public class OrderNotifier(IEmailSender email, ISmsSender sms) : IOrderNotifier { }

Disposing Injected Services

// BAD — container will also dispose this
public class OrderService(AppDbContext db) : IDisposable
{
    public void Dispose() => db.Dispose(); // double dispose!
}

// GOOD — let the container handle disposal
public class OrderService(AppDbContext db)
{
    // Don't dispose db — DI container does it at scope end
}

Testing with DI

Unit Testing with Mocks

[Fact]
public async Task GetByIdAsync_OrderExists_ReturnsOrder()
{
    // Arrange — create mocks instead of real implementations
    var expected = new Order { Id = 1, Total = 99.99m };
    var mockRepo = new Mock<IOrderRepository>();
    mockRepo
        .Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
        .ReturnsAsync(expected);

    var service = new OrderService(mockRepo.Object, Mock.Of<ILogger<OrderService>>());

    // Act
    var result = await service.GetByIdAsync(1, CancellationToken.None);

    // Assert
    result.Should().NotBeNull();
    result!.Id.Should().Be(1);
}

Integration Testing with WebApplicationFactory

public class OrderApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task GetById_ReturnsOrder()
    {
        // Use the real DI container with test overrides
        var client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                services.RemoveAll<AppDbContext>();
                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase("TestDb"));
            });
        }).CreateClient();

        var response = await client.GetAsync("/api/orders/1");
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

Summary — DI Checklist

Topic Key Takeaway
Registration AddTransient / AddScoped / AddSingleton in Program.cs
Resolution Constructor injection (preferred), never Service Locator
Transient New instance every time; stateless, cheap services
Scoped One per HTTP request; DbContext, repositories, services
Singleton One for the app; caches, config, connection pools
Captive dependency Never inject Scoped/Transient into Singleton
IServiceScopeFactory Safe way for Singletons to use Scoped services
Open generics One registration for all types: typeof(IRepo<>), typeof(Repo<>)
Keyed services .NET 8 — resolve by key with [FromKeyedServices]
ValidateOnBuild Catches missing registrations at startup
IHttpClientFactory Always use for HttpClient — avoids socket exhaustion
Testing Mock interfaces; use WebApplicationFactory for integration