Scoped DbContext in ASP.NET Core: An antipattern?

💡
It is Microsoft's recommended way, but be careful. It might become nightmare.

Microsoft’s official guidance recommends registering your DbContext with a scoped lifetime in ASP.NET Core. On the surface, this makes sense: a single DbContext instance is tied to the lifetime of an HTTP request, but it's a hidden trap. As with many “default” patterns, what looks convenient can quickly turn into a nightmare if you’re not careful.

👉 References:

What Scoped Services Really Mean

👉 you might skip this section if you are familiar with Scoped services in ASP.NET Core

A scoped service is instantiated once per scope and reused throughout that scope. In ASP.NET Core, each HTTP request creates a new scope. That means any service registered as scoped will be shared across all components handling that request.

Consider this minimal example:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<MyService>();

var app = builder.Build();
app.UseMiddleware<MyCustomMiddleware>();
app.MapGet("/", (MyService myService) => {
    Console.WriteLine($"Minimal api's instance: {myService.GetHashCode()}");
    return "ok";
});
app.Run();

public class MyService { }
public class MyCustomMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        var myService = context.RequestServices.GetRequiredService<MyService>();
        Console.WriteLine($"MyCustomMiddleware's instance {myService.GetHashCode()}");
        await next(context); // Call the next middleware
    }
}
GET /
MyCustomMiddleware's instance 2284283
Minimal api's instance: 2284283

GET /
MyCustomMiddleware's instance 1997173
Minimal api's instance: 1997173
..

Output after refreshing a few times

Both middleware and endpoint share the same MyService instance. That’s the essence of scoped lifetimes.

Why Scoped DbContext Is Dangerous?

When you share a DbContext across multiple services in the same request, you lose isolation. Each service may:

  • Load and track entities
  • Modify state
  • Call SaveChanges

Each of the above can have side-effect on other services. This creates subtle, hard-to-diagnose bugs:

  • Shared mutable state bug - Occurs when multiple components access and modify the same state, leading to unintended side effects.
  • Hidden coupling - One service indirectly affects another due to shared dependencies or global state, violating separation of concerns.
  • State leakage - Internal state of one service "leaks" into another, often via static fields, singletons, or improperly scoped services.
  • Side-effect bug - A service causes unintended changes outside its scope, often due to impure functions or global state.

Nasty examples

public class CustomClaimsTransformer : IClaimsTransformation
{
    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {        
	var users = cache.GetOrCreateAsync("users", () =>
            await dbContext.Users.ToListAsync();)

        var user = users.First(u => u.Name == principal.Name);
        principal = AddClaims(principal);
        return principal;
    }
}

public class UsersService(ApplicationDbContext dbContext)
{
    public async Task EditUser(UserDto dto)
    {
	var userEntity = UserMapper.MapToEntity(dto);
        dbContext.Update(userEntity);	
	await dbContext.SaveChangesAsync();
    }
}

Here, CustomClaimsTransformer loads a user, and UsersService tries to update another instance with the same primary key. Result:

InvalidOperationException: The instance of entity type 'MyApp.Model.User' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked.
, because CustomClaimsTransformer already loaded the user and you cannot attach entity with the same PK to DbContext instance twice.

To make it worse, on a subsequent request, the error disappears because the users are cached. Neither service is “wrong,” but the shared DbContext introduced side effects.


Now consider even more serious bug which saves wrong data:

public class OrderService(ApplicationDbContext dbContext)
{
    public async Task EditOrder(OrderDto orderDto)
    {       
        var order = dbContext.Orders.Find(orderDto.OrderId);
        orderDto.MapTo(order);
        if (Validate(order)) // returns false;
        {
           await dbContext.SaveChanges();
        }
    }
}

public class TrackingService(ApplicationDbContext dbContext)
{
    public async Task InvokeAsync()
    {       
        var resource = dbContext.Resource.First(...);
        resource.LastAccessed = DateTime.Now();
        await dbContext.SaveChanges()
    }
}

If OrderService modifies the entity but skips SaveChanges due to validation failure, TrackingService will still persist those modifications when it calls SaveChanges. The result: invalid data silently saved.

The Takeaway

💡
Scoped DbContext is the default, but in my opinion an antipattern.

Sharing a single DbContext across multiple services in a request pipeline can lead to:

  • Tracking conflicts
  • Accidental persistence of invalid state
  • Performance issue due to large number of tracked entities

These are precisely the kinds of bugs that erode reliability in production systems.

// don't do this
builder.Service.AddDbContext<MyDbContext>(...);

// do this instead
builder.Service.AddDbFactoryContext<MyDbContext>(...);

In short: while AddDbContext() is the default, treating it as the “recommended” way is misleading. For serious applications, especially those with complex pipelines or multiple services touching persistence, IDbContextFactory is the safer, more maintainable choice.