Analyzer rule

LC030: Review DbContext Lifetime Mismatches

LC030 flags singletons and hosted services that store a DbContext, a lifetime mismatch that leads to threading errors and stale data. Use a factory.

Default severity
Info
Code fix
Manual only
Category
Architecture

Change the severity in .editorconfig:

[*.cs]
dotnet_diagnostic.LC030.severity = error

Sample code · Analyzer source

LC030: Review DbContext Lifetime Mismatches

In Plain Terms

Imagine you have one paintbrush (DbContext) that everyone in the class has to share at the same time. Paint gets mixed up, bristles break, and everyone makes a mess! Instead, give each person their own paintbrush from a box (IDbContextFactory) when they need one, and put it back when they’re done.

Goal

Flag proven long-lived types that store or directly receive a DbContext, and flag explicit singleton DbContext registrations, so the lifetime can be reviewed.

The Problem

DbContext is NOT thread-safe and is designed to be short-lived (Scoped). Storing it on a long-lived service is risky and can lead to:

  1. Threading Crashes: Two concurrent requests try to use the context at the same time, causing an InvalidOperationException.
  2. Memory Leaks: The Change Tracker keeps every entity ever fetched in memory forever.
  3. Data Corruption: Stale data from previous requests persists in the context.

Example Violation

// Review needed: hosted services are long-lived by default
public sealed class Worker : BackgroundService
{
    private readonly AppDbContext _db;
    public Worker(AppDbContext db) => _db = db;
}

The Fix

Review the service lifetime first. If the type is long-lived, inject IDbContextFactory<T> instead or move the DbContext usage to a scoped component. LC030 intentionally has no code fix because the right correction depends on the service ownership model and registration.

// Correct
public class MyService
{
    private readonly IDbContextFactory<AppDbContext> _factory;
    public MyService(IDbContextFactory<AppDbContext> factory) => _factory = factory;

    public void DoWork()
    {
        using var db = _factory.CreateDbContext();
        // ...
    }
}

Analyzer Logic

ID: LC030

Category: Architecture

Severity: Info

Algorithm

  1. Targets:
    • Fields and stored properties whose type derives from Microsoft.EntityFrameworkCore.DbContext, including static members on proven long-lived types. Computed getter properties are ignored only when the getter directly creates a fresh context with IDbContextFactory<TContext>.CreateDbContext() or new TContext().
    • Constructor parameters whose type derives from DbContext, when the type has no stored DbContext member to report.
    • DI calls that register a DbContext itself as singleton.
  2. Strict long-lived proof:
    • Report when the containing type implements IHostedService, inherits BackgroundService, has a conventional ASP.NET Core middleware Invoke/InvokeAsync(HttpContext, ...) shape, is registered with AddHostedService<T>(), or is registered with AddSingleton(...).
    • For singleton factory registrations, report the concrete implementation type only when the factory directly returns a constructed implementation, such as AddSingleton<IWorker>(_ => new Worker(...)).
    • Report direct AddSingleton<TDbContext>() and AddDbContext<TContext>(..., ServiceLifetime.Singleton) registrations.
    • DI registration matching is limited to Microsoft.Extensions.DependencyInjection methods whose first parameter is IServiceCollection, avoiding project helpers that happen to share names such as AddSingleton.
    • Stay quiet for controllers, page models, view components, IMiddleware, AddScoped, AddTransient, factories, options, and generic service classes with no long-lived proof.
  3. Optional project configuration:
    • dotnet_code_quality.LC030.detection_mode = expanded enables conservative name-based review hints such as *Singleton*, *HostedService, and *BackgroundWorker.
    • dotnet_code_quality.LC030.long_lived_types = MyApp.IAlwaysSingleton, MyApp.LongLivedBase treats matching base types or interfaces as long-lived. Separate the full type names with commas: in .editorconfig and .globalconfig files ; and # start a comment, so anything after them is ignored.

Notes

LC030 is intentionally manual-only. It does not provide a code fix because the correct change may be IDbContextFactory<TContext>, creating a scope through IServiceScopeFactory, changing registration lifetimes, or moving work into a scoped collaborator.

Test Cases

Violations

public sealed class Worker : BackgroundService { private readonly AppDbContext _db; ... }
public sealed class Worker : BackgroundService { private static AppDbContext _db; ... }
public sealed class Worker : BackgroundService { public AppDbContext Db { get; } = new AppDbContext(); ... }
public sealed class Worker : BackgroundService { private AppDbContext Db => _services.GetRequiredService<AppDbContext>(); ... }
public sealed class AuditMiddleware { private readonly AppDbContext _db; public Task InvokeAsync(HttpContext ctx) => Task.CompletedTask; }
services.AddSingleton<IWorker>(_ => new Worker(db)); // factory returns a singleton implementation that stores DbContext
services.AddSingleton<AppDbContext>(); // DbContext registered as singleton
services.AddDbContext<AppDbContext>(contextLifetime: ServiceLifetime.Singleton);

Valid

public class MyController { public MyController(AppDbContext db) { ... } } // Controllers are scoped
public sealed class ScopedAuditMiddleware : IMiddleware { public ScopedAuditMiddleware(AppDbContext db) { ... } } // IMiddleware can be scoped
services.AddScoped<MyService>();
private readonly IDbContextFactory<AppDbContext> _factory;
private AppDbContext Db => _factory.CreateDbContext(); // Fresh context per access
public sealed class PlainType { private static AppDbContext _db; } // No long-lived-type proof