ASP.NET Core DI — Captive Dependencies That Exhaust Pools
- DI decouples service creation from usage, making code testable and maintainable.
- Three lifetimes: Transient (always new), Scoped (per request), Singleton (per app).
- Captive dependencies: injecting Scoped into Singleton leads to memory and connection leaks.
- Built-in DI container manages service creation and disposal
- Three lifetimes: Transient (new each time), Scoped (per request), Singleton (app lifetime)
- Captive dependency bug: injecting Scoped into Singleton silently breaks state isolation
- ASP.NET Core throws InvalidOperationException in development if it detects captive dependencies
- Always depend on interfaces, not concrete classes — keeps code testable and swappable
DI Debug Cheat Sheet
Startup crash: 'Cannot resolve scoped service from root provider'
Add `builder.Services.BuildServiceProvider(validateScopes: true)` in development to get detailed error messages at startup.Search the codebase for `AddSingleton` and review each one's constructor parameters.Service is always null when injected into a controller
Search Program.cs for the registration call. Ensure the interface and implementation types match exactly.In a middleware or filter, try `app.Services.GetService<T>()` to verify resolution works before the controller pipeline.Memory grows over time and GC cannot reclaim
Use `dotnet-counters` or a memory profiler to see number of instances of your services. Also check EventSource events from `Microsoft-Extensions-DependencyInjection`.Review all Singleton registrations for dependencies that are not themselves Singletons.Keyset services (AddKeyedSingleton) not resolving in .NET 8+
Check registration: `builder.Services.AddKeyedScoped<IProcessor, StripeProcessor>("stripe");` then resolve: `sp.GetRequiredKeyedService<IProcessor>("stripe");`Make sure the attribute `[FromKeyedServices("key")]` is spelled exactly as the registration key.Production Incident
BackgroundService injected IServiceScopeFactory, but inside the service they always resolved the same IOrderRepository instance by storing it in a field. That IOrderRepository was registered as Scoped and depended on Entity Framework's DbContext (also Scoped). The Singleton held a reference to a single Scoped instance, preventing disposal. The DbContext's underlying database connection was never returned to the pool.IServiceScopeFactory.CreateScope(), resolve the Scoped services within that scope, and let the scope dispose automatically. This ensures fresh DbContext instances that get properly returned to the pool.IServiceScopeFactory into Singletons, not the Scoped services themselves.Use ASP.NET Core's built-in development-time validation by calling builder.Services.BuildServiceProvider(validateScopes: true) in development to catch captive dependencies at startup.Production Debug GuideWhat to check when the container doesn't behave as expected
IServiceScopeFactory instead. In development, enable scope validation in Program.cs: builder.Services.BuildServiceProvider(validateScopes: true) to catch this earlier.builder.Services.AddScoped and not AddSingleton. If it's a Singleton accidentally, Scoped services resolve from the root provider once and reuse the same instance. Use the InstanceId trick from the code samples to confirm.AddScoped<IService, Implementation>() without a factory if you need automatic disposal.Dependency Injection (DI) is one of those patterns that sounds academic until the day a tight-coupled codebase bites you in production — a change to one class breaks five others, unit tests require real databases, and swapping a third-party library means rewriting half the service layer. ASP.NET Core bakes DI into its very foundation, making it the cleanest implementation of the pattern in the .NET ecosystem.
The core idea is straightforward: instead of a class creating its own dependencies with new, those dependencies are handed to it from the outside — by the framework's IoC container. This inverts control, breaks tight coupling, and makes every class independently testable. ASP.NET Core's built-in container handles three lifetime scopes — Singleton, Scoped, and Transient — each with distinct behaviour that determines when objects are created and destroyed.
By the end of this guide you will be able to register services in Program.cs, inject them via constructors and primary constructors (C# 12+), choose the right lifetime for each service, implement advanced patterns like keyed services and factory delegates, and write unit tests that mock dependencies cleanly without touching the DI container directly.
Why Tight Coupling Is a Silent Killer (and What DI Does About It)
Before you can appreciate DI, you need to feel the pain it eliminates. Consider an OrderService that sends a confirmation email. The naive approach is to write var emailSender = new right inside the service. It works — until you try to unit test SmtpEmailSender()OrderService and your test runner starts firing real emails. Or until your company switches from SMTP to SendGrid. Or until you need to log emails differently in staging vs production.
The root issue is that OrderService is now responsible for two things: processing orders AND knowing exactly how to construct an email sender. That violates the Single Responsibility Principle and creates a dependency on a concrete implementation rather than an abstraction.
DI fixes this by flipping the script. You define an interface — IEmailSender — that describes what an email sender does without specifying how it does it. Then OrderService declares that it needs something that implements IEmailSender, and the DI container figures out what to hand it at runtime. Your service becomes blissfully ignorant of the plumbing, and you gain the freedom to swap, mock, or extend that plumbing without touching the service at all.
// ───────────────────────────────────────────────────────────── // BAD: Tight coupling — OrderService builds its own dependency // ───────────────────────────────────────────────────────────── public class SmtpEmailSender { public void Send(string recipient, string subject, string body) { // Imagine a real SMTP call here Console.WriteLine($"[SMTP] Sending '{subject}' to {recipient}"); } } public class TightlyCoupledOrderService { // Hard-coded dependency: we own the SmtpEmailSender, full stop. // To test this class without sending real emails? Impossible. private readonly SmtpEmailSender _emailSender = new SmtpEmailSender(); public void PlaceOrder(string customerEmail, string productName) { Console.WriteLine($"Order placed for: {productName}"); _emailSender.Send(customerEmail, "Order Confirmed", $"Thanks for ordering {productName}!"); } } // ───────────────────────────────────────────────────────────── // GOOD: Loose coupling via interface + constructor injection // ───────────────────────────────────────────────────────────── // The contract — defines WHAT, not HOW public interface IEmailSender { void Send(string recipient, string subject, string body); } // Real implementation for production public class SmtpEmailSenderV2 : IEmailSender { public void Send(string recipient, string subject, string body) { Console.WriteLine($"[SMTP] Sending '{subject}' to {recipient}"); } } // Fake implementation for unit tests — zero side effects public class FakeEmailSender : IEmailSender { public void Send(string recipient, string subject, string body) { Console.WriteLine($"[FAKE] Would have sent '{subject}' to {recipient}"); } } // OrderService now depends on the ABSTRACTION, not the concrete class. // The DI container (or a test) will decide what IEmailSender looks like. public class OrderService { private readonly IEmailSender _emailSender; // Constructor injection: the dependency is DECLARED, not created public OrderService(IEmailSender emailSender) { _emailSender = emailSender; } public void PlaceOrder(string customerEmail, string productName) { Console.WriteLine($"Order placed for: {productName}"); // This line is identical whether _emailSender is SMTP, SendGrid or Fake _emailSender.Send(customerEmail, "Order Confirmed", $"Thanks for ordering {productName}!"); } } // ───────────────────────────────────────────────────────────── // Demo — wiring it up manually to illustrate the concept // ───────────────────────────────────────────────────────────── class Program { static void Main() { // In production: pass the real sender var productionService = new OrderService(new SmtpEmailSenderV2()); productionService.PlaceOrder("alice@example.com", "Mechanical Keyboard"); Console.WriteLine(); // In a unit test: pass the fake — no SMTP server needed var testService = new OrderService(new FakeEmailSender()); testService.PlaceOrder("bob@example.com", "USB-C Hub"); } }
[SMTP] Sending 'Order Confirmed' to alice@example.com
Order placed for: USB-C Hub
[FAKE] Would have sent 'Order Confirmed' to bob@example.com
new SomeConcreteService() inside another service, stop — that's a tight coupling alarm going off. Register it with the container and inject the interface instead.new SmtpEmailSender() was a hidden dependency.new for anything that's not a value object, you're probably doing it wrong.Registering Services in ASP.NET Core — Program.cs Is Your Wiring Diagram
In ASP.NET Core, all DI configuration lives in Program.cs (or Startup.cs in older projects). The builder.Services property gives you an IServiceCollection — think of it as a recipe book that tells the container 'when someone asks for X, hand them Y'.
There are three lifetime options when registering a service, and choosing the wrong one is one of the most common sources of subtle bugs in .NET apps:
Transient — a brand new instance every single time. Perfect for lightweight, stateless services like validators or formatters.
Scoped — one instance per HTTP request. This is the sweet spot for most business logic services and database contexts (like Entity Framework's DbContext). Every class that participates in the same request shares the same instance.
Singleton — one instance for the entire lifetime of the application. Use this for expensive-to-create, thread-safe services like configuration helpers, HTTP clients, or in-memory caches.
The container resolves the full dependency chain automatically. If OrderService needs IEmailSender which needs ILogger, you just register all three and the container figures out the construction order.
// Program.cs — the entry point for a modern ASP.NET Core app // This is where you tell the DI container how to build your services. using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; var builder = WebApplication.CreateBuilder(args); // ─── Register your services with the DI container ─────────────── // TRANSIENT: new instance every time IProductValidator is requested. // Good for stateless operations — no shared state to worry about. builder.Services.AddTransient<IProductValidator, ProductValidator>(); // SCOPED: one instance per HTTP request. // All classes within a single request share the SAME OrderService instance. // This is the correct lifetime for DbContext and most business services. builder.Services.AddScoped<IOrderService, OrderService>(); // SINGLETON: one instance for the whole app lifetime. // Every request, every thread gets the same PricingCache object. // Only use this when the class is thread-safe and expensive to create. builder.Services.AddSingleton<IPricingCache, InMemoryPricingCache>(); // Built-in registrations — AddControllers registers MVC services, // which themselves have many internal DI registrations under the hood. builder.Services.AddControllers(); var app = builder.Build(); app.UseHttpsRedirection(); app.MapControllers(); app.Run(); // ─── Service interfaces and implementations ────────────────────── public interface IProductValidator { bool IsValid(string productName, decimal price); } public class ProductValidator : IProductValidator { public bool IsValid(string productName, decimal price) => !string.IsNullOrWhiteSpace(productName) && price > 0; } public interface IOrderService { string CreateOrder(string customerEmail, string productName, decimal price); } public class OrderService : IOrderService { private readonly IProductValidator _validator; private readonly IPricingCache _pricingCache; // The container resolves IProductValidator and IPricingCache automatically. // You never call 'new OrderService(...)' yourself in production code. public OrderService(IProductValidator validator, IPricingCache pricingCache) { _validator = validator; _pricingCache = pricingCache; } public string CreateOrder(string customerEmail, string productName, decimal price) { if (!_validator.IsValid(productName, price)) return "Order rejected: invalid product data."; // Use the cached price if available, otherwise use provided price var finalPrice = _pricingCache.GetPrice(productName) ?? price; return $"Order created for {customerEmail}: {productName} at ${finalPrice:F2}"; } } public interface IPricingCache { decimal? GetPrice(string productName); } public class InMemoryPricingCache : IPricingCache { // Singleton means this dictionary is shared across all requests. // Thread safety matters here — in real code consider ConcurrentDictionary. private readonly Dictionary<string, decimal> _prices = new() { { "Mechanical Keyboard", 129.99m }, { "USB-C Hub", 49.99m } }; public decimal? GetPrice(string productName) => _prices.TryGetValue(productName, out var price) ? price : null; } // ─── Controller that uses OrderService via DI ──────────────────── [ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly IOrderService _orderService; // ASP.NET Core sees this constructor and asks the DI container // to supply an IOrderService. It creates one (Scoped), injects it here, // and disposes it at the end of this HTTP request. public OrdersController(IOrderService orderService) { _orderService = orderService; } [HttpPost] public IActionResult PlaceOrder([FromBody] PlaceOrderRequest request) { var result = _orderService.CreateOrder( request.CustomerEmail, request.ProductName, request.Price ); return Ok(new { message = result }); } } public record PlaceOrderRequest(string CustomerEmail, string ProductName, decimal Price);
// Response:
{
"message": "Order created for alice@example.com: Mechanical Keyboard at $129.99"
}
// POST /api/orders with body: {"customerEmail":"bob@example.com","productName":"","price":49.99}
// Response:
{
"message": "Order rejected: invalid product data."
}
InvalidOperationException at startup if it detects this in development mode. The fix: either bump the Singleton down to Scoped, or use IServiceScopeFactory to manually create a scope when you genuinely need short-lived services inside a long-lived one.Service Lifetimes in Action — Seeing the Difference With Real Output
Reading about lifetimes is one thing. Watching them behave differently in a running app is what makes it truly stick. The best way to see lifetime differences is to inject a service into multiple places within the same request and check whether the instances are the same object or different ones.
A unique ID generated at construction time is a perfect diagnostic tool for this. If two classes receive the same ID, they got the same instance. Different IDs mean different instances.
This also reveals a critical real-world implication: if you use a Transient service that holds some state and you expect state changes in one part of the request to be visible in another, you'll be silently surprised — Transient creates a new instance each time, so that state doesn't travel. Scoped is usually what you actually want for request-level state sharing.
// A self-contained console app that mimics what ASP.NET Core DI does internally. // Run this to SEE the lifetime differences with your own eyes. using Microsoft.Extensions.DependencyInjection; // ─── Service Definitions ───────────────────────────────────────── public interface ITransientCounter { string InstanceId { get; } } public interface IScopedCounter { string InstanceId { get; } } public interface ISingletonCounter { string InstanceId { get; } } // Each service generates a unique short ID when constructed. // If two injections share an ID, they're the same instance. public class TransientCounter : ITransientCounter { public string InstanceId { get; } = Guid.NewGuid().ToString()[..8]; public TransientCounter() => Console.WriteLine($" [Transient CREATED] id={InstanceId}"); } public class ScopedCounter : IScopedCounter { public string InstanceId { get; } = Guid.NewGuid().ToString()[..8]; public ScopedCounter() => Console.WriteLine($" [Scoped CREATED] id={InstanceId}"); } public class SingletonCounter : ISingletonCounter { public string InstanceId { get; } = Guid.NewGuid().ToString()[..8]; public SingletonCounter() => Console.WriteLine($" [Singleton CREATED] id={InstanceId}"); } // ─── A service that depends on all three ───────────────────────── public class ReportService { private readonly ITransientCounter _transient; private readonly IScopedCounter _scoped; private readonly ISingletonCounter _singleton; public ReportService( ITransientCounter transient, IScopedCounter scoped, ISingletonCounter singleton) { _transient = transient; _scoped = scoped; _singleton = singleton; } public void PrintIds(string label) { Console.WriteLine($" {label}:"); Console.WriteLine($" Transient id = {_transient.InstanceId}"); Console.WriteLine($" Scoped id = {_scoped.InstanceId}"); Console.WriteLine($" Singleton id = {_singleton.InstanceId}"); } } // ─── Wiring & Demo ─────────────────────────────────────────────── var services = new ServiceCollection(); services.AddTransient<ITransientCounter, TransientCounter>(); services.AddScoped<IScopedCounter, ScopedCounter>(); services.AddSingleton<ISingletonCounter, SingletonCounter>(); services.AddTransient<ReportService>(); // ReportService itself is Transient for this demo var rootProvider = services.BuildServiceProvider(); Console.WriteLine("=== SIMULATED REQUEST 1 ==="); using (var scope1 = rootProvider.CreateScope()) { // Resolve ReportService TWICE within the same scope (same simulated request) var reportA = scope1.ServiceProvider.GetRequiredService<ReportService>(); var reportB = scope1.ServiceProvider.GetRequiredService<ReportService>(); reportA.PrintIds("ReportA (first resolve in request 1)"); reportB.PrintIds("ReportB (second resolve in request 1)"); Console.WriteLine(); Console.WriteLine(" SAME Scoped instance in request 1? " + (reportA.GetType().GetField("_scoped", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) ?.GetValue(reportA) is IScopedCounter s1 && reportB.GetType().GetField("_scoped", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) ?.GetValue(reportB) is IScopedCounter s2 && s1.InstanceId == s2.InstanceId ? "YES ✓" : "NO ✗")); } Console.WriteLine(); Console.WriteLine("=== SIMULATED REQUEST 2 ==="); using (var scope2 = rootProvider.CreateScope()) { var reportC = scope2.ServiceProvider.GetRequiredService<ReportService>(); reportC.PrintIds("ReportC (first resolve in request 2)"); }
[Singleton CREATED] id=a3f1c920
[Scoped CREATED] id=b7d2e441
[Transient CREATED] id=c9a0f312
[Transient CREATED] id=d1b8e205
ReportA (first resolve in request 1):
Transient id = c9a0f312
Scoped id = b7d2e441
Singleton id = a3f1c920
ReportB (second resolve in request 1):
Transient id = d1b8e205 <-- different! new Transient every time
Scoped id = b7d2e441 <-- same! shared within this request scope
Singleton id = a3f1c920 <-- same! one instance for the whole app
SAME Scoped instance in request 1? YES ✓
=== SIMULATED REQUEST 2 ===
[Scoped CREATED] id=e5c3a817 <-- new Scoped for the new request
[Transient CREATED] id=f2d9b603
ReportC (first resolve in request 2):
Transient id = f2d9b603
Scoped id = e5c3a817 <-- different from request 1's Scoped
Singleton id = a3f1c920 <-- still the same Singleton from request 1
Advanced Patterns — Keyed Services, Factory Injection and IServiceScopeFactory
Once you've got basic DI working, three patterns come up constantly in real production codebases that beginner tutorials rarely cover.
Keyed Services (.NET 8+): Sometimes you have multiple implementations of the same interface and need to pick one by name — for example, a PaymentProcessor for both Stripe and PayPal. Before .NET 8 you needed workarounds like a factory pattern or a dictionary. Now AddKeyedScoped lets you register and resolve by a string or enum key.
Factory Functions: When a service needs runtime data to be constructed (not just other services), you can pass a factory lambda to AddScoped. The lambda receives the IServiceProvider, so you can resolve other dependencies manually while also computing runtime values.
IServiceScopeFactory: This is the correct way to consume Scoped services from a background service or Singleton. You inject the factory, create a scope when you need it, do your work, and dispose the scope. This avoids the captive dependency problem entirely and is the pattern ASP.NET Core's own IHostedService implementations use.
// Demonstrates three advanced DI patterns you'll encounter in real projects. // Designed for .NET 8+ for the Keyed Services feature. using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; // ─── Pattern 1: Keyed Services (.NET 8+) ───────────────────────── public interface IPaymentProcessor { string ProcessPayment(decimal amount); } public class StripePaymentProcessor : IPaymentProcessor { public string ProcessPayment(decimal amount) => $"[Stripe] Charged ${amount:F2} via Stripe API"; } public class PayPalPaymentProcessor : IPaymentProcessor { public string ProcessPayment(decimal amount) => $"[PayPal] Charged ${amount:F2} via PayPal API"; } // ─── Pattern 2: Factory Function Registration ───────────────────── public class DatabaseConnectionService { public string ConnectionString { get; } // This service needs a connection string that comes from config at runtime public DatabaseConnectionService(string connectionString) { ConnectionString = connectionString; Console.WriteLine($" [DbConnection] Created with: {connectionString}"); } } // ─── Pattern 3: IServiceScopeFactory in a Singleton ─────────────── public class OrderCleanupBackgroundService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; // We inject IServiceScopeFactory (which IS a Singleton) — safe! // We do NOT inject IOrderRepository (which is Scoped) directly — that would be a bug. public OrderCleanupBackgroundService(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { Console.WriteLine("\n[BackgroundService] Starting cleanup cycle..."); // Create a scope manually — this mimics what ASP.NET Core // does automatically for each HTTP request. using (var scope = _scopeFactory.CreateScope()) { // Now safely resolve the Scoped service within our controlled scope var repository = scope.ServiceProvider .GetRequiredService<IOrderRepository>(); await repository.DeleteExpiredOrdersAsync(); // When 'using' block exits, scope is disposed, // and so is the Scoped IOrderRepository instance. } await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } } } public interface IOrderRepository { Task DeleteExpiredOrdersAsync(); } public class SqlOrderRepository : IOrderRepository { public async Task DeleteExpiredOrdersAsync() { await Task.Delay(50); // Simulates a DB call Console.WriteLine(" [SqlOrderRepository] Deleted expired orders from database."); } } // ─── Wiring everything up ───────────────────────────────────────── var builder = Host.CreateApplicationBuilder(args); // Keyed registrations — same interface, two keys builder.Services.AddKeyedScoped<IPaymentProcessor, StripePaymentProcessor>("stripe"); builder.Services.AddKeyedScoped<IPaymentProcessor, PayPalPaymentProcessor>("paypal"); // Factory function — lambda provides the runtime connection string builder.Services.AddScoped<DatabaseConnectionService>(serviceProvider => { // In real code, you'd pull this from IConfiguration: // var config = serviceProvider.GetRequiredService<IConfiguration>(); // var connStr = config.GetConnectionString("Default"); var connStr = "Server=prod-db;Database=orders;Trusted_Connection=true"; return new DatabaseConnectionService(connStr); }); // Scoped repository + Singleton background service using scope factory builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>(); builder.Services.AddHostedService<OrderCleanupBackgroundService>(); var host = builder.Build(); // ─── Demo: Resolving keyed services ────────────────────────────── using var demoScope = host.Services.CreateScope(); var sp = demoScope.ServiceProvider; // [FromKeyedServices("stripe")] attribute does this automatically in controllers var stripeProcessor = sp.GetRequiredKeyedService<IPaymentProcessor>("stripe"); var paypalProcessor = sp.GetRequiredKeyedService<IPaymentProcessor>("paypal"); Console.WriteLine(stripeProcessor.ProcessPayment(99.99m)); Console.WriteLine(paypalProcessor.ProcessPayment(49.50m)); var dbService = sp.GetRequiredService<DatabaseConnectionService>(); Console.WriteLine($" Connection string in use: {dbService.ConnectionString}"); // Run the host briefly to see background service fire await host.StartAsync(); await Task.Delay(2000); await host.StopAsync();
[PayPal] Charged $49.50 via PayPal API
[DbConnection] Created with: Server=prod-db;Database=orders;Trusted_Connection=true
Connection string in use: Server=prod-db;Database=orders;Trusted_Connection=true
[BackgroundService] Starting cleanup cycle...
[SqlOrderRepository] Deleted expired orders from database.
[FromKeyedServices("stripe")] attribute — no service locator pattern needed. It keeps your constructor clean and your intent explicit: public CheckoutController([FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor).Testing with Dependency Injection — Mocking, Integration Tests, and Validation
DI's biggest win is testability. When a service declares its dependencies via interfaces in constructor, you can swap real implementations for mocks or stubs in tests. This section covers three testing strategies that every senior engineer uses.
Unit Testing: Use a mocking framework like Moq to inject fake dependencies. Since your service depends only on interfaces, you can verify it calls the right methods with the right parameters without setting up databases or network calls.
Integration Testing with WebApplicationFactory: ASP.NET Core provides WebApplicationFactory<T> that creates an in-memory test server with a configurable DI container. You can replace specific services with test doubles (e.g., fake email sender) while keeping the rest of the real pipeline. This is the recommended approach for testing controllers and middleware.
Container Validation: In development, enable scoped validation by calling builder.Services.BuildServiceProvider(validateScopes: true). This throws at startup if it detects a Singleton with Scoped dependencies — catching captive dependencies before they reach production. For integration tests, you can also validate the container's configuration by resolving each service and verifying no exceptions.
// ───────────────────────────────────────────────────────────── // Unit Test: Mocking dependencies with Moq // ───────────────────────────────────────────────────────────── using Moq; using Xunit; public class OrderServiceTests { [Fact] public void PlaceOrder_CallsEmailSenderWithCorrectParameters() { // Arrange var mockSender = new Mock<IEmailSender>(); var service = new OrderService(mockSender.Object); // Act service.PlaceOrder("alice@example.com", "Keyboard"); // Assert mockSender.Verify(s => s.Send( "alice@example.com", "Order Confirmed", It.Is<string>(body => body.Contains("Keyboard"))), Times.Once); } } // ───────────────────────────────────────────────────────────── // Integration Test: Using WebApplicationFactory to replace services // ───────────────────────────────────────────────────────────── using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; public class OrdersControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>> { private readonly WebApplicationFactory<Program> _factory; public OrdersControllerIntegrationTests(WebApplicationFactory<Program> factory) { _factory = factory.WithWebHostBuilder(builder => { // Override the DI container for testing builder.ConfigureServices(services => { // Remove the real email sender, add a fake one services.RemoveAll<IEmailSender>(); services.AddSingleton<IEmailSender, FakeEmailSender>(); }); }); } [Fact] public async Task PostOrder_ReturnsSuccess_WithFakeEmailSender() { // Arrange var client = _factory.CreateClient(); var request = new { CustomerEmail = "test@test.com", ProductName = "Mouse", Price = 29.99m }; // Act var response = await client.PostAsJsonAsync("/api/orders", request); // Assert response.EnsureSuccessStatusCode(); var content = await response.Content.ReadFromJsonAsync<OrderResponse>(); Assert.Contains("Order created", content.Message); } } // ───────────────────────────────────────────────────────────── // Container Validation: Catch captive dependencies at startup // ───────────────────────────────────────────────────────────── // In Program.cs: // var builder = WebApplication.CreateBuilder(args); // ... service registrations ... // if (builder.Environment.IsDevelopment()) // { // builder.Services.BuildServiceProvider(validateScopes: true); // } // var app = builder.Build();
Test Passed — IEmailSender.Send() was called exactly once with the expected parameters.
// Integration test output:
HTTP 200 Response with message "Order created for test@test.com: $29.99"
// Container validation:
If a captive dependency exists, app throws InvalidOperationException at startup in development.
validateScopes: true in development and CI, and write at least one integration test per endpoint.| Lifetime | Instances Created | Shared Across | Best For | Watch Out For |
|---|---|---|---|---|
| Transient | One per injection request | Nothing — always new | Stateless services, formatters, validators | Memory pressure if the service is heavy to create |
| Scoped | One per HTTP request | All classes within the same request | DbContext, business logic services, unit-of-work | Injecting into Singletons (captive dependency bug) |
| Singleton | One for app lifetime | Every request, every thread | Caches, configuration, HttpClient, thread-safe utilities | Mutable shared state causing race conditions in multi-threaded scenarios |
🎯 Key Takeaways
- DI decouples service creation from usage, making code testable and maintainable.
- Three lifetimes: Transient (always new), Scoped (per request), Singleton (per app).
- Captive dependencies: injecting Scoped into Singleton leads to memory and connection leaks.
- Use IServiceScopeFactory for Scoped services in Singletons.
- Enable scope validation in development:
BuildServiceProvider(validateScopes: true). - Depend on interfaces, not concrete classes — swap implementations without touching consumers.
- WebApplicationFactory provides a testable DI container for integration tests.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat are the three service lifetimes in ASP.NET Core DI, and when would you use each?Mid-levelReveal
- QWhat is a captive dependency in ASP.NET Core DI, and how does it manifest in production?SeniorReveal
- QExplain how you would test a controller that depends on a Scoped service registered in the DI container.SeniorReveal
- QWhat is the IServiceScopeFactory and when would you need it?Mid-levelReveal
Frequently Asked Questions
What is the difference between AddTransient, AddScoped, and AddSingleton?
AddTransient creates a new instance every time the service is requested. AddScoped creates one instance per HTTP request (or per created scope). AddSingleton creates one instance for the entire application lifetime. Choose based on the dependency's lifecycle: transient for stateless lightweight services, scoped for request-scoped state like DbContext, singleton for expensive thread-safe services.
Can I inject a Scoped service into a Singleton?
No, don't. That creates a captive dependency. The Singleton will hold a single Scoped instance forever, which never gets properly disposed and shares state across requests. Instead, inject IServiceScopeFactory and create a new scope when you need the Scoped service.
Does ASP.NET Core's DI container automatically dispose services?
Yes, for services that implement IDisposable or IAsyncDisposable, the container will call Dispose when the scope ends (for Scoped) or when the application shuts down (for Singleton). Transient services that implement IDisposable are disposed when the scope they were created in ends, but if you resolve them from the root container (not a scope), they will not be disposed. Also, if you manually register an instance or use a factory that returns an existing object, the container won't dispose it.
What is the service locator anti-pattern in ASP.NET Core?
The service locator anti-pattern is resolving dependencies directly from the container in application code using HttpContext.RequestServices.GetService<T>() or IServiceProvider.GetService<T> inside business logic. It hides the dependency from the constructor signature, makes testing harder (you'd need to set up the DI container in tests), and can lead to runtime errors if the service is not registered. The better approach is constructor injection or method injection.
How do I register multiple implementations of the same interface in ASP.NET Core?
You can call AddScoped<IService, ImplA>() and AddScoped<IService, ImplB>() multiple times. When you inject IEnumerable<IService>, the container gives you all registered implementations. For choosing a specific implementation by name, use Keyed Services (.NET 8+) with AddKeyedScoped<IService, ImplA>("A") and resolve with [FromKeyedServices("A")].
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.