Dependency Injection in Modern .NET

If you’re a .NET developer in 2025, you already know that Dependency Injection (DI) is not an optional “nice-to-have” — it’s the default and recommended way of building maintainable, testable, and loosely-coupled applications. Microsoft made DI a first-class citizen starting with .NET Core, and with every new version (especially .NET 8 and .NET 9) the built-in container gets more powerful.

This article is a complete, up-to-date introduction to DI in .NET for developers who already know C# but want to master the current best practices — including the brand new keyed services feature introduced in .NET 8.

What is Dependency Injection?

Dependency Injection is a software design pattern that implements Inversion of Control (IoC). Instead of a class creating its own dependencies, they are injected from the outside. This brings three major benefits:

  • Loose coupling – classes depend on abstractions, not concrete implementations
  • Easier unit testing – you can inject mocks or stubs
  • Better maintainability – changing an implementation doesn’t require changing the consumers

In .NET, the DI container is part of Microsoft.Extensions.DependencyInjection and is already included in every ASP.NET Core (and minimal API) template.

Start a New Project from the Command Line

Let’s create a fresh .NET 9 Web API project from scratch:

PowerShell
# Install dotnet SDK - if it's needed
winget install Microsoft.DotNet.SDK.9

# Create minimal APIs style (recommended in 2025)
dotnet new web -n DependencyInjectionDemo

# Or create new Web API project
dotnet new webapi -n DependencyInjectionDemo --use-controllers

# Go to Your project
cd DependencyInjectionDemo

# For Demo purposestat
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.InMemory

# Swagger API
dotnet add package Swashbuckle.AspNetCore

# Run your GUI
start DependencyInjectionDemo.csproj

The template already has DI fully configured — you just need to register your services.

Service Lifetimes – Quick Reminder

LifetimeMeaningTypical use cases
TransientNew instance every timeLightweight, stateless services
ScopedOne instance per HTTP request / scopeDbContext, user-specific services
SingletonOne instance for the entire applicationCaches, logging configuration, factories

Registering Services – Clean Code

C#
// Program.cs (.NET 8+ top-level statements)
var builder = WebApplication.CreateBuilder(args);

// [some code]

// Common registrations
// Application services
builder.Services.AddScoped<IUserService, UserService>();

// Multiple validator implementations – will be injected as IEnumerable<IPaymentValidator>
builder.Services.AddSingleton<IPaymentValidator, CreditCardValidator>();
builder.Services.AddSingleton<IPaymentValidator, CryptoAmountValidator>();

// Keyed services – modern .NET 8+ way to resolve implementations by key
builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedSingleton<IPaymentGateway, PayPalGateway>("paypal");
builder.Services.AddKeyedSingleton<IPaymentGateway, CryptoGateway>("crypto");

// [some code]

app.Run();

Constructor Injection – Clean Code

C#
public interface IUserService
{
    Task<User> GetByIdAsync(Guid id);
    Task CreateAsync(User user);
}

public class UserService : IUserService
{
    private readonly ApplicationDbContext databaseContext;
    private readonly ILogger<UserService> logger;

    public UserService(ApplicationDbContext databaseContext, ILogger<UserService> logger)
    {
        this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task<User?> GetByIdAsync(Guid id)
    {
        this.logger.LogInformation("Fetching user {UserId}", id);
        return await this.databaseContext.Users
            .AsNoTracking()
            .FirstOrDefaultAsync(u => u.Id == id);
    }

    public async Task<User> CreateAsync(User user)
    {
        var toAdd = user with { Id = Guid.NewGuid(), CreatedAt = DateTime.UtcNow };
        this.databaseContext.Users.Add(toAdd);
        await this.databaseContext.SaveChangesAsync();
        this.logger.LogInformation("User created: {UserId}", toAdd.Id);
        return toAdd;
    }

    Task IUserService.CreateAsync(User user)
    {
        return CreateAsync(user);
    }
}

Controller Using Injected Services

C#
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    private readonly IUserService userService;

    public UsersController(IUserService userService)
        => this.userService = userService ?? throw new ArgumentNullException(nameof(userService));

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<User>> Get(Guid id)
        => await this.userService.GetByIdAsync(id) is User user
            ? this.Ok(user)
            : this.NotFound();

    [HttpPost]
    public async Task<ActionResult<User>> Create([FromBody] User user)
    {
        var created = this.userService.CreateAsync(user);
        return this.CreatedAtAction(nameof(Get), new { id = created.Id }, created);
    }

}

Keyed Services – The Big New Feature in .NET 8+

Sometimes you have multiple implementations of the same interface and you want to choose one at runtime (payment gateways, shipping providers, strategy pattern, etc.).

.NET 8 introduced keyed services — the official, type-safe way to do it.

1. Register keyed services

C#
builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedSingleton<IPaymentGateway, PayPalGateway>("paypal");
builder.Services.AddKeyedSingleton<IPaymentGateway, CryptoGateway>("crypto");

2. The interface and implementations

C#
public record PaymentResult(bool Success, string Gateway);

public interface IPaymentGateway
{
    Task<PaymentResult> ProcessAsync(decimal amount, string currency);
}

public class StripeGateway : IPaymentGateway
{
    private readonly ILogger<StripeGateway> logger;

    public StripeGateway(ILogger<StripeGateway> logger) => this.logger = logger;

    public Task<PaymentResult> ProcessAsync(decimal amount, string currency)
    {
        this.logger.LogInformation("Stripe: charging {Amount} {Currency}", amount, currency);
        return Task.FromResult(new PaymentResult(true, "Stripe", Guid.NewGuid().ToString("N")[..10]));
    }
}

public class PayPalGateway : IPaymentGateway
{
    public Task<PaymentResult> ProcessAsync(decimal amount, string currency)
        => Task.FromResult(new PaymentResult(true, "PayPal", Guid.NewGuid().ToString("N")[..12]));
}

public class CryptoGateway : IPaymentGateway
{
    public Task<PaymentResult> ProcessAsync(decimal amount, string currency)
        => Task.FromResult(new PaymentResult(true, $"Crypto-{currency}"));
}

public record PaymentResult(bool Success, string Gateway);

3. Resolve by key – two ways

C#
[ApiController]
[Route("api/payments")]
public class PaymentsController : ControllerBase
{
    private readonly IKeyedServiceProvider keyedServices;
    private readonly IEnumerable<IPaymentValidator> validators;

    public PaymentsController(
        IKeyedServiceProvider keyedServices,
        IEnumerable<IPaymentValidator> validators)
    {
        this.keyedServices = keyedServices;
        this.validators = validators;
    }

    [HttpPost("charge/{provider}")]
    public async Task<ActionResult<PaymentResult>> Charge(
        string provider,
        decimal amount,
        string currency = "USD")
    {
        // Way 1. All validators
        foreach (var validator in this.validators)
        {
            var result = await validator.ValidateAsync(amount, currency, provider);
            if (!result.IsValid)
                return this.BadRequest(new PaymentResult(false, provider, ErrorMessage: result.ErrorMessage));
        }

        // Way 2. Payment with keyed gateway
        var gateway = this.keyedServices
            .GetRequiredKeyedService<IPaymentGateway>(provider.ToLowerInvariant());
        var payment = await gateway.ProcessAsync(amount, currency);

        return this.Ok(payment);
    }
}

4. Optional: Strongly-typed factory (extra clean)

C#
public enum PaymentProvider { Stripe, PayPal, Crypto }

public class PaymentGatewayFactory
{
    private readonly IKeyedServiceProvider keyedServices;

    public PaymentGatewayFactory(IKeyedServiceProvider keyedServices)
    {
        this.keyedServices = keyedServices;
    }

    public IPaymentGateway Create(PaymentProvider provider) => provider switch
    {
        PaymentProvider.Stripe 
        => this.keyedServices.GetRequiredKeyedService<IPaymentGateway>("stripe"),
        PaymentProvider.PayPal 
        => this.keyedServices.GetRequiredKeyedService<IPaymentGateway>("paypal"),
        PaymentProvider.Crypto 
        => this.keyedServices.GetRequiredKeyedService<IPaymentGateway>("crypto"),
        _ => throw new NotSupportedException()
    };
}

// Register it
builder.Services.AddSingleton<PaymentGatewayFactory>();

Swagger Demo

DI Best Practices in 2025

  • Always use constructor injection
  • Use built-in container — it’s fast and feature-complete
  • Use keyed services instead of manual dictionaries
  • Register third-party libraries with extension methods (AddMediatR, AddFluentValidation, etc.)
  • Use Scrutor for assembly scanning if you have many services

That’s it! You now have a complete, production-ready understanding of Dependency Injection in modern .NET.

Happy coding — and may your services always resolve instantly


Here is working code https://github.com/pascalsikora/dotnet-dependency-injection-demo/

Leave a Reply

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