Dependency Injection in .NET 10 — Scoped, Transient, Singleton & Keyed Services Explained

.NET 10 Dependency Injection Complete Guide

Dependency Injection in .NET 10 — Scoped, Transient, Singleton & Keyed Services Explained

Master the most important design pattern in .NET development. Learn service lifetimes, keyed services, primary constructors, the Options Pattern, and the production mistakes that cause real bugs — all with working C# code.

AS

Adnan Shaukat

May 21, 2026  ·  13 min read  ·  Backend Developer

Dependency Injection is the backbone of every ASP.NET Core application. It is the reason your controllers receive services they need without creating them, the reason your code is testable, and the reason large codebases remain maintainable over time. Yet it is also one of the most misunderstood features in .NET — and misusing it causes real production bugs that are notoriously hard to diagnose.

In this guide I will walk you through everything: what DI is and why it exists, the three service lifetimes and when to use each, the keyed services feature refined in .NET 10, primary constructor injection, the Options Pattern, common mistakes that break production apps, and how to test with DI correctly.

01 — Foundation What is Dependency Injection?

Dependency Injection is a design pattern that implements Inversion of Control — instead of a class creating its own dependencies using the new keyword, those dependencies are provided (injected) from the outside by a container.

The .NET 10 DI container — built into ASP.NET Core — manages the creation, lifetime, and disposal of your services automatically. You register services once at startup. The container resolves and injects them wherever they are needed.

Simple analogy

Think of DI like a restaurant kitchen. The chef (your class) does not go to the farm to get eggs (dependencies). The kitchen manager (DI container) delivers exactly what the chef needs, when they need it. The chef focuses entirely on cooking.

02 — The Case For DI Without DI vs With DI

C#
// ❌ WITHOUT DI — tightly coupled, impossible to test public class OrderService { private readonly OrderRepository _repo; private readonly EmailService _email; public OrderService() { // Creates dependencies itself — tight coupling _repo = new OrderRepository(new AppDbContext()); // needs DB! _email = new EmailService(new SmtpClient()); // sends real emails! } // How do you unit test this without a real database and SMTP server? // You cannot. This code is untestable. } // ✅ WITH DI — loosely coupled, fully testable public class OrderService { private readonly IOrderRepository _repo; private readonly IEmailService _email; // Dependencies injected by the container — just interfaces public OrderService(IOrderRepository repo, IEmailService email) { _repo = repo; _email = email; } // Unit test: inject mock IOrderRepository and mock IEmailService // Integration test: inject real implementations // Production: DI container injects real implementations automatically }

03 — Most Important Concept Service Lifetimes — Singleton, Scoped, Transient

Choosing the wrong service lifetime is the most common DI mistake in .NET — and it causes subtle, hard-to-diagnose bugs in production. Understanding the three lifetimes is non-negotiable.

♾️

Singleton

Created ONCE when first requested. Same instance shared across the entire application lifetime.

Use for: configuration, caching, logging, stateless services

🔄

Scoped

Created ONCE per HTTP request. Same instance within the request, new instance for each new request.

Use for: DbContext, repositories, services with request state

🆕

Transient

Created EVERY time it is requested. New instance each injection — even within the same request.

Use for: lightweight, stateless operations, utility services

C#
// Program.cs — registering services with correct lifetimes // Singleton — one instance for the app's entire lifetime builder.Services.AddSingleton<IMemoryCacheService, MemoryCacheService>(); builder.Services.AddSingleton<IConfiguration>(builder.Configuration); // Scoped — one instance per HTTP request (most common for backend services) builder.Services.AddScoped<IProductRepository, ProductRepository>(); builder.Services.AddScoped<IOrderService, OrderService>(); builder.Services.AddDbContext<AppDbContext>(...); // DbContext is scoped by default // Transient — new instance every time it is injected builder.Services.AddTransient<IEmailValidator, EmailValidator>(); builder.Services.AddTransient<IGuidGenerator, GuidGenerator>(); // How to verify which lifetime a service needs — ask these questions: // Does it hold state? → probably Scoped or Singleton // Is it thread-safe? → can be Singleton // Does it use DbContext? → must be Scoped (DbContext is scoped) // Is it cheap to create? → can be Transient // Is it stateless? → Singleton is fine

04 — Registration Patterns Registering Services — All the Ways

C#
// 1. Interface to implementation — most common pattern builder.Services.AddScoped<IProductService, ProductService>(); // 2. Concrete type only — when no interface needed builder.Services.AddScoped<ProductService>(); // 3. Factory delegate — when creation needs logic builder.Services.AddScoped<IEmailService>(provider => { var config = provider.GetRequiredService<IOptions<EmailSettings>>().Value; return config.UseSmtp ? new SmtpEmailService(config.SmtpHost) : new SendGridEmailService(config.ApiKey); }); // 4. TryAdd — only registers if not already registered (great for libraries) builder.Services.TryAddScoped<IProductService, ProductService>(); // 5. Replace — swap an existing registration (useful in tests) builder.Services.Replace(ServiceDescriptor.Scoped<IEmailService, MockEmailService>()); // 6. Register multiple implementations of same interface builder.Services.AddScoped<INotificationService, EmailNotificationService>(); builder.Services.AddScoped<INotificationService, SmsNotificationService>(); builder.Services.AddScoped<INotificationService, PushNotificationService>(); // Inject IEnumerable<INotificationService> to get all three

05 — .NET 10 Feature Keyed Services — The Clean Solution for Multiple Implementations

Before .NET 8, if you had multiple implementations of the same interface and needed to inject a specific one, you had ugly workarounds — injecting IEnumerable<T> and filtering, writing custom factories, or using the service locator anti-pattern. Keyed services solve this cleanly.

Keyed services, introduced in .NET 8 and refined through .NET 10, bring named registrations to the built-in dependency injection container. You tag each registration with a key, then resolve by that key at the injection site.

C#
// Scenario: You have multiple payment providers public interface IPaymentService { Task<bool> ProcessAsync(decimal amount); } public class StripePaymentService : IPaymentService { ... } public class PayPalPaymentService : IPaymentService { ... } public class JazzCashPaymentService : IPaymentService { ... } // Register with keys — Program.cs builder.Services.AddKeyedScoped<IPaymentService, StripePaymentService> ("stripe"); builder.Services.AddKeyedScoped<IPaymentService, PayPalPaymentService> ("paypal"); builder.Services.AddKeyedScoped<IPaymentService, JazzCashPaymentService>("jazzcash"); // Inject the specific one you need using [FromKeyedServices] public class CheckoutService { private readonly IPaymentService _stripe; private readonly IPaymentService _paypal; public CheckoutService( [FromKeyedServices("stripe")] IPaymentService stripe, [FromKeyedServices("paypal")] IPaymentService paypal) { _stripe = stripe; _paypal = paypal; } public async Task ProcessAsync(string provider, decimal amount) { var service = provider.ToLower() switch { "stripe" => _stripe, "paypal" => _paypal, _ => throw new ArgumentException("Unknown provider") }; await service.ProcessAsync(amount); } } // In Minimal APIs — inject keyed service directly app.MapPost("/checkout/stripe", async ( [FromKeyedServices("stripe")] IPaymentService payment, PaymentRequest req) => { var success = await payment.ProcessAsync(req.Amount); return success ? Results.Ok() : Results.BadRequest(); }); // AnyKey — .NET 10 refinement: fallback for unregistered keys builder.Services.AddKeyedSingleton<ICache, PremiumCache>("premium"); builder.Services.AddKeyedSingleton<ICache, DefaultCache>(KeyedService.AnyKey); // "premium" key returns PremiumCache, any other key returns DefaultCache

Why keyed services matter

Keyed services eliminate the need for custom factories and IEnumerable filtering — no custom factories, no filtering logic, no service locator hacks. Just clean DI with the [FromKeyedServices] attribute.

06 — C# 12+ Feature Primary Constructor Injection — Cleaner Code

C# 12 introduced primary constructors which work beautifully with DI. Instead of declaring private fields and writing a constructor body, you can define dependencies directly in the class declaration — much less boilerplate.

C#
// Old way — lots of boilerplate public class ProductService { private readonly IProductRepository _repo; private readonly ILogger<ProductService> _logger; private readonly IMapper _mapper; public ProductService( IProductRepository repo, ILogger<ProductService> logger, IMapper mapper) { _repo = repo; _logger = logger; _mapper = mapper; } public async Task<ProductDto> GetByIdAsync(Guid id, CancellationToken ct) { var product = await _repo.GetByIdAsync(id, ct); _logger.LogInformation("Fetched product {Id}", id); return _mapper.Map<ProductDto>(product); } } // ✅ New way — primary constructor (C# 12+, works in .NET 10) public class ProductService( IProductRepository repo, ILogger<ProductService> logger, IMapper mapper) { // No field declarations, no constructor body needed // repo, logger, mapper are directly available as parameters public async Task<ProductDto> GetByIdAsync(Guid id, CancellationToken ct) { var product = await repo.GetByIdAsync(id, ct); logger.LogInformation("Fetched product {Id}", id); return mapper.Map<ProductDto>(product); } }

07 — Configuration Done Right The Options Pattern — Inject Configuration Correctly

Injecting IConfiguration directly into your services is an anti-pattern — it couples your services to the configuration system and makes them harder to test. The Options Pattern is the correct way to inject settings.

C#
// 1. Define a strongly-typed settings class public class EmailSettings { public const string SectionName = "Email"; public string SmtpHost { get; init; } = string.Empty; public int SmtpPort { get; init; } public string FromEmail { get; init; } = string.Empty; public string ApiKey { get; init; } = string.Empty; } // 2. Bind and register in Program.cs builder.Services.Configure<EmailSettings>( builder.Configuration.GetSection(EmailSettings.SectionName)); // 3. Inject using IOptions — three variants public class EmailService(IOptions<EmailSettings> options) { private readonly EmailSettings _settings = options.Value; // IOptions<T> — singleton, read once at startup // IOptionsSnapshot<T> — scoped, re-reads per request (great for hot-reload) // IOptionsMonitor<T> — singleton, notified when config changes at runtime } // 4. appsettings.json /* { "Email": { "SmtpHost": "smtp.gmail.com", "SmtpPort": 587, "FromEmail": "adnan@example.com", "ApiKey": "your-sendgrid-api-key" } } */ // 5. Add validation so bad config fails at startup, not at runtime builder.Services .AddOptions<EmailSettings>() .BindConfiguration(EmailSettings.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); // throws if invalid settings at app startup

08 — Clean Program.cs Organise Registrations with Extension Methods

Organising registrations into feature-level extension methods keeps Program.cs clean as projects scale. As your app grows, Program.cs becomes hundreds of lines of service registrations. Extension methods solve this elegantly.

C#
// Infrastructure/DependencyInjection.cs public static class InfrastructureServices { public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration) { services.AddDbContext<AppDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("Default"))); services.AddScoped<IProductRepository, ProductRepository>(); services.AddScoped<IOrderRepository, OrderRepository>(); services.AddScoped<IUnitOfWork, UnitOfWork>(); return services; } } // Application/DependencyInjection.cs public static class ApplicationServices { public static IServiceCollection AddApplication( this IServiceCollection services) { services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly( typeof(CreateProductCommandHandler).Assembly)); services.AddValidatorsFromAssembly( typeof(CreateProductCommandValidator).Assembly); return services; } } // Program.cs — clean and readable builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddApplication(); builder.Services.AddPresentation();

09 — Production Bugs Common DI Mistakes That Break Production

Mistake 1 — Captive dependency (Scoped inside Singleton)

This is the most common DI bug in production. The captive dependency problem — injecting a scoped service inside a singleton — is the most common DI bug seen in production. A Singleton lives for the app's lifetime. If it holds a Scoped service, that Scoped service never gets disposed and behaves like a Singleton — causing stale data, connection leaks, and race conditions.

C#
// ❌ DANGEROUS — singleton capturing a scoped service public class BackgroundWorker : BackgroundService { private readonly IProductRepository _repo; // IProductRepository is SCOPED! public BackgroundWorker(IProductRepository repo) // WRONG — will throw or cause bugs => _repo = repo; } // ✅ Correct — use IServiceScopeFactory to create scopes manually public class BackgroundWorker(IServiceScopeFactory scopeFactory) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { // Create a new scope for each unit of work await using var scope = scopeFactory.CreateAsyncScope(); var repo = scope.ServiceProvider .GetRequiredService<IProductRepository>(); await repo.ProcessPendingAsync(ct); await Task.Delay(TimeSpan.FromMinutes(1), ct); } } } // Enable scope validation in development — catches this bug at startup builder.Host.UseDefaultServiceProvider(options => { options.ValidateScopes = builder.Environment.IsDevelopment(); options.ValidateOnBuild = builder.Environment.IsDevelopment(); });

Mistake 2 — Service Locator anti-pattern

C#
// ❌ Service locator — hides dependencies, makes testing hard public class ProductService { private readonly IServiceProvider _provider; public ProductService(IServiceProvider provider) => _provider = provider; public void DoWork() { var repo = _provider.GetService<IProductRepository>(); // anti-pattern } } // ✅ Constructor injection — explicit, testable public class ProductService(IProductRepository repo) { public void DoWork() { // repo is injected, visible, testable } }

Mistake 3 — Injecting IConfiguration directly

C#
// ❌ Bad — magic strings, no validation, hard to test public class EmailService(IConfiguration config) { private readonly string _host = config["Email:SmtpHost"]!; // magic string } // ✅ Good — Options Pattern (see Section 7) public class EmailService(IOptions<EmailSettings> options) { private readonly EmailSettings _settings = options.Value; // Type-safe, validated, easily mocked in tests }

10 — Testing Testing Services That Use DI

C#
// Unit test — mock dependencies, no real container needed public class ProductServiceTests { [Fact] public async Task GetByIdAsync_ExistingProduct_ReturnsDto() { // Arrange — create mocks var repoMock = new Mock<IProductRepository>(); var loggerMock = new Mock<ILogger<ProductService>>(); var product = Product.Create("Laptop", "A laptop", 999m, 10); repoMock.Setup(r => r.GetByIdAsync(product.Id, default)) .ReturnsAsync(product); // Inject mocks directly — no DI container needed var service = new ProductService(repoMock.Object, loggerMock.Object); // Act var result = await service.GetByIdAsync(product.Id, default); // Assert result.Should().NotBeNull(); result!.Name.Should().Be("Laptop"); } } // Integration test — use WebApplicationFactory with real DI public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public ProductsApiTests(WebApplicationFactory<Program> factory) { _client = factory .WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace real DB with in-memory for tests services.Replace(ServiceDescriptor.Scoped<AppDbContext>(_ => { var opts = new DbContextOptionsBuilder<AppDbContext>() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new AppDbContext(opts); })); }); }) .CreateClient(); } [Fact] public async Task GetProducts_ReturnsSuccessStatusCode() { var response = await _client.GetAsync("/api/products"); response.Should().Be200Ok(); } }

Quick Reference DI Best Practices Checklist

Always inject through interfaces, not concrete types

Use Scoped for DbContext, repositories, and request-level services

Never inject Scoped inside Singleton — use IServiceScopeFactory instead

Use Keyed Services for multiple implementations — no custom factories

Use Options Pattern — never inject IConfiguration directly

Use extension methods to keep Program.cs clean

Enable scope validation in development to catch lifetime errors early

Prefer constructor injection — avoid property and method injection

Never use the service locator pattern — it hides dependencies

Final Thoughts

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

Dependency Injection .NET 10 Keyed Services ASP.NET Core C# Backend Tutorial
AS

Written by

Adnan Shaukat

Backend Developer with 5+ years in .NET, Python, and REST APIs. Writing daily at codewithadnanshaukat.blogspot.com

Comments

Popular posts from this blog

What is Agentic AI? A Complete Developer Guide for 2026

CQRS Pattern in .NET 10 with MediatR — Complete Step-by-Step Tutorial

EF Core 10 Performance Tips — 12 Ways to Make Your Queries Blazing Fast