C# Dependency Injection & Service Lifetimes
C# Dependency Injection & Service Lifetimes
Table of Contents
- What Is Dependency Injection?
- The DI Container
- Service Lifetimes
- Real-World 3-Layer Architecture
- Captive Dependency Problem
- Registration Patterns
- Resolving Services
- Lifetime Decision Guide
- Common Anti-Patterns
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 liveIServiceProvider— 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
IDisposableresources (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,
HttpClientfactories - 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 |