Captive Dependencies and Scope Validation

Spot a scoped service trapped inside a singleton, let scope validation catch it at startup, and fix it with IServiceScopeFactory.

Syntaxbuilder.Host.UseDefaultServiceProvider(o => { o.ValidateScopes = true; o.ValidateOnBuild = true; });

A captive dependency is a service held by something that lives longer than it should. The classic case: a singleton takes a scoped service in its constructor. The singleton is built once, so the scoped instance it receives is never replaced; every request after the first uses that one object. With a DbContext that means one change tracker shared by all requests and threads: tracked entities pile up, memory grows, data from one customer is visible to another, and concurrent requests fail with A second operation was started on this context instance.

Why it often works on a laptop

Without validation the provider resolves the scoped dependency from the root scope, which behaves like a singleton. Single-user manual testing never runs two requests at once, so the bug ships and surfaces under load as intermittent concurrency exceptions and stale data.

Scope validation

ServiceProviderOptions has two switches. ValidateScopes makes the provider throw Cannot consume scoped service 'X' from singleton 'Y' when a singleton's graph contains a scoped service, and Cannot resolve scoped service 'X' from root provider when code asks app.Services for a scoped service directly. ValidateOnBuild walks every registration when the provider is built and reports all problems in one AggregateException, so the app refuses to start instead of failing on the first unlucky request.

SettingDefault in DevelopmentDefault elsewhereCatches
ValidateScopestruefalseScoped captured by singleton, scoped from root
ValidateOnBuildtruefalseUnresolvable constructors at startup; captives too, but only when ValidateScopes is also on

The host sets both from IsDevelopment(). Production leaves them off by default: ValidateScopes adds a check on every resolution, and ValidateOnBuild adds start-up time. Many teams turn ValidateOnBuild on in every environment (a one-off cost at startup) or run a test that builds the provider with both switches.

Limits

Validation only sees constructor graphs. It cannot look inside a factory lambda, so AddSingleton(sp => new X(sp.GetRequiredService<ShopDb>())) is invisible to ValidateOnBuild (the scope check still fires when the lambda runs, if validation is on). It also ignores transients captured by singletons, which is legal but means the transient now lives forever, and IOptionsSnapshot<T>, itself scoped, is a common hidden captive.

The fixes

  1. Change the lifetime: if the consumer does not need to be a singleton, make it scoped.
  2. If it must be a singleton (a cache, a hosted service), inject IServiceScopeFactory, create a scope per unit of work, resolve the scoped service inside it, and dispose the scope.
  3. For options, swap IOptionsSnapshot<T> for IOptionsMonitor<T>, which is a singleton.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Find a singleton price-alert job that captured a scoped ShopDb before it shares one change tracker across all requests
  • Make a deployment fail at startup when a new handler's dependency was never registered, instead of 500s on one endpoint
  • Replace IOptionsSnapshot<ShippingOptions> in a singleton quote service with IOptionsMonitor so reloaded fees apply

More examples

Validate everywhere, not only in Development

With <code>ValidateOnBuild</code> on in production, a deployment whose registrations cannot be satisfied crashes during <code>Build()</code>, the readiness probe never passes and the rollout stops with the old pods still serving. That is far better than a pod that starts, reports healthy and returns 500 on the one endpoint whose service is missing. With <code>ValidateScopes</code> off in production, though, the build-time check no longer reports captive dependencies.

Example · csharp
var builder = WebApplication.CreateBuilder(args);

// ValidateOnBuild in every environment catches missing registrations; captive detection
// needs ValidateScopes, so keep both on in Development/Staging and the CI registration test.
// Only ValidateScopes has a per-resolution cost.
builder.Host.UseDefaultServiceProvider((context, options) =>
{
    options.ValidateOnBuild = true;
    options.ValidateScopes = context.HostingEnvironment.IsDevelopment()
                             || context.HostingEnvironment.IsStaging();
});

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<OrderService>();

var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id, OrderService orders) => orders.Describe(id));
app.Run();

public interface IOrderRepository
{
    string Find(int id);
}

public sealed class OrderRepository : IOrderRepository
{
    public string Find(int id) => $"order {id}";
}

public sealed class OrderService(IOrderRepository repository)
{
    public string Describe(int id) => repository.Find(id);
}

Hidden captive: IOptionsSnapshot in a singleton

<code>IOptionsSnapshot&lt;T&gt;</code> is registered as scoped so it can recompute once per request. Injected into a singleton it either throws under scope validation or, in production, freezes the first value forever, so a reloaded fee never takes effect. <code>IOptionsMonitor&lt;T&gt;</code> is the singleton-safe choice and reads <code>CurrentValue</code> on each call.

Example · csharp
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ShippingOptions>(builder.Configuration.GetSection("Shipping"));

// Broken: IOptionsSnapshot<T> is scoped, so this singleton captures it.
// builder.Services.AddSingleton<BrokenShippingQuotes>();

builder.Services.AddSingleton<ShippingQuotes>();
var app = builder.Build();
app.MapGet("/shipping/quote", (decimal weightKg, ShippingQuotes quotes) => quotes.Quote(weightKg));
app.Run();

public sealed class ShippingOptions
{
    public decimal BaseFee { get; set; } = 4.90m;
    public decimal PerKg { get; set; } = 1.20m;
}

public sealed class BrokenShippingQuotes(IOptionsSnapshot<ShippingOptions> options)
{
    public decimal Quote(decimal kg) => options.Value.BaseFee + kg * options.Value.PerKg;
}

// IOptionsMonitor<T> is a singleton and still sees configuration reloads.
public sealed class ShippingQuotes(IOptionsMonitor<ShippingOptions> options)
{
    public decimal Quote(decimal kg)
    {
        var current = options.CurrentValue;
        return current.BaseFee + kg * current.PerKg;
    }
}

A test that validates the real registrations

Per-feature registration methods are small enough to validate in isolation. Running <code>BuildServiceProvider</code> with both switches in a unit test fails CI when someone makes <code>OrderService</code> a singleton or forgets a repository, without booting the web host. An integration test that starts the real app in Development covers the whole graph.

Example · csharp
using Xunit;

public interface IOrderRepository;
public sealed class OrderRepository : IOrderRepository;
public sealed class OrderService(IOrderRepository repository)
{
    public IOrderRepository Repository => repository;
}

public static class OrdersRegistration
{
    public static IServiceCollection AddOrders(this IServiceCollection services)
    {
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<OrderService>();
        return services;
    }
}

public class RegistrationTests
{
    [Fact]
    public void Orders_feature_registrations_are_valid()
    {
        var services = new ServiceCollection();
        services.AddOrders();

        // Throws AggregateException listing every captive or unresolvable service.
        using var provider = services.BuildServiceProvider(new ServiceProviderOptions
        {
            ValidateScopes = true,
            ValidateOnBuild = true
        });

        using var scope = provider.CreateScope();
        Assert.NotNull(scope.ServiceProvider.GetRequiredService<OrderService>());
    }
}

Discussion

  • Be the first to comment on this lesson.