DI021

Non-Thread-Safe Service Shared Across Concurrent Handler Invocations

A documented non-thread-safe service (EF Core `DbContext` and derived contexts, `DbConnection`/`DbCommand`/`DbTransaction`/`DbDataReader` and their interfaces, `IDbContextTransaction`, `HttpContext`) that is created or resolved once and then captured — through a field, a closure over an outer local, or an enclosing method parameter — into a handler that a framework invokes **concurrently**:

Default severity: Warning · Code fix: Yes

Why it matters

This is the deferred form of the captive dependency. The lifetimes can look correct — a scope was even created — but one instance is shared across overlapping invocations and fails at runtime with errors like *"A second operation was started on this context instance before a previous operation completed."* It works in dev (one message at a time) and explodes under production load.

README problem example

public class OrderProcessor : BackgroundService
{
    private readonly AppDbContext _db;                 // resolved once
    private readonly ServiceBusSessionProcessor _processor;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _processor.ProcessMessageAsync += HandleAsync; // invoked concurrently
        await _processor.StartProcessingAsync(stoppingToken);
    }

    private async Task HandleAsync(ProcessSessionMessageEventArgs args)
    {
        _db.Add(args);                 // DI021: one DbContext, N concurrent handlers
        await _db.SaveChangesAsync();  // "A second operation was started on this context"
    }
}

README better pattern

private async Task HandleAsync(ProcessSessionMessageEventArgs args)
{
    await using var scope = _scopeFactory.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Add(args);
    await db.SaveChangesAsync();
}

Yes. Rewrites the handler to resolve the service from a new scope per invocation, plumbs `IServiceScopeFactory` through the constructor when needed, and removes the now-dead captured field.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app concurrent capture warning

    public class Bad_TimerCallbackSharedConnection
    {
        private readonly DbConnection _connection;
        private Timer? _timer;

        public Bad_TimerCallbackSharedConnection(DbConnection connection)
        {
            _connection = connection;
        }

        public void Start()
        {
            _timer = new Timer(Poll, null, 0, 5000);
        }

        private void Poll(object? state)
        {
            // [DI021] '_connection' is shared across concurrent invocations of System.Threading.Timer callbacks
            var command = _connection.CreateCommand();
            command.CommandText = "SELECT 1";
            command.ExecuteNonQuery();
        }
    }

Sample app per-invocation pattern

    public class Good_ConnectionPerInvocation
    {
        private readonly Func<DbConnection> _connectionFactory;
        private Timer? _timer;

        public Good_ConnectionPerInvocation(Func<DbConnection> connectionFactory)
        {
            _connectionFactory = connectionFactory;
        }

        public void Start()
        {
            _timer = new Timer(Poll, null, 0, 5000);
        }

        private void Poll(object? state)
        {
            // Create the non-thread-safe resource per invocation instead of sharing one instance.
            using var connection = _connectionFactory();
            using var command = connection.CreateCommand();
            command.CommandText = "SELECT 1";
            command.ExecuteNonQuery();
        }
    }

Related guides

  • No problem-guide pages point here yet.