DEV Community

Cover image for Dependency Injection in .NET Is Easy to Get Wrong
qodors
qodors

Posted on

Dependency Injection in .NET Is Easy to Get Wrong

Almost every weird intermittent bug I've dealt with in a .NET app traced back to one place: a service registered with the wrong lifetime in Program.cs. Not the logic inside the service. The single line that said how long it should live.

DI itself is built into .NET and easy to start with. You register your services, ask for them in a constructor, and the framework hands them over. The part that trips people up isn't wiring it together. It's the lifetimes.

Get a lifetime wrong and the app still starts, requests still succeed, and everything looks healthy. The damage shows up later as stale data, or state bleeding between users, or memory that climbs and never drops back down.

The Three Lifetimes

There are three, and the whole thing hinges on understanding them.

*Transient *— you get a new instance every time you ask for one. Two things in the same request that both need it get two separate copies.

*Scoped *— you get one instance per request. Everything in a single HTTP request shares the same instance, and the next request gets a fresh one.

*Singleton *— you get one instance for the entire lifetime of the app. Every request, every user, shares the exact same object.

builder.Services.AddTransient<IEmailSender, EmailSender>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ICacheProvider, CacheProvider>();
Enter fullscreen mode Exit fullscreen mode

That's the whole API. Three methods. Picking the wrong one of the three is where most of the trouble comes from.
The Captive Dependency

This is the one that gets almost everybody at some point.

You have a singleton. Inside it, you inject something scoped. It compiles, it runs, and it looks fine. But you've just trapped a scoped service inside a singleton.

The singleton is created once and lives forever. The scoped service it grabbed gets held onto for that whole time, instead of being created fresh per request like it's supposed to. So now a service that was meant to live for one request is stuck living for the entire app.

public class CacheProvider : ICacheProvider   // registered as Singleton
{
    private readonly AppDbContext _db;         // this is Scoped

    public CacheProvider(AppDbContext db)      // now the DbContext is captive
    {
        _db = db;
    }
}
Enter fullscreen mode Exit fullscreen mode

DbContext is the classic victim. It's registered scoped for a reason. It's not built to be shared across requests, it tracks changes and holds a connection, and when you trap it inside a singleton you get stale data, threading errors, and connection problems that are miserable to track down.

The rule that keeps you safe: a service can only depend on things that live as long as it does, or longer. A singleton can depend on a singleton. A scoped service can depend on scoped or singleton. But a singleton depending on scoped is the trap.

The good news is that .NET can catch this for you. There's a scope validation check that throws at startup when a singleton depends on something scoped, and it's on by default in the Development environment. Don't turn it off. If it throws, it's telling you about a real bug before it reaches production.
Singletons Holding State

The other common mistake is making something a singleton without thinking about what it holds.

A singleton is shared across every request at the same time. If it has mutable state — a field it writes to, a dictionary it updates, a list it appends to — then multiple requests are hitting that state at once, on different threads. That's a race condition, and it shows up as data from one user leaking into another user's request, or numbers that don't add up.

If a service is a singleton, either it holds no state, or the state it holds is safe for many threads to touch at once. A stateless helper is fine as a singleton. A cache built on a thread-safe collection is fine. A service that quietly keeps per-user data in a plain field is not.
So Which One Do You Pick

For most things, the answer is scoped, and that's also the default for a reason.

Scoped fits how web requests work. One instance per request, fresh state each time, cleaned up when the request ends. Your services that touch the database, handle business logic, or deal with the current user should almost always be scoped.

Use transient for small, cheap, stateless things where a new instance every time costs nothing. Use singleton for things that are genuinely shared and either hold no state or are built to be thread-safe — configuration, a connection factory, an in-memory cache.

When you're not sure, scoped is the safe default. Reaching for singleton to "save memory" is where people get into trouble, and the memory you save is almost never worth the bugs you buy.
Our Take

At Qodors, when we're brought into a .NET app with weird intermittent bugs — stale data, state bleeding between users, memory that only grows — DI lifetimes are near the top of the list we check. It's rarely the code inside the service that's broken. It's how the service was registered.

What makes it hard is that the wrong lifetime doesn't fail loudly. The app starts, requests succeed, everything looks healthy. The problem only surfaces under real concurrent traffic, and by then it looks like a dozen unrelated bugs instead of one registration mistake.

Turn on scope validation, default to scoped, and think for a second before you make anything a singleton. Most DI problems never happen if you do those three things.
Quick Checklist

  • Default to scoped unless you have a clear reason not to
  • Never inject a scoped service into a singleton (the captive dependency)
  • A service can only depend on things that live as long as it does, or longer
  • Keep scope validation on in Development — don't silence it
  • Singletons must be stateless or genuinely thread-safe
  • DbContext stays scoped — don't trap it in a singleton

The wrong lifetime is a one-line mistake that hides for weeks. It doesn't break the build and it doesn't throw in testing. It waits for real traffic, then hands you a pile of bugs that look unrelated until you trace them back to a single registration.

Default to scoped, keep scope validation on, and be deliberate about singletons.

DotNet #CSharp #DependencyInjection #AspNetCore #BackendDevelopment #SoftwareEngineering #DotNetCore #StartupCTO #QodorsEdge

Written by the team at Qodors — we fix .NET apps with weird intermittent bugs. → www.qodors.com

Top comments (0)