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:
# 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
| Lifetime | Meaning | Typical use cases |
|---|---|---|
| Transient | New instance every time | Lightweight, stateless services |
| Scoped | One instance per HTTP request / scope | DbContext, user-specific services |
| Singleton | One instance for the entire application | Caches, logging configuration, factories |
Registering Services – Clean Code
// 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
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
[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
builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedSingleton<IPaymentGateway, PayPalGateway>("paypal");
builder.Services.AddKeyedSingleton<IPaymentGateway, CryptoGateway>("crypto");2. The interface and implementations
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
[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)
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/
