Skip to content
Home C# / .NET ASP.NET Core DI — Captive Dependencies That Exhaust Pools

ASP.NET Core DI — Captive Dependencies That Exhaust Pools

Where developers are forged. · Structured learning · Free forever.
📍 Part of: ASP.NET → Topic 6 of 14
A Singleton caching a Scoped DbContext exhausted the connection pool in 30 minutes.
⚙️ Intermediate — basic C# / .NET knowledge assumed
In this tutorial, you'll learn
A Singleton caching a Scoped DbContext exhausted the connection pool in 30 minutes.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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
🚨 START HERE

DI Debug Cheat Sheet

Quick commands and checks for common DI issues in ASP.NET Core
🟡

Startup crash: 'Cannot resolve scoped service from root provider'

Immediate ActionLook for Singleton that injects a Scoped dependency. Check Startup.cs or Program.cs for any Singleton registered service that has constructor parameters of Scoped types.
Commands
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.
Fix NowReplace the injected Scoped service with `IServiceScopeFactory` and create a scope inside the Singleton's methods.
🟡

Service is always null when injected into a controller

Immediate ActionCheck if the service is registered at all. Run the app and navigate to a test endpoint that uses the service — if 500 'No service for type' occurs, registration is missing.
Commands
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.
Fix NowRegister the service: `builder.Services.AddScoped<IYourInterface, YourImplementation>();`
🟡

Memory grows over time and GC cannot reclaim

Immediate ActionSuspect captive dependency — a Singleton holding a Scoped or Transient instance that never gets disposed.
Commands
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.
Fix NowApply `IServiceScopeFactory` pattern in Singletons. Ensure all Scoped/Transient services are resolved inside a `using var scope = _scopeFactory.CreateScope();` block.
🟡

Keyset services (AddKeyedSingleton) not resolving in .NET 8+

Immediate ActionVerify the key type and value match exactly. Keys are case-sensitive. If using an enum, ensure you register with the enum value and resolve with the same value.
Commands
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.
Fix NowAdd a startup validation that resolves each keyed service in a `try`/`catch` to catch typos at app start.
Production Incident

The Singleton That Held an Open Database Connection Forever

A background service registered as Singleton accidentally captured a Scoped DbContext, causing connection pool exhaustion and silent request failures after a few hours of operation.
SymptomThe application runs fine for the first 30 minutes. Then requests start timing out, the database CPU spikes, and the connection pool throws 'The connection pool has been exhausted' errors. No obvious code change triggered it.
AssumptionThe team assumed since the background service only reads data periodically, using a Singleton would be efficient — no need to create a new service for each execution.
Root causeA Singleton 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.
FixChange the background service to create a new scope inside each execution cycle using 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.
Key Lesson
Scoped services must be resolved and used within the same scope — never cache them in Singletons.Always inject 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 Guide

What to check when the container doesn't behave as expected

Application throws InvalidOperationException at startup: 'Cannot resolve scoped service from root provider'You're injecting a Scoped service (like DbContext) into a Singleton. Use IServiceScopeFactory instead. In development, enable scope validation in Program.cs: builder.Services.BuildServiceProvider(validateScopes: true) to catch this earlier.
A service is resolved but null unexpectedlyCheck if the service is registered. If it's a controller, ensure the controller itself is not registered as a Singleton (controllers are Scoped by default). For custom types, verify the interface-to-implementation registration exists in builder.Services.
Multiple requests share the same state in a supposedly Scoped serviceVerify the service is registered with 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.
Dispose is never called on services that implement IDisposableThe container only disposes services it creates. If you manually register an instance or use a factory that returns an existing object, the container won't dispose it. Use 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 SmtpEmailSender() right inside the service. It works — until you try to unit test 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.

TightCouplingVsDI.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// ─────────────────────────────────────────────────────────────
// 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");
    }
}
▶ Output
Order placed for: Mechanical Keyboard
[SMTP] Sending 'Order Confirmed' to alice@example.com

Order placed for: USB-C Hub
[FAKE] Would have sent 'Order Confirmed' to bob@example.com
💡The Golden Rule of DI:
Always depend on interfaces, not concrete classes. If you find yourself writing new SomeConcreteService() inside another service, stop — that's a tight coupling alarm going off. Register it with the container and inject the interface instead.
📊 Production Insight
In production, a tight coupling bug meant swapping from SMTP to SendGrid required touching 15 files — each new SmtpEmailSender() was a hidden dependency.
The test suite couldn't mock the email sender, so every test ran against a real staging SMTP server, causing flaky results.
Rule: if you type new for anything that's not a value object, you're probably doing it wrong.
🎯 Key Takeaway
Tight coupling makes code hard to test and harder to change.
DI flips the relationship: declare what you need, don't create it.
Depend on abstractions, not concretions — that's the D in SOLID.

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 · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
// 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);
▶ Output
// POST /api/orders with body: {"customerEmail":"alice@example.com","productName":"Mechanical Keyboard","price":200.00}
// 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."
}
⚠ Watch Out: Captive Dependencies
Never inject a Scoped or Transient service into a Singleton. The Singleton lives forever, so it holds onto that Scoped instance forever too — it never gets disposed and you end up sharing state across requests. ASP.NET Core will actually throw an 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.
📊 Production Insight
In production, a common mistake is registering a service as Singleton when it depends on a Scoped service like DbContext.
The app starts fine but requests start failing after a few hours — the Singleton holds the same DbContext instance across requests, causing connection pool exhaustion.
Rule: if a service uses a Scoped dependency, it must be Scoped (or use IServiceScopeFactory).
🎯 Key Takeaway
Transient = new every time, Scoped = per request, Singleton = forever.
The container resolves dependencies automatically — no manual wiring.
Captive dependencies are silent killers: Scoped injected into Singleton never gets disposed.

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.

LifetimeDemoApp.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
// 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)");
}
▶ Output
=== SIMULATED REQUEST 1 ===
[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
🔥Interview Gold:
Interviewers love asking 'What's the difference between Scoped and Transient?' The concise answer: Transient = new instance every injection. Scoped = one instance per HTTP request, shared by all classes within that request. Singleton = one instance, forever. Bonus points if you mention the captive dependency bug that occurs when you inject Scoped into Singleton.
📊 Production Insight
A production issue: a team used Transient for a database service expecting it to be lightweight, but each new instance created a new DbContext — connection pool exhausted in minutes.
Another team used Scoped for a configuration service thinking it would be created per request, but the config was loaded from an external service every single request, adding 200ms latency.
Rule: match the lifetime to the actual lifecycle of the resource — not to convenience.
🎯 Key Takeaway
Use the InstanceId trick to verify lifetimes during development.
Transient = always new, Scoped = per request, Singleton = forever.
Choose lifetime based on the dependency's lifecycle needs, not performance guesses.

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.

AdvancedDIPatterns.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
// 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();
▶ Output
[Stripe] Charged $99.99 via Stripe API
[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.
💡Pro Tip: Use [FromKeyedServices] in Controllers
In .NET 8+, you can resolve a specific keyed implementation directly in a controller constructor or action parameter using the [FromKeyedServices("stripe")] attribute — no service locator pattern needed. It keeps your constructor clean and your intent explicit: public CheckoutController([FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor).
📊 Production Insight
Keyed services can cause runtime errors if the key string doesn't match — the error message only says 'No service for type', not which key was missing.
In production, a typo in a keyed service name caused a null reference when the controller tried to use the processor.
Rule: always register a fallback default implementation or validate keyed services at startup.
🎯 Key Takeaway
Keyed services keep constructor clean when you need multiple implementations.
Factory functions allow runtime parameter injection.
IServiceScopeFactory is the safe way to consume Scoped services from Singletons.

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.

DITestingExample.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// ─────────────────────────────────────────────────────────────
// 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();
▶ Output
// Unit test output:
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.
💡Don't Mock the DI Container
In unit tests, never instantiate the real DI container. Just create the services you need by passing mock dependencies to the constructor. For integration tests, use WebApplicationFactory which manages a real container with optional overrides. Mocking the container itself is an anti-pattern that hides real DI configuration issues.
📊 Production Insight
A team skipped integration tests and only relied on unit tests with mock DbContext. In production, the actual EF Core configuration was missing a required index — the mock never revealed it. The integration test with WebApplicationFactory caught the missing index because it ran real migrations.
Another common failure: container validation disabled in staging, so a captive dependency was discovered only after a production incident.
Rule: always run validateScopes: true in development and CI, and write at least one integration test per endpoint.
🎯 Key Takeaway
DI makes unit testing trivial — mock the interfaces, not the container.
WebApplicationFactory gives you a real container you can customize per test.
Enable scope validation in development to catch captive dependencies early.
🗂 Service Lifetime Comparison
Choose the right lifetime based on lifecycle and state requirements
LifetimeInstances CreatedShared AcrossBest ForWatch Out For
TransientOne per injection requestNothing — always newStateless services, formatters, validatorsMemory pressure if the service is heavy to create
ScopedOne per HTTP requestAll classes within the same requestDbContext, business logic services, unit-of-workInjecting into Singletons (captive dependency bug)
SingletonOne for app lifetimeEvery request, every threadCaches, configuration, HttpClient, thread-safe utilitiesMutable 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

    Captive Dependency: Injecting Scoped service into Singleton
    Symptom

    The application runs fine initially, but after some time the database connection pool is exhausted, or requests get stale data because the Singleton holds a reference to a single Scoped DbContext that is never properly disposed.

    Fix

    Never inject Scoped or Transient services directly into a Singleton. Instead, inject IServiceScopeFactory and create a new scope whenever you need to resolve Scoped services. Alternatively, change the Singleton's lifetime to Scoped if its dependencies are Scoped.

    Service Locator Anti-Pattern: Accessing the container directly via `HttpContext.RequestServices` or `IServiceProvider` in application code
    Symptom

    Code that uses serviceProvider.GetService<T>() inside business logic is hard to test, hides dependencies, and can lead to runtime resolution failures. It also couples your code to the DI container implementation.

    Fix

    Always inject dependencies through constructors or method injection. If you need to resolve services dynamically, use a factory pattern or IObjectFactory that itself is injected. The IServiceProvider should only be used in infrastructure code (like middleware or custom factories).

    Registering a service under multiple interfaces without matching lifetimes
    Symptom

    A class implements two interfaces. Registered as AddScoped<IFoo, Service>() and AddScoped<IBar, Service>(). A class that injects both IFoo and IBar gets two different instances of Service because the container treats them as independent registrations. Shared state is not shared.

    Fix

    Register the concrete service as AddScoped<Service>() and then add forwarders: AddScoped<IFoo>(sp => sp.GetRequiredService<Service>()) and AddScoped<IBar>(sp => sp.GetRequiredService<Service>()). Or use TryAdd with a singleton instance registration.

    Not disposing manually created scopes in background services
    Symptom

    Background services that create IServiceScope but forget to wrap it in using block cause memory leaks and never dispose the scoped services (and their connections). Eventually the app throws OutOfMemoryException.

    Fix

    Always create scopes with using or call .Dispose() in a finally block. For BackgroundService, wrap the scope creation inside the ExecuteAsync loop and dispose after each iteration.

Interview Questions on This Topic

  • QWhat are the three service lifetimes in ASP.NET Core DI, and when would you use each?Mid-levelReveal
    Transient: a new instance every time the service is requested. Use for lightweight, stateless services like validators or DTO mappers. Scoped: one instance per HTTP request (or per created scope). Use for services that share state within a request, like DbContext or business logic services. Singleton: one instance for the entire application lifetime. Use for thread-safe, expensive-to-create services like caches, configuration wrappers, or HttpClient. The key rule is to choose the lifetime that matches the lifecycle of the underlying resource.
  • QWhat is a captive dependency in ASP.NET Core DI, and how does it manifest in production?SeniorReveal
    A captive dependency occurs when a Singleton service holds a direct reference to a Scoped or Transient service. The Singleton lives forever, but the captured service should have been disposed at the end of each request. This leads to memory leaks, stale data, and resource exhaustion (especially database connections). In production, you might see connection pool errors or growing memory. ASP.NET Core throws InvalidOperationException at startup if it detects this in development with validateScopes: true. The fix: inject IServiceScopeFactory into the Singleton and create a new scope for each operation.
  • QExplain how you would test a controller that depends on a Scoped service registered in the DI container.SeniorReveal
    For unit testing, I'd mock the service interface and inject it directly into the controller's constructor. For integration testing, I'd use WebApplicationFactory<Program> and override the DI container in the ConfigureServices callback to replace the real implementation with a test double (e.g., a fake repository). This way the controller runs in a realistic environment with most real infrastructure except the parts I want to control. I can also use BuildServiceProvider(validateScopes: true) in integration tests to verify no captive dependencies exist.
  • QWhat is the IServiceScopeFactory and when would you need it?Mid-levelReveal
    IServiceScopeFactory is a Singleton service that can create new scopes at runtime. You need it when you have a long-lived service (Singleton) that must resolve Scoped services. Instead of injecting the Scoped service directly (which would be a captive dependency), you inject IServiceScopeFactory, create a scope within each operation, resolve the Scoped services from that scope, and dispose the scope after use. The typical use case is IHostedService or BackgroundService that runs periodic tasks that need database access.

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")].

🔥
Naren Founder & Author

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.

← PreviousAuthentication in ASP.NET CoreNext →SignalR for Real-time Apps
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged