11 min read

Dependency Injection in .NET: The Complete Guide for 2026

Dependency Injection in .NET: The Complete Guide for 2026

If you've ever inherited a .NET codebase riddled with new SomeService() scattered across controllers and handlers, you know the pain. It's a testing nightmare, a maintenance headache, and a hidden source of hard-to-trace bugs. Dependency injection (DI) is the answer — and after building production APIs in .NET for several years, I can tell you it's the single pattern that's improved my code quality the most.

In this guide, I'll walk you through everything you need to know about DI in .NET for 2026 — from the fundamentals to the newer keyed services feature, real code from projects I've worked on, and production pitfalls I've fallen into so you don't have to. Whether you're new to DI or looking to sharpen your skills with the latest .NET 9 features, this is the guide I wish I had.


What Is Dependency Injection and Why Does It Matter?

Dependency injection is a design pattern where a class receives its dependencies from an external source rather than creating them internally. In .NET, this is handled by the built-in IoC (Inversion of Control) container, which lives in Microsoft.Extensions.DependencyInjection.

Before DI, code often looked like this:

public class OrderService
{
    private readonly EmailService _emailService = new EmailService(); // tightly coupled!

    public void PlaceOrder(Order order)
    {
        // ... order logic
        _emailService.Send(order.CustomerEmail, "Order confirmed");
    }
}

The problem? You can't swap out EmailService in tests. You can't replace it with an SMS service without modifying OrderService. And if EmailService itself has dependencies — you've now got a chain of hidden instantiation.

With DI, you flip this:

public class OrderService
{
    private readonly IEmailService _emailService;

    public OrderService(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public void PlaceOrder(Order order)
    {
        _emailService.Send(order.CustomerEmail, "Order confirmed");
    }
}

Now OrderService depends on an abstraction (IEmailService), not a concrete type. The container handles wiring. Your tests can inject a mock. Clean, testable, loosely coupled — exactly what the Microsoft DI guidelines recommend.

The Three Core Benefits I've Actually Felt

  1. Testability — Mocking services became trivial. My unit test coverage jumped significantly once I switched fully to constructor injection.
  2. Flexibility — Swapping implementations (e.g., from in-memory cache to Redis) now means changing one registration line in Program.cs, not hunting through the codebase.
  3. Clarity — When everything is wired via DI, you can look at a class constructor and instantly know all its dependencies. No hidden state, no surprises.

Setting Up the DI Container in .NET (Program.cs)

In modern .NET (6 and beyond), all service registration happens in Program.cs. There's no more Startup.cs unless you prefer that structure. Here's a minimal example:

var builder = WebApplication.CreateBuilder(args);

// Register your services
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailService, SmtpEmailService>();
builder.Services.AddSingleton<IConfigService, ConfigService>();

var app = builder.Build();
app.Run();

Simple enough. But the real decision here is choosing the right service lifetime — and this is where I've seen the most bugs in real codebases.

Understanding Service Lifetimes

.NET's built-in container supports three lifetimes:

LifetimeCreatedDestroyedWhen to use
SingletonOnce, app startupApp shutdownConfig, caches, stateless utilities
ScopedPer HTTP requestEnd of requestDbContext, per-request services
TransientEvery injectionWhen scope endsLightweight, stateless operations
// Singleton — one instance for the entire app lifetime
builder.Services.AddSingleton<IConfigService, AppConfigService>();

// Scoped — one per HTTP request (the default for DbContext)
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

// Transient — new instance every time it's injected
builder.Services.AddTransient<INotificationBuilder, EmailNotificationBuilder>();

My personal rule of thumb: default to Transient when uncertain. The memory overhead is minimal for small services, and it avoids the most dangerous mistake I'll cover below.

Organizing Registrations at Scale

Once your project grows past a few services, stuffing everything into Program.cs becomes unwieldy. I've found that using extension methods per feature layer keeps things clean:

// Infrastructure/DependencyInjection.cs
public static class InfrastructureExtensions
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(config.GetConnectionString("Default")));

        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}

Then in Program.cs:

builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication(); // another extension for business logic

This mirrors the clean architecture approach recommended by codewithmukesh and it's a pattern I now use in every project.


Building a Real Feature with DI: Notification Service

Let me show you a real-world scenario I dealt with in a recent e-commerce backend. I needed to send notifications via multiple channels — Email, SMS, and Push — depending on user preferences.

The Classic Problem: Multiple Implementations

Before .NET 8, handling this cleanly was awkward. You'd end up injecting IEnumerable<INotificationService> and filtering manually, or building a custom factory. Neither was clean.

// Clunky pre-.NET 8 approach
public class NotificationDispatcher
{
    private readonly IEnumerable<INotificationService> _services;

    public NotificationDispatcher(IEnumerable<INotificationService> services)
    {
        _services = services;
    }

    public Task SendAsync(string channel, string message)
    {
        var service = _services.FirstOrDefault(s => s.Channel == channel);
        return service?.SendAsync(message) ?? Task.CompletedTask;
    }
}

The .NET 8+ Solution: Keyed Services

Keyed services, introduced in .NET 8, solve this elegantly. You register multiple implementations of the same interface with a unique key, then inject exactly the one you need using the [FromKeyedServices] attribute.

// Registration
builder.Services.AddKeyedScoped<INotificationService, EmailNotificationService>("email");
builder.Services.AddKeyedScoped<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedScoped<INotificationService, PushNotificationService>("push");
// Injection — clean and explicit
public class NotificationDispatcher
{
    private readonly INotificationService _emailService;
    private readonly INotificationService _smsService;

    public NotificationDispatcher(
        [FromKeyedServices("email")] INotificationService emailService,
        [FromKeyedServices("sms")] INotificationService smsService)
    {
        _emailService = emailService;
        _smsService = smsService;
    }
}

No custom factories. No filtering logic. No service locator hacks. Just clean DI. .NET 9 extended keyed services to middleware as well, which opens up even more scenarios.

I recommend using enums as keys rather than raw strings — it eliminates typos and improves discoverability:

public enum NotificationChannel { Email, Sms, Push }

builder.Services.AddKeyedScoped<INotificationService, EmailNotificationService>(NotificationChannel.Email);
builder.Services.AddKeyedScoped<INotificationService, SmsNotificationService>(NotificationChannel.Sms);

// Then inject:
public NotificationDispatcher(
    [FromKeyedServices(NotificationChannel.Email)] INotificationService emailService)

Production Best Practices (and Mistakes I've Made)

After running .NET APIs in production, here are the lessons that cost me debugging sessions:

1. Never Inject a Scoped Service into a Singleton (Captive Dependency)

This is the #1 DI mistake I see. If a singleton service holds a reference to a scoped service, that scoped service effectively becomes a singleton — which breaks your expected lifetime and can cause subtle data corruption.

// WRONG — DbContext is scoped, OrderProcessor is singleton
builder.Services.AddSingleton<IOrderProcessor, OrderProcessor>();
builder.Services.AddScoped<AppDbContext>();

// OrderProcessor capturing DbContext as singleton = data corruption risk
public class OrderProcessor
{
    private readonly AppDbContext _db; // This is now "captive" — bad!
    public OrderProcessor(AppDbContext db) => _db = db;
}

ASP.NET Core will throw a InvalidOperationException in development if you enable scope validation (it's on by default in Development environment). But in production, this silently misbehaves. The fix: inject IServiceScopeFactory and create a scope manually:

public class OrderProcessor : IOrderProcessor
{
    private readonly IServiceScopeFactory _scopeFactory;

    public OrderProcessor(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public async Task ProcessAsync(Order order)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // use db safely within this scope
    }
}

2. Avoid the Service Locator Pattern

Calling serviceProvider.GetService<T>() directly inside a class is the service locator anti-pattern. It hides dependencies, makes testing hard, and defeats the purpose of DI. The Microsoft DI guidelines explicitly call this out. Use constructor injection instead — always.

3. Use the Options Pattern for Configuration

I see IConfiguration injected directly into services all the time. Don't do it. Use the Options Pattern for strongly typed, testable configuration:

// appsettings.json
{
  "EmailSettings": {
    "SmtpHost": "smtp.example.com",
    "Port": 587
  }
}

// Options class
public class EmailSettings
{
    public string SmtpHost { get; set; } = string.Empty;
    public int Port { get; set; }
}

// Registration
builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection("EmailSettings"));

// Injection
public class SmtpEmailService
{
    private readonly EmailSettings _settings;

    public SmtpEmailService(IOptions<EmailSettings> options)
    {
        _settings = options.Value;
    }
}

4. Prefer TryAdd* for Library Code

If you're writing a library or shared module, use TryAddScoped / TryAddSingleton instead of AddScoped. This prevents overwriting a registration the consumer may have intentionally made:

// Doesn't overwrite if already registered
services.TryAddScoped<IEmailService, SmtpEmailService>();

5. Register IDisposable Services Properly

The DI container automatically calls Dispose() on IDisposable services when their lifetime ends — but only if the container created them. If you register an existing instance with AddSingleton<T>(instance), you must dispose it yourself. This tripped me up with a custom telemetry client once.


Key Takeaways

  • Dependency injection decouples your classes from their implementations, making code testable and maintainable — it's non-negotiable in modern .NET.
  • The three lifetimes — Singleton, Scoped, Transient — each have specific use cases; defaulting to Transient for ambiguous cases is the safe choice.
  • Keyed services (introduced in .NET 8, extended in .NET 9) are the clean solution for multiple implementations of the same interface — drop the custom factories.
  • Organizing registrations into feature-level extension methods keeps Program.cs clean as projects scale.
  • The captive dependency problem (scoped inside singleton) is the most common DI bug I've seen in production — enable scope validation during development and use IServiceScopeFactory in singletons when you need scoped services.
  • Use the Options Pattern for configuration injection — never inject raw IConfiguration into services.
  • Avoid the service locator pattern — if you're calling GetService<T>() inside a class, that's a design smell worth fixing.
  • Constructor injection is always preferred over property or method injection for required dependencies.

Conclusion

Dependency injection in .NET is one of those fundamentals that seems simple on the surface but has real depth — and real consequences when misused. After years of building .NET backends, I can say the investment in truly understanding DI — lifetimes, scoping, keyed services, proper disposal — pays back in fewer production bugs and much faster testing cycles.

The .NET ecosystem's built-in DI container has matured significantly, especially with keyed services in .NET 8/9. You rarely need a third-party container for standard use cases anymore.

If this guide helped clarify something that was fuzzy, drop a comment below or share it with your team. And if you want to dive deeper into .NET architecture topics, there's plenty more to explore on steve-bang.com.