Mid-level 12 min · March 06, 2026

ASP.NET Core DI — Captive Dependencies That Exhaust Pools

A Singleton caching a Scoped DbContext exhausted the connection pool in 30 minutes.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.

Follow
Production
production tested
June 01, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
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
✦ Definition~90s read
What is Dependency Injection in ASP.NET Core?

ASP.NET Core's built-in dependency injection (DI) container is the framework's default mechanism for managing object creation and lifetime. It solves the fundamental problem of tight coupling by letting you declare what a class needs (its dependencies) rather than how to build them.

Imagine you run a coffee shop.

You register services in Program.cs—the wiring diagram for your entire application—and the container automatically resolves them when constructing controllers, middleware, or other registered services. This isn't just about testability; it's about controlling resource consumption at scale, because the container enforces lifetime rules (Singleton, Scoped, Transient) that directly impact how long objects hold onto connections, memory, or threads.

The critical failure mode that exhausts pools is the captive dependency: a service with a shorter lifetime (like Scoped) injected into a longer-lived one (like Singleton). When a Singleton holds a reference to a DbContext (Scoped by default), that context never gets disposed until the application shuts down.

Each request creates a new context, but the Singleton keeps the old one alive, leaking connections and eventually starving the pool. The container won't warn you—it just hands you the dependency you asked for. This is why understanding lifetimes isn't academic; it's the difference between a healthy pool and a production incident.

ASP.NET Core's DI is lightweight compared to containers like Autofac or Castle Windsor—it lacks advanced features like property injection, interception, or decorators out of the box. You shouldn't use it when you need those patterns, or when you're building a library that needs to be container-agnostic.

But for the vast majority of web applications, it's sufficient and performant. The key is to treat Program.cs as a deliberate design document, not a dumping ground, and to validate your registrations with tools like Microsoft.Extensions.DependencyInjection.Verification or integration tests that catch captive dependencies before they hit production.

Plain-English First

Imagine you run a coffee shop. Instead of training every barista to grow their own coffee beans, roast them, and grind them fresh each morning, you just have a supplier deliver exactly what they need. The barista doesn't care where the beans came from — they just make coffee. Dependency Injection works the same way: instead of a class creating its own tools (dependencies), someone else hands those tools to it. That 'someone else' is ASP.NET Core's built-in DI container.

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 ASP.NET Core DI Can Starve Your Connection Pool

Dependency injection in ASP.NET Core is a built-in container that manages object lifetimes and wiring. The core mechanic: you register types with a lifetime — Singleton, Scoped, or Transient — and the container resolves them automatically, disposing Scoped and Transient instances when their scope ends. This eliminates manual factory code but introduces a subtle trap: captive dependencies.

A captive dependency occurs when a long-lived service (Singleton) holds a reference to a shorter-lived service (Scoped or Transient). The container resolves the short-lived service once and caches it for the Singleton's lifetime, never releasing it. In practice, this means a DbContext registered as Scoped but injected into a Singleton service lives forever, accumulating connections and exhausting the pool.

Use this pattern in any ASP.NET Core application that requires structured service composition. It matters because a misconfigured lifetime silently degrades throughput: a single captive DbContext can hold a connection open for hours, causing timeouts under load. The rule: never inject a Scoped or Transient service into a Singleton — always verify lifetimes at registration time.

Captive Dependency Trap
A Singleton holding a Scoped DbContext doesn't throw an error — it just slowly starves your connection pool until requests start timing out.
Production Insight
A team injected a Scoped DbContext into a Singleton background service that processed messages. After 10 minutes under load, all 100 pool connections were held by that single service.
Symptom: intermittent 'Timeout expired' exceptions on DbContext creation, even though pool size was 100 and active requests were under 50.
Rule of thumb: if a Singleton holds any service that implements IDisposable, verify it's also registered as Singleton — otherwise, you're leaking resources.
Key Takeaway
Captive dependencies are silent: no error, just gradual performance degradation.
Always match lifetimes: Singleton → Singleton, Scoped → Scoped, Transient → Transient.
Use container validation (ValidateOnBuild) in development to catch mismatches early.
ASP.NET Core DI Service Lifetimes THECODEFORGE.IO ASP.NET Core DI Service Lifetimes Singleton leaks Scoped → captive dependency → connection pool exhaustion Singleton AddSingleton<T> — one instance for entire app lifetime IEmailSender, IConfiguration, IMemoryCache, HttpClient HTTP Request 1 Scoped — Request 1 AddScoped<T> — new instance per request DbContext, UserService, OrderService Transient new each time Transient new each time HTTP Request 2 Scoped — Request 2 (different instance) AddScoped creates fresh instance — disposed at request end fresh DbContext, fresh UserService ⛔ Captive Dependency Anti-Pattern Singleton holds reference to Scoped service public class CachingService(DbContext db) { } // DbContext is Scoped builder.Services.AddSingleton<CachingService>(); // ← leaks DbContext forever! Result: DbContext never disposed → connections held → pool exhausted in ~30 min ✓ Fix: IServiceScopeFactory using var scope = _factory.CreateScope(); var db = scope.ServiceProvider .GetRequiredService<DbContext>(); ✓ Catch at startup: ValidateOnBuild host.UseDefaultServiceProvider(o => { o.ValidateOnBuild = true; o.ValidateScopes = true; }); Rule: Singleton → Singleton only. Scoped → Scoped or Transient. Never inject short-lived into long-lived. THECODEFORGE.IO
thecodeforge.io
ASP.NET Core DI Service Lifetimes
Dependency Injection Aspnet Core

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// ─────────────────────────────────────────────────────────────
// 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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
// 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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// 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

## IServiceScopeFactory: The Correct Pattern for Scoped Dependencies in Singletons

When you need a scoped service (like DbContext) inside a singleton or background service, never inject the scoped service directly — that would make it a captive dependency, effectively turning it into a singleton and causing stale data or concurrency issues. Instead, inject IServiceScopeFactory and create a scope per operation.

```csharp public class DataCleanupService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger<DataCleanupService> _logger;

public DataCleanupService(IServiceScopeFactory scopeFactory, ILogger<DataCleanupService> logger) { _scopeFactory = scopeFactory; _logger = logger; }

protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using (var scope = _scopeFactory.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var staleRecords = await dbContext.Records .Where(r => r.ExpiresAt < DateTime.UtcNow) .ToListAsync(stoppingToken);

dbContext.Records.RemoveRange(staleRecords); await dbContext.SaveChangesAsync(stoppingToken);

_logger.LogInformation("Cleaned {Count} stale records", staleRecords.Count); }

await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } } ```

Key points: - Always dispose the scope after use (the using block ensures this). - Each iteration gets a fresh DbContext — no stale tracking, no concurrency nightmares. - Do not cache the scope or the scoped service across iterations.

## Factory Delegate: Runtime Parameters the Container Can't Know

When you need to pass parameters that are only known at runtime (e.g., tenant ID, connection string, user context), register a factory delegate that takes IServiceProvider and returns your service.

```csharp // Registration services.AddScoped<ITenantService>(sp => { var tenantId = sp.GetRequiredService<ITenantContext>().TenantId; return new TenantService(tenantId, sp.GetRequiredService<ILogger<TenantService>>()); });

// Or as a named factory services.AddSingleton<Func<string, ITenantService>>(sp => tenantId => { var logger = sp.GetRequiredService<ILogger<TenantService>>(); return new TenantService(tenantId, logger); }); ```

When to use: - The service needs a value that changes per request or per operation. - You want to avoid injecting IServiceProvider into your business logic. - The factory itself is registered in DI, so it can resolve other dependencies.

## ActivatorUtilities.CreateInstance: Hybrid Construction for Plugins

When you need to instantiate a class that has some dependencies from DI and some parameters you provide at runtime (e.g., plugin activation, dynamic dispatch), use ActivatorUtilities.CreateInstance.

```csharp public class PluginHost { private readonly IServiceProvider _serviceProvider;

public PluginHost(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; }

public IPlugin ActivatePlugin(Type pluginType, string configData) { // configData is passed directly; other deps come from DI return (IPlugin)ActivatorUtilities.CreateInstance( _serviceProvider, pluginType, new object[] { configData }); } }

// Plugin constructor public class EmailPlugin : IPlugin { public EmailPlugin(string configData, IEmailSender sender, ILogger<EmailPlugin> logger) { // configData from caller, sender and logger from DI } } ```

Rules: - The container resolves all parameters it can; your provided arguments fill the rest. - If a parameter type matches both a provided argument and a registered service, your argument wins. - Great for plugin systems, strategy pattern, or any dynamic instantiation.

## Named/Keyed Services (.NET 8+): Multiple Implementations Without Service Locator

Before .NET 8, you had to use IEnumerable<T> or a service locator to choose between multiple implementations. Keyed services give you clean, compile-time-safe selection.

```csharp // Registration services.AddKeyedSingleton<IPaymentProcessor, StripeProcessor>("Stripe"); services.AddKeyedSingleton<IPaymentProcessor, PayPalProcessor>("PayPal"); services.AddKeyedSingleton<IPaymentProcessor, SquareProcessor>("Square");

// Consumption via [FromKeyedServices] public class CheckoutService { private readonly IPaymentProcessor _processor;

public CheckoutService([FromKeyedServices("Stripe")] IPaymentProcessor processor) { _processor = processor; } }

// Or resolve at runtime with a factory services.AddSingleton<IPaymentProcessorFactory>(sp => processorType => { return sp.GetRequiredKeyedService<IPaymentProcessor>(processorType); }); ```

Benefits: - No manual switch or if-else chains. - The container manages the mapping; your code just declares what it needs. - Works with AddKeyedSingleton, AddKeyedScoped, AddKeyedTransient.

## The Service Locator Anti-Pattern: When It's Actually OK

Injecting IServiceProvider into your service class is almost always a code smell. It hides dependencies, makes testing harder, and can lead to runtime errors that would otherwise be caught at startup.

Red flags: - You're using GetRequiredService inside a method to resolve services on the fly. - Your constructor takes only IServiceProvider. - You're building a "smart" service that decides which implementation to use based on runtime data.

When it's acceptable: - Generic dispatchers: A message router that resolves handlers by message type. - Plugin hosts: Where the set of implementations is dynamic and loaded from assemblies. - Lazy initialization: When a dependency is expensive to create and may not be needed. - Framework infrastructure: The DI container itself, or libraries like IMediator.

```csharp // Acceptable use: generic dispatcher public class CommandDispatcher { private readonly IServiceProvider _serviceProvider;

public CommandDispatcher(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; }

public async Task Dispatch<TCommand>(TCommand command) where TCommand : ICommand { var handler = _serviceProvider.GetRequiredService<ICommandHandler<TCommand>>(); await handler.Handle(command); } } ```

Rule of thumb: If you can replace IServiceProvider with a specific interface (like ICommandHandlerFactory), do it. If the number of possible resolutions is unbounded or dynamic, IServiceProvider is the pragmatic choice.

AdvancedDIPatterns.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// 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.
Production Insight
I've seen teams burn hours debugging 'random' DbContext errors because they injected scoped services into singletons. The fix is always IServiceScopeFactory. For keyed services, they're a lifesaver in multi-tenant apps — just don't overuse them; if you have 50 keys, reconsider your architecture.
Key Takeaway
Use IServiceScopeFactory for scoped services in singletons, factory delegates for runtime parameters, ActivatorUtilities.CreateInstance for hybrid construction, and keyed services (.NET 8+) for multiple implementations. Avoid IServiceProvider injection except in generic dispatchers or plugin hosts.

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// ─────────────────────────────────────────────────────────────
// 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.

Constructor Injection: The Only Pattern You Need — Here's Why

You've seen classes with five or six services injected via constructor. That's fine — don't let anyone tell you it's a code smell. Constructor injection is the simplest and most reliable way to see your dependencies at compile time. The moment you switch to property injection or method injection, you lose that compile-time guarantee. If a constructor parameter is missing, your app fails fast at startup, not at 3 AM when a user triggers a specific code path. ASP.NET Core resolves all constructor dependencies automatically when it activates the class. If it can't resolve one, you get an InvalidOperationException during container resolution, not a null reference later. Always favor constructor injection over any other pattern. Your unit tests will thank you, and your production logs will stop hiding missing dependencies.

OrderService.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge
public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly INotificationService _notifier;
    private readonly ILogger<OrderService> _logger;

    // Constructor injection — dependencies are explicit
    public OrderService(
        IOrderRepository repo,
        INotificationService notifier,
        ILogger<OrderService> logger)
    {
        _repo = repo ?? throw new ArgumentNullException(nameof(repo));
        _notifier = notifier ?? throw new ArgumentNullException(nameof(notifier));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<OrderResult> PlaceOrderAsync(Order order)
    {
        _logger.LogInformation("Placing order {OrderId}", order.Id);
        var saved = await _repo.SaveAsync(order);
        await _notifier.SendConfirmationAsync(order);
        return saved;
    }
}
Output
// Startup fails immediately if IOrderRepository is not registered
Unhandled exception: System.InvalidOperationException: Unable to resolve service for type 'IOrderRepository' while attempting to activate 'OrderService'.
Production Trap:
Never suppress the ArgumentNullException check. A null dependency that slips past DI is a NullReferenceException waiting to happen in production.
Key Takeaway
Constructor injection is fail-fast by design. If the container can't build your object graph at startup, you catch it before any request is processed.

Scope Validation Doesn't Lie — Stop Ignoring It

You deployed to staging and everything worked. Then production starts throwing exceptions about resolved scoped services from singleton consumers. That's because development mode enables scope validation by default, and your staging environment likely didn't have it. ASP.NET Core validates scopes when ValidateOnBuild is enabled. It checks that no scoped service flows into a singleton — a violation called captive dependency. When that happens, the singleton holds the scoped service for its entire lifetime, which means you get stale data, incorrect state, or outright NullReferenceExceptions. Always enable ValidateScopes in your production service collection. Add it to Program.cs with builder.Services.BuildServiceProvider( new ServiceProviderOptions { ValidateScopes = true }). Your singleton will scream at you at startup rather than silently corrupting your application state.

Program.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IShoppingCartService, ShoppingCartService>();
builder.Services.AddSingleton<ISessionManager, SessionManager>();

var app = builder.Build();

// Validate scopes in your production pipeline
var serviceProvider = app.Services;
using (var scope = serviceProvider.CreateScope())
{
    // This will throw if a scoped service is injected into a singleton
    var session = scope.ServiceProvider.GetRequiredService<ISessionManager>();
}

app.Run();
// Output when scoped service is captive in singleton:
// System.InvalidOperationException: Cannot resolve scoped service 'IShoppingCartService'
// from root provider
Output
// Fails at startup, not at runtime
Unhandled exception: System.InvalidOperationException: Cannot resolve scoped service 'IShoppingCartService' from root provider.
Production Trap:
Scope validation is not a development-only feature. Add it to your Program.cs for all environments. The cost of a failing startup is infinitely cheaper than debugging a data corruption bug six weeks post-deploy.
Key Takeaway
Validate scopes at startup. A captive dependency is a ticking time bomb — scope validation defuses it before it reaches users.

ValidateOnBuild and Scope Validation — Stop Captive Dependencies Reaching Production

## The Default: Silent Leaks in Production

By default, ASP.NET Core only validates captive dependencies (Scoped injected into Singleton) in the Development environment. In Production, the same misconfiguration silently leaks — your Singleton holds a Scoped instance forever, breaking per-request isolation.

```csharp // This works in Development (throws at startup), but silently leaks in Production var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<MySingleton>(); builder.Services.AddScoped<IScopedService, ScopedService>();

public class MySingleton { public MySingleton(IScopedService scoped) { } // Captive dependency! } ```

## ValidateOnBuild(): Catch Captive Dependencies at Startup

ValidateOnBuild throws an InvalidOperationException during host.Build() if any service cannot be constructed due to a captive dependency. The exact exception message:

> "Cannot resolve 'MySingleton' from root provider because it requires scoped service 'IScopedService'."

``csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<MySingleton>(); builder.Services.AddScoped<IScopedService, ScopedService>(); builder.Services.Configure<HostOptions>(opts => { opts.ValidateOnBuild = true; }); var host = builder.Build(); // Throws InvalidOperationException ``

## ValidateScopes(): Separate Scope Violations

ValidateScopes catches scope violations at resolution time (e.g., resolving a Scoped service from a Singleton scope). You need both:

  • ValidateOnBuild: catches captive dependencies during service graph construction
  • ValidateScopes: catches runtime scope violations (e.g., resolving Scoped from root scope)

``csharp builder.Services.Configure<HostOptions>(opts => { opts.ValidateScopes = true; opts.ValidateOnBuild = true; }); ``

## Enable Both in All Environments

``csharp var builder = Host.CreateDefaultBuilder(args) .UseDefaultServiceProvider((context, opts) => { opts.ValidateScopes = true; opts.ValidateOnBuild = true; }); ``

Why you should: This catches DI misconfigurations in every environment — Development, Staging, Production. The performance cost is negligible (only at startup/resolution), and the safety gain is enormous.

## Integration Test Pattern: Catch DI Errors in CI

``csharp [Fact] public void BuildHost_ShouldNotThrow() { var host = Program.CreateHostBuilder(Array.Empty<string>()) .UseDefaultServiceProvider(opts => { opts.ValidateScopes = true; opts.ValidateOnBuild = true; }) .Build(); // If this doesn't throw, DI is valid } ``

## Named/Keyed Services (.NET 8+)

Use AddKeyedSingleton, AddKeyedScoped with IKeyedServiceProvider to register multiple implementations of the same interface without factory delegates:

```csharp builder.Services.AddKeyedSingleton<ICache, RedisCache>("redis"); builder.Services.AddKeyedSingleton<ICache, MemoryCache>("memory");

public class CacheManager { public CacheManager(IKeyedServiceProvider keyedProvider) { var redis = keyedProvider.GetRequiredKeyedService<ICache>("redis"); var memory = keyedProvider.GetRequiredKeyedService<ICache>("memory"); } } ```

## Common Captive Dependency: IHttpContextAccessor in Background Service

IHttpContextAccessor is Scoped. Injecting it into a Singleton (e.g., BackgroundService) is a classic captive dependency:

```csharp public class MyBackgroundService : BackgroundService { private readonly IHttpContextAccessor _httpContextAccessor; // Captive!

public MyBackgroundService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; }

protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // _httpContextAccessor.HttpContext is always null here } } ```

Exception: InvalidOperationException: Cannot resolve scoped service 'IHttpContextAccessor' from root provider.

Fix: Use IServiceScopeFactory to create a scope per operation:

```csharp public class MyBackgroundService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory;

public MyBackgroundService(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; }

protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var scope = _scopeFactory.CreateScope(); var httpContextAccessor = scope.ServiceProvider.GetRequiredService<IHttpContextAccessor>(); // Now httpContextAccessor.HttpContext is valid } } ```

Production Insight
I've debugged a production incident where a Singleton holding a Scoped DbContext caused stale data reads for 48 hours before detection. The fix was enabling ValidateOnBuild — it would have thrown at deployment. Always validate DI in CI with integration tests.
Key Takeaway
Enable ValidateOnBuild and ValidateScopes in all environments. Use IServiceScopeFactory for background services. Test DI configuration in CI. For multiple implementations, use keyed services (.NET 8+).
● Production incidentPOST-MORTEMseverity: high

The Singleton That Held an Open Database Connection Forever

Symptom
The 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.
Assumption
The 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 cause
A 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.
Fix
Change 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 guideWhat to check when the container doesn't behave as expected4 entries
Symptom · 01
Application throws InvalidOperationException at startup: 'Cannot resolve scoped service from root provider'
Fix
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.
Symptom · 02
A service is resolved but null unexpectedly
Fix
Check 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.
Symptom · 03
Multiple requests share the same state in a supposedly Scoped service
Fix
Verify 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.
Symptom · 04
Dispose is never called on services that implement IDisposable
Fix
The 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.
★ DI Debug Cheat SheetQuick commands and checks for common DI issues in ASP.NET Core
Startup crash: 'Cannot resolve scoped service from root provider'
Immediate action
Look 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 now
Replace 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 action
Check 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 now
Register the service: builder.Services.AddScoped<IYourInterface, YourImplementation>();
Memory grows over time and GC cannot reclaim+
Immediate action
Suspect 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 now
Apply 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 action
Verify 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 now
Add a startup validation that resolves each keyed service in a try/catch to catch typos at app start.
Service Lifetime Comparison
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

1
DI decouples service creation from usage, making code testable and maintainable.
2
Three lifetimes
Transient (always new), Scoped (per request), Singleton (per app).
3
Captive dependencies
injecting Scoped into Singleton leads to memory and connection leaks.
4
Use IServiceScopeFactory for Scoped services in Singletons.
5
Enable scope validation in development
BuildServiceProvider(validateScopes: true).
6
Depend on interfaces, not concrete classes
swap implementations without touching consumers.
7
WebApplicationFactory provides a testable DI container for integration tests.

Common mistakes to avoid

4 patterns
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What are the three service lifetimes in ASP.NET Core DI, and when would ...
Q02SENIOR
What is a captive dependency in ASP.NET Core DI, and how does it manifes...
Q03SENIOR
Explain how you would test a controller that depends on a Scoped service...
Q04SENIOR
What is the IServiceScopeFactory and when would you need it?
Q01 of 04SENIOR

What are the three service lifetimes in ASP.NET Core DI, and when would you use each?

ANSWER
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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between AddTransient, AddScoped, and AddSingleton?
02
Can I inject a Scoped service into a Singleton?
03
Does ASP.NET Core's DI container automatically dispose services?
04
What is the service locator anti-pattern in ASP.NET Core?
05
How do I register multiple implementations of the same interface in ASP.NET Core?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.

Follow
Verified
production tested
June 01, 2026
last updated
1,554
articles · all by Naren
🔥

That's ASP.NET. Mark it forged?

12 min read · try the examples if you haven't

Previous
Authentication in ASP.NET Core
6 / 14 · ASP.NET
Next
SignalR for Real-time Apps