Dependency Injection Anti-Patterns in ASP.NET Core

TL;DR The 5 DI anti-patterns that pass code review: service locator, captive dependencies, over-injection, disposable transients, and lifetime mismatches.

Dependency injection in ASP.NET Core is simple to use. It is also simple to misuse. These are the patterns that compile, pass tests, and then cause production incidents.

Common questions this answers

  • What is the service locator anti-pattern and why does it matter?
  • How do captive dependencies cause state bugs in production?
  • When is injecting IServiceProvider acceptable?
  • How do I detect DI problems before they ship?

Definition (what this means in practice)

DI anti-patterns are dependency injection usages that violate the design principles DI is meant to enforce. They create hidden dependencies, lifetime bugs, and services that are hard to test. The symptoms often appear only under production load or after state accumulates across requests.

In practice, this means reviewing constructor signatures, checking service lifetimes, and enabling build-time validation.

Terms used

  • Service locator: resolving dependencies at runtime via IServiceProvider instead of constructor injection.
  • Captive dependency: a scoped or transient service captured by a singleton, causing it to live longer than intended.
  • Lifetime: how long a service instance lives (Singleton, Scoped, Transient).
  • Constructor over-injection: a class with too many constructor parameters, indicating it has too many responsibilities.

Reader contract

This article is for:

  • Engineers building ASP.NET Core applications.
  • Reviewers checking DI registrations and constructor signatures.

You will leave with:

  • Detection patterns for 5 common DI anti-patterns.
  • ValidateOnBuild configuration to catch problems at startup.
  • A checklist for DI code review.

This is not for:

  • DI beginners (assumes working knowledge of constructor injection).
  • Third-party container configuration (Autofac, etc.).

Quick start (10 minutes)

If you do nothing else, do this:

Verified on: ASP.NET Core (.NET 10).

  1. Confirm ValidateOnBuild + ValidateScopes are enabled in Development (they are by default in modern ASP.NET Core builder patterns unless you override service provider options).
  2. Search your codebase for IServiceProvider injections outside of factory classes.
  3. Check that no singleton injects a scoped service directly.
  4. Review any class with more than 5 constructor parameters.
  5. Check that transient services implementing IDisposable are intentional.
// Program.cs - optional: explicitly configure DI validation in Development
// Note: In Development, these are enabled by default unless you override service provider options.
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = builder.Environment.IsDevelopment();
    options.ValidateOnBuild = builder.Environment.IsDevelopment();
});

Anti-pattern 1: Service locator

The service locator pattern hides dependencies by resolving them at runtime instead of declaring them in constructors. Microsoft's DI guidelines explicitly recommend avoiding it.

The problem

// BAD: Service locator - dependencies are hidden
public class OrderService(IServiceProvider serviceProvider)
{
    public async Task ProcessOrderAsync(Order order)
    {
        // Dependency is hidden - not visible in constructor
        var emailService = serviceProvider.GetRequiredService<IEmailService>();
        await emailService.SendConfirmationAsync(order);
    }
}

Why it matters

  • Dependencies are not visible in the constructor signature.
  • Unit testing requires mocking IServiceProvider instead of the actual dependency.
  • The class can resolve anything, making its actual dependencies unclear.
  • Missing registrations and lifetime issues fail at runtime (often far from where the dependency is actually used).

The fix

// GOOD: Explicit constructor injection
public class OrderService(IEmailService emailService)
{
    public async Task ProcessOrderAsync(Order order)
    {
        await emailService.SendConfirmationAsync(order);
    }
}

Detection

Search for IServiceProvider in constructor parameters. Every occurrence outside of factory classes or middleware is suspect.

# Find potential service locator usages
grep -r "IServiceProvider" --include="*.cs" | grep -v "Factory\|Middleware\|Program.cs"

Anti-pattern 2: Captive dependencies

A captive dependency occurs when a service with a shorter lifetime is injected into a service with a longer lifetime. The shorter-lived service becomes "captive" and lives as long as its container.

The problem

// Registration
builder.Services.AddSingleton<ISingletonService, SingletonService>();
builder.Services.AddScoped<IScopedService, ScopedService>();

// BAD: Scoped service captured by singleton
public class SingletonService(IScopedService scopedService) : ISingletonService
{
    // scopedService is now effectively a singleton
    // It will hold state across all requests
}

Why it matters

Scoped services are designed to have per-request lifetime. When captured by a singleton:

  • DbContext instances are shared across requests (concurrency bugs, stale data).
  • Per-request state bleeds between users.
  • Disposal does not happen when expected.

Microsoft's guidelines state: do not resolve a scoped service from a singleton.

The fix

If a singleton needs scoped functionality, inject IServiceScopeFactory and create explicit scopes:

// GOOD: Create explicit scope when singleton needs scoped service
public class SingletonService(IServiceScopeFactory scopeFactory) : ISingletonService
{
    public async Task DoWorkAsync()
    {
        using var scope = scopeFactory.CreateScope();
        var scopedService = scope.ServiceProvider.GetRequiredService<IScopedService>();
        await scopedService.ProcessAsync();
    }
}

Detection

Enable ValidateScopes in development. With scope validation enabled, the default service provider throws when scoped services are resolved from the root provider and when a singleton tries to consume a scoped service.

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = builder.Environment.IsDevelopment();
});

Anti-pattern 3: Constructor over-injection

When a class has many constructor parameters, it usually means the class has too many responsibilities. This is not strictly a DI problem, but DI makes it visible.

The problem

// BAD: Too many dependencies - this class does too much
public class OrderController(
    IOrderService orderService,
    IInventoryService inventoryService,
    IPaymentService paymentService,
    IShippingService shippingService,
    INotificationService notificationService,
    IAuditService auditService,
    IDiscountService discountService,
    ITaxService taxService) : ControllerBase
{
    // ...
}

Why it matters

  • The class violates the Single Responsibility Principle.
  • Testing requires mocking many dependencies.
  • Changes to any dependency affect this class.
  • The class is hard to understand and maintain.

The fix

Refactor to aggregate services or apply the facade pattern:

// GOOD: Aggregate related operations
public class OrderController(IOrderOrchestrator orderOrchestrator) : ControllerBase
{
    // OrderOrchestrator coordinates the workflow
}

public class OrderOrchestrator(
    IOrderService orderService,
    IPaymentService paymentService,
    IShippingService shippingService) : IOrderOrchestrator
{
    // Focused coordination of order workflow
}

Detection

Review constructor signatures. A common heuristic: more than 4-5 constructor parameters warrants investigation. Some teams enforce this with analyzers.

Anti-pattern 4: Disposable transient services

When a transient service implements IDisposable, the DI container tracks it for disposal. The container doesn't dispose it until the scope is disposed (if resolved from a scope) or until the root container is disposed (typically on app shutdown).

The problem

// Registration
builder.Services.AddTransient<IDataProcessor, DataProcessor>();

// DataProcessor implements IDisposable
public class DataProcessor : IDataProcessor, IDisposable
{
    private readonly Stream _stream = new MemoryStream();

    public void Dispose() => _stream.Dispose();
}

Why it matters

Microsoft's guidelines warn: the container holds references to IDisposable transients until the scope (or container) is disposed. If you resolve many transient disposables within a scope, memory grows until the scope ends.

If you resolve transient IDisposable instances from the root container (for example, app.Services), they won't be disposed until the app shuts down. In high-throughput code paths, this can become a memory leak.

The fix

Options:

  1. Make the service scoped instead of transient (if appropriate).
  2. Use a factory pattern and take ownership of disposal.
  3. Avoid registering IDisposable services as transient unless you're confident they won't be resolved from the root container.
// Option 1: Change to scoped if per-request lifetime is acceptable
builder.Services.AddScoped<IDataProcessor, DataProcessor>();

// Option 2: Factory with explicit disposal ownership
builder.Services.AddSingleton<IDataProcessorFactory, DataProcessorFactory>();

public class Consumer(IDataProcessorFactory factory)
{
    public async Task ProcessAsync()
    {
        using var processor = factory.Create();
        await processor.ProcessAsync();
    } // Caller controls disposal
}

Detection

Search for transient registrations of types that implement IDisposable:

// Review these registrations carefully
builder.Services.AddTransient<IDisposableService, DisposableService>();

Anti-pattern 5: Lifetime mismatches

Beyond captive dependencies, other lifetime mismatches cause subtle bugs.

Common mismatches

Registration Problem
Singleton depends on Scoped Captive dependency (covered above)
Singleton depends on Transient The transient instance is created once and retained; this is only a problem if the dependency was intended to be per-operation or isn't safe to share
Scoped depends on Transient Usually fine, but transient is created per resolution

The transient-in-singleton problem

// Registration
builder.Services.AddSingleton<ISingletonService, SingletonService>();
builder.Services.AddTransient<ITransientHelper, TransientHelper>();

// BAD: Transient is captured and reused
public class SingletonService(ITransientHelper helper) : ISingletonService
{
    // helper is created once and reused for application lifetime
    // This is only a bug if TransientHelper was intended to be per-operation
    // or isn't safe to share (stateful, not thread-safe, holds resources, etc.)
}

The fix

If a singleton needs fresh instances, inject a typed factory (this keeps the service locator usage contained to a factory, which is an explicitly acceptable pattern for resolution):

public interface ITransientHelperFactory
{
    ITransientHelper Create();
}

public sealed class TransientHelperFactory(IServiceProvider services) : ITransientHelperFactory
{
    public ITransientHelper Create() => services.GetRequiredService<ITransientHelper>();
}

public class SingletonService(ITransientHelperFactory helperFactory) : ISingletonService
{
    public void DoWork()
    {
        var helper = helperFactory.Create();
        helper.Process();
    }
}

builder.Services.AddTransient<ITransientHelper, TransientHelper>();
builder.Services.AddSingleton<ITransientHelperFactory, TransientHelperFactory>();

Detection strategies

ValidateOnBuild

Catches missing registrations at application startup instead of at first resolution:

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateOnBuild = builder.Environment.IsDevelopment();
});

In modern ASP.NET Core templates, ValidateOnBuild is enabled by default in Development unless you override service provider options.

Limitation: build-time validation can't catch every runtime resolution path (for example, services pulled via IServiceProvider later, or instances created outside the container).

ValidateScopes

Catches scoped services resolved from the root provider (and scoped services consumed by singletons):

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = builder.Environment.IsDevelopment();
});

In modern ASP.NET Core templates, ValidateScopes is enabled by default in Development unless you override service provider options.

Code review checklist

  • No IServiceProvider in constructors (except factories).
  • No scoped services injected into singletons.
  • Constructor parameters under 5 (investigate if more).
  • Transient + IDisposable registrations are intentional.

Factory pattern: when IServiceProvider is acceptable

IServiceProvider injection is appropriate in factory classes whose purpose is to create instances:

// ACCEPTABLE: Factory explicitly creates instances
public class OrderProcessorFactory(IServiceProvider serviceProvider) : IOrderProcessorFactory
{
    public IOrderProcessor Create(OrderType type)
    {
        return type switch
        {
            OrderType.Standard => serviceProvider.GetRequiredService<StandardOrderProcessor>(),
            OrderType.Express => serviceProvider.GetRequiredService<ExpressOrderProcessor>(),
            _ => throw new ArgumentOutOfRangeException(nameof(type))
        };
    }
}

The factory's purpose is resolution. It is not hiding dependencies; it is providing them.

Copy/paste artifact: DI validation configuration

// Program.cs - add after builder creation
builder.Host.UseDefaultServiceProvider(options =>
{
    // Validate scopes: catch captive dependencies
    options.ValidateScopes = builder.Environment.IsDevelopment();

    // Validate on build: catch missing registrations at startup
    options.ValidateOnBuild = builder.Environment.IsDevelopment();
});

Copy/paste artifact: DI code review checklist

DI Code Review Checklist

1. Service locator
   - [ ] No IServiceProvider in constructors (except factories/middleware)
   - [ ] No GetService/GetRequiredService outside factories

2. Captive dependencies
   - [ ] No scoped services injected into singletons
   - [ ] Singletons use IServiceScopeFactory for scoped access

3. Constructor size
   - [ ] Controllers and services have <= 5 constructor parameters
   - [ ] Large constructors refactored to aggregates/facades

4. Disposable transients
   - [ ] Transient + IDisposable is intentional
   - [ ] High-volume transient disposables use factory pattern

5. Validation enabled
   - [ ] ValidateScopes enabled in development
   - [ ] ValidateOnBuild enabled in development

Common failure modes

  1. DbContext captured by singleton, causing cross-request data leakage.
  2. IServiceProvider used everywhere, hiding actual dependencies.
  3. Memory growth from undisposed transient services.
  4. State bugs from transient services captured by singletons.
  5. Missing registrations discovered in production instead of at startup.

Checklist

  • ValidateScopes and ValidateOnBuild enabled in development.
  • No IServiceProvider injection outside of factories.
  • No scoped services injected into singletons.
  • Constructor parameters reviewed for over-injection.
  • Transient IDisposable registrations are intentional.

FAQ

Is injecting IServiceProvider always wrong?

No. It is appropriate in factory classes and certain middleware scenarios. The anti-pattern is using it to hide dependencies in regular services.

How do I know if I have a captive dependency?

Enable ValidateScopes in development. The runtime will throw an InvalidOperationException if a scoped service is resolved from the root provider (or injected into a singleton).

What is the right number of constructor parameters?

There is no absolute rule. Many teams use 4-5 as a threshold for investigation. More than that usually indicates the class has too many responsibilities.

Should I use a third-party DI container?

The built-in container handles most scenarios. Consider third-party containers only if you need advanced features like interception, property injection, or complex lifetime management not supported by the default container.

How do I unit test a class that uses IServiceProvider?

If the class legitimately needs IServiceProvider (factory pattern), mock IServiceProvider to return your test doubles. If the class should not use IServiceProvider, refactor to constructor injection first.

Does ValidateOnBuild catch everything?

No. It catches missing registrations for services that can be fully resolved at build time. Open generics, factory-based registrations, and lazy resolutions may not be fully validated.

What to do next

Enable ValidateScopes and ValidateOnBuild in your development configuration today. Then search your codebase for IServiceProvider injections outside of factory classes.

For more on building production-quality ASP.NET Core applications, read EF Core Performance Mistakes That Ship to Production.

If you want help reviewing DI patterns in your codebase, reach out via Contact.

References

Author notes

Decisions:

  • Recommend ValidateScopes and ValidateOnBuild in development. Rationale: catches common DI bugs at startup instead of in production.
  • Flag IServiceProvider injection as suspect outside factories. Rationale: Microsoft guidelines explicitly recommend avoiding service locator pattern.
  • Use IServiceScopeFactory in singletons that need scoped services. Rationale: creates proper scope lifetime instead of captive dependency.

Observations:

  • Captive dependency bugs often appear as "stale data" or concurrency exceptions in DbContext.
  • Service locator makes refactoring harder because dependencies are not visible.
  • Over-injected constructors are often symptoms of missing domain boundaries.