Five DI Anti-Patterns Haunting .NET Apps and how to fix Them

Dependency injection is powerful until small mistakes turn into late night outages. This post walks through five common DI anti patterns in .NET, shows why they fail, and gives compact fixes. Learn lifetime rules, avoid hidden dependencies, and make testing easy.

I once chased a production bug at midnight that only appeared for the second user who logged in. The logs were clean, the code looked fine, and my coffee was doing all the heavy lifting. The culprit was not a race condition or a missing await. It was plain old dependency injection used in sneaky ways.

In this post we will walk through five DI antipatterns I keep seeing in .NET apps. For each one we will look at what it looks like, why it hurts, and a small fix you can drop in today. Along the way I will share a simple sniff test that has saved me from shipping a few of these face palm moments.

1. Service locator in disguise

If your class injects IServiceProvider then goes fishing for dependencies inside methods, it is wearing a service locator costume. It hides what the class truly needs, makes tests awkward, and increases the chance of surprise behavior.

Bad version:

public class JediCouncil
{
private readonly IServiceProvider _provider;
public JediCouncil(IServiceProvider provider) => _provider = provider;
public Task TrainAsync()
{
var saber = _provider.GetRequiredService<ILightsaberService>();
var force = _provider.GetRequiredService<IForceMeter>();
return saber.SwingAsync(force.Level);
}
}

Fix by being honest and explicit. Inject the actual dependencies and let the container do its job.

public class JediCouncil
{
private readonly ILightsaberService _saber;
private readonly IForceMeter _force;
public JediCouncil(ILightsaberService saber, IForceMeter force)
{ _saber = saber; _force = force; }
public Task TrainAsync() => _saber.SwingAsync(_force.Level);
}

If the constructor starts to look like a grocery list, that is feedback. Consider splitting responsibilities or composing dependencies behind a smaller abstraction rather than hiding them behind IServiceProvider.

2. Captive dependency

This one mixes lifetimes. A singleton grabs a scoped service and never lets go. The first scope wins and other requests see the wrong data.

Problem setup:

services.AddSingleton<IGalaxyCache, GalaxyCache>();
services.AddScoped<IRebelContext, RebelContext>();
public class GalaxyCache : IGalaxyCache
{ public GalaxyCache(IRebelContext ctx) { /* captured scoped */ } }

Two simple fixes:

  • Align lifetimes. If a class needs scoped services, make it scoped.
services.AddScoped<IGalaxyCache, GalaxyCache>();
services.AddScoped<IRebelContext, RebelContext>();
  • Turn on validation so the framework yells before production does.
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = true;
o.ValidateOnBuild = true;
});

There are rare cases for a singleton to create a short lived scope on demand, but prefer aligning lifetimes first.

3. Everything is a singleton

Singletons are fast and easy to reason about when they are stateless or truly shared. They are trouble when a property changes during a request and then leaks to every other request.

Smelly state in a singleton:

public class GameScoreBoard : IGameScoreBoard
{
public int LastScore { get; set; }
public void Record(int score) => LastScore = score;
}

Safer defaults for a web app:

  • Singleton for stateless things and shared factories like configuration, caches, or HttpClient factories
  • Scoped for anything tied to the current request like DbContext, unit of work, or user context
  • Transient for small stateless helpers you do not mind creating often

Registrations that lean safe:

services.AddScoped<IGameScoreBoard, GameScoreBoard>();
services.AddHttpClient();

Start scoped when in doubt. If profiling shows pressure, then consider moving to singleton and prove it safe.

4. Constructor over injection

A constructor with ten services is not a DI problem. It is a class doing too many jobs. Thin out your controller or service by moving behavior behind a focused orchestration type.

A focused orchestrator keeps dependencies small and intent clear:

public interface IOrderOrchestrator
{ Task<Result> CreateAsync(OrderDto dto); }
public class OrderOrchestrator : IOrderOrchestrator
{
private readonly IInventory _inv; private readonly IPayments _pay; private readonly INotify _note;
public OrderOrchestrator(IInventory inv, IPayments pay, INotify note)
{ _inv = inv; _pay = pay; _note = note; }
public async Task<Result> CreateAsync(OrderDto dto)
{ await _inv.Reserve(dto.Id); await _pay.Charge(dto.Id); await _note.Send(dto.Id); return Result.Ok(); }
}

A controller becomes a thin coordinator:

public class OrdersController
{
private readonly IOrderOrchestrator _orc;
public OrdersController(IOrderOrchestrator orc) => _orc = orc;
public Task<Result> Post(OrderDto dto) => _orc.CreateAsync(dto);
}

When you reduce the constructor to the few things the class truly owns, tests get simpler and responsibilities settle into place.

5. New all the things

If a dependency does network, disk, time, randomness, or nontrivial logic, it should be injected. New inside your class shuts out the container and makes tests cranky.

Problem example:

public class WeatherHolocron
{
public async Task<string> GetAsync()
{
var http = new HttpClient();
return await http.GetStringAsync("https://api");
}
}

Prefer typed clients so lifetimes and policies are managed by the host.

public class WeatherHolocron
{
private readonly HttpClient _http;
public WeatherHolocron(HttpClient http) => _http = http;
public Task<string> GetAsync() => _http.GetStringAsync("/forecast");
}

Registration becomes explicit and test friendly:

services.AddHttpClient<WeatherHolocron>(c =>
{
c.BaseAddress = new Uri("https://api.weather.test");
});

Configuration should flow through options rather than magic strings.

public record SmtpOptions(string Host);
public class SmtpEmailer : IEmailer
{
private readonly string _host;
public SmtpEmailer(IOptions<SmtpOptions> opt) => _host = opt.Value.Host;
}
services.Configure<SmtpOptions>(o => o.Host = "smtp.company.test");
services.AddScoped<IEmailer, SmtpEmailer>();

Wrap up

Good DI is honest about what a class needs and careful about lifetimes. It makes tests easy to write, failures easier to diagnose, and production behavior boring in the best possible way. The container is already on your side. Give it clear types and the right lifetimes, and it will carry your app a long way without waking you up at midnight.