Why Your ASP.NET Core App is Falling and How to Fix It?

In the modern .NET ecosystem, Dependency Injection (DI) is not an optional luxury; it is the fundamental design pattern that powers ASP.NET Core. It’s the “magic” that allows us to build loosely coupled, testable, and maintainable applications. The Program.cs file is, at its heart, a setup script telling the DI container what to do.

But this magic comes with a critical set of rules.

While the framework makes it incredibly simple to register a service, choosing the wrong service lifetime is one of the most common, costly, and difficult-to-debug mistakes a .NET developer can make. A single incorrect registration can lead to memory leaks, unpredictable behavior, data corruption, and catastrophic concurrency crashes.

This article, aimed at developers working with .NET, dives deep into the most dangerous DI scope mistakes, why they happen, and how you can avoid them to build robust, production-ready applications.

The Foundation: A 60-Second Refresher on the Three DI Scopes

Before we dive into the pitfalls, let’s quickly redefine the three service lifetimes available in ASP.NET Core. Every mistake stems from misunderstanding these three Add methods.

1. AddSingleton()

  • What it is: The DI container creates one single instance of this service when it’s first requested. Every subsequent request for this service, from any user, across any part of the application, will receive that exact same instance for the entire lifetime of the application.
  • When to use it: For services that are thread-safe and stateless.
  • Examples: Logging (e.g., ILogger), configuration (IConfiguration), HttpClientFactory, or a truly in-memory application-wide cache.

2. AddScoped()

  • What it is: The DI container creates one instance per request. In an ASP.NET Core web app, a “request” is the entire lifecycle of an HTTP request.
  • This is the default and most common lifetime for web applications.
  • When to use it: For services that need to maintain state within a single HTTP request, or for services that are not thread-safe.
  • Examples: DbContext from Entity Framework Core (this is the big one!), a “Unit of Work” pattern, or a request-level cache.

3. AddTransient()

  • What it is: The DI container creates a brand new instance every single time the service is requested. If three different services in a single request all depend on a Transient service, the container will create three separate instances.
  • When to use it: For lightweight, stateless services where a new instance is cheap to create.
  • Examples: Mappers (like AutoMapper), small calculation services, or services that are not thread-safe and must be unique for every consumer.

The Golden Rule of Scopes

If you remember nothing else, remember this:

A service cannot depend on another service with a shorter lifetime.

This means:

  • Singleton can only depend on Singleton.
  • Scoped can depend on Scoped and Singleton.
  • Transient can depend on Transient, Scoped, and Singleton.

A violation of this rule creates a Captive Dependency, the most dangerous pitfall of all.

Pitfall #1: The “Captive Dependency” (The Time Bomb)

This is the number one, most critical mistake. A Captive Dependency occurs when you inject a shorter-lived service (like a Scoped DbContext) into a longer-lived service (like a Singleton MyService).

The Problem: The Singleton service is created once and lives forever. When it’s created, the DI container injects its dependencies. If one of those dependencies is MyScopedService, that instance is created and… it’s now trapped. It will be “captured” by the Singleton and will live for the entire application lifetime, effectively becoming a Singleton itself.

This is a stateful, “dirty” singleton in disguise, and it will break your application.

Example: The “Dirty” Cache

Imagine you create a service to cache user data, and you register it as a Singleton.

// WRONG: A Singleton service
public class MySingletonCache
{
    private readonly MyDbContext _context;

    // This DbContext is Scoped, but it's being injected into a Singleton.
    // It will be created ONCE and live forever.
    public MySingletonCache(MyDbContext context)
    {
        _context = context; 
    }

    public async Task<User> GetUser(int id)
    { 
        // This will eventually fail with concurrency exceptions or return stale data.
        return await _context.Users.FindAsync(id); 
    }
}

Registration in Program.cs:

builder.Services.AddDbContext<MyDbContext>(...); // DbContext is Scoped
builder.Services.AddSingleton<MySingletonCache>(); // Our service is Singleton

The Symptoms:

  1. Stale Data: User A requests their data. The MySingletonCache is created, and a new MyDbContext is injected into it. The user’s data is fetched and cached inside the DbContext.
  2. User B requests their data. They get the exact same MyDbContext instance.
  3. Even worse, if User A changes their name, this DbContext instance won’t know about it unless it’s manually refreshed. It will keep returning the old, stale data.
  4. Concurrency Crashes: DbContext is not thread-safe. When User B and User C make requests at the same time, they will both try to use the same DbContext instance on different threads, leading to the dreaded System.InvalidOperationException: A second operation started on this context before a previous operation completed.

Pitfall #2: Using Scoped Services in Background Services (IHostedService)

This is the most common variant of the Captive Dependency. A BackgroundService (or any IHostedService) is always registered as a Singleton.

It’s extremely common to want to do database work in a background service (e.g., “clean up old records every night”).

The WRONG Way:

// WRONG: Injecting a Scoped DbContext into a Singleton Hosted Service
public class MyCleanupService : BackgroundService
{
    private readonly MyDbContext _context;

    // This code will CRASH on startup (if scope validation is on)
    public MyCleanupService(MyDbContext context)
    {
        _context = context;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // This DbContext will be captured and live forever,
            // becoming a massive memory leak and concurrency nightmare.
            var oldRecords = await _context.Logs
                .Where(l => l.Timestamp < DateTime.UtcNow.AddDays(-30))
                .ToListAsync(stoppingToken);

            _context.Logs.RemoveRange(oldRecords);
            await _context.SaveChangesAsync(stoppingToken);

            await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
        }
    }
}

The RIGHT Way: Use IServiceScopeFactory To fix this, you must inject the IServiceScopeFactory (which is a Singleton) and manually create a new “scope” inside your execution loop.

// RIGHT: Creating a new scope for each unit of work
public class MyCleanupService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    // Inject the factory (which is a Singleton)
    public MyCleanupService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // 1. Create a new scope for this one operation
            using (var scope = _scopeFactory.CreateScope())
            {
                // 2. Resolve your Scoped service from the new scope
                var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();

                // 3. Do your work
                var oldRecords = await dbContext.Logs
                    .Where(l => l.Timestamp < DateTime.UtcNow.AddDays(-30))
                    .ToListAsync(stoppingToken);

                dbContext.Logs.RemoveRange(oldRecords);
                await dbContext.SaveChangesAsync(stoppingToken);
            } 
            // 4. The scope is disposed, the DbContext is disposed. All good.

            await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
        }
    }
}

Pitfall #3: Misusing AddTransient() vs. AddScoped()

This mistake is more subtle but can lead to very confusing bugs.

The Problem: A developer needs a service that maintains a bit of state during a single request (e.g., a “Unit of Work” or a per-request cache). They mistakenly register it as AddTransient().

Example:

  • You have an IOrderService and a IProductService.
  • You also have a “Unit of Work” service, IUnitOfWork, which holds your DbContext.
  • The goal is to have both services use the same IUnitOfWork instance so they can save their changes in a single database transaction.

The WRONG Registration:

builder.Services.AddTransient<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IProductService, ProductService>();

The Result:

  1. An HTTP request comes in.
  2. The IOrderService is requested. The container creates it. It needs an IUnitOfWork, so the container creates a new UnitOfWork_Instance_A.
  3. In the same request, the IProductService is requested. The container creates it. It also needs an IUnitOfWork, so the container creates a new UnitOfWork_Instance_B.

Your OrderService and ProductService now have two different DbContext instances. Any changes made in OrderService will not be part of the same transaction as changes made in ProductService. Your unit of work is broken.

The Fix: Registering the IUnitOfWork as AddScoped() would have ensured both services received the exact same instance for that one HTTP request.

How to Automatically Detect These Problems

In modern .NET (6+), the framework helps you. By default, when running in the Development environment, ASP.NET Core automatically enables Scope Validation.

This means that if you try to commit Pitfall #1 (injecting a Scoped service into a Singleton), your application will crash on startup with a very clear exception, telling you exactly what you did wrong.

You can ensure this is on in Program.cs:

builder.Host.UseDefaultServiceProvider(options =>
{
    // This is on by default in Development, but good to know
    options.ValidateScopes = true; 
    options.ValidateOnBuild = true; // Even stricter, checks on build
});

This feature is a lifesaver, but it doesn’t catch the IHostedService problem (Pitfall #2), which must be solved with IServiceScopeFactory.

Final Checklist: When to Use What

  • AddSingleton(): Is it 100% thread-safe and stateless? (e.g., ILogger).
  • AddScoped(): Is it related to this one HTTP request? (e.g., DbContext, IUnitOfWork). When in doubt for a web app, start here.
  • AddTransient(): Is it a lightweight, stateless “helper” that needs to be created multiple times per request? (e.g., a simple mapper).

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *