You are viewing a preview of this lesson. Sign in to start learning
Back to ASP.NET with .NET 10

Dependency Injection Mastery

Advanced DI patterns and configuration management

Dependency Injection Mastery in ASP.NET with .NET 10

Master Dependency Injection (DI) patterns in modern ASP.NET applications with free flashcards and spaced repetition practice. This lesson covers service lifetimes, advanced registration patterns, and DI best practicesβ€”essential concepts for building maintainable, testable .NET 10 applications.

Welcome to DI Mastery πŸ’‰

Dependency Injection is the backbone of modern ASP.NET development. Understanding DI deeply transforms you from someone who "uses" the DI container to someone who leverages it to build elegant, maintainable architectures. In .NET 10, the built-in DI container has matured significantly, offering powerful features that rival third-party containers.

This lesson will take you beyond basic AddScoped and AddTransient calls. You'll learn when to use each lifetime, how to handle complex dependency graphs, avoid common pitfalls like captive dependencies, and implement advanced patterns like the decorator and factory patterns.

Core Concepts: Understanding Service Lifetimes πŸ”„

The Three Lifetimes

ASP.NET Core's DI container manages three primary service lifetimes:

Lifetime Creation Behavior Use Case Symbol
Transient New instance every time requested Lightweight, stateless services πŸ”„
Scoped One instance per HTTP request/scope DbContext, request-specific logic πŸ“¦
Singleton One instance for application lifetime Configuration, caches, logging πŸ‘‘

Transient Services πŸ”„

Transient services are created each time they're requested from the service container. This lifetime works best for lightweight, stateless services.

services.AddTransient<IEmailService, EmailService>();

πŸ’‘ When to use Transient:

  • The service has no state
  • The service is lightweight to instantiate
  • You want to avoid shared state issues

⚠️ Avoid Transient for:

  • Services with expensive initialization (DB connections, file handles)
  • Services that maintain state
  • Services with disposable resources that need careful management

Scoped Services πŸ“¦

Scoped services are created once per client request (or scope). In web applications, this typically means one instance per HTTP request.

services.AddScoped<IOrderProcessor, OrderProcessor>();
services.AddDbContext<AppDbContext>(); // Scoped by default

πŸ’‘ When to use Scoped:

  • Entity Framework DbContext (always use Scoped!)
  • Services that should share state within a single request
  • Unit of Work pattern implementations

🧠 Memory Device: Think "Scoped for Single request" - one instance per web request.

Singleton Services πŸ‘‘

Singleton services are created once and shared throughout the application's lifetime.

services.AddSingleton<IMemoryCache, MemoryCache>();
services.AddSingleton<IConfiguration>(configuration);

πŸ’‘ When to use Singleton:

  • Configuration objects
  • Caching services
  • Logging infrastructure
  • Thread-safe state that should be shared

⚠️ Critical Safety Rule: Singletons must be thread-safe since they're shared across all requests!

The Captive Dependency Problem 🚨

One of the most insidious DI bugs is the captive dependency - when a longer-lived service captures a shorter-lived dependency.

❌ DANGEROUS PATTERN: Captive Dependency

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Singleton Service πŸ‘‘          β”‚
β”‚                                 β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚   β”‚ Scoped Dependency πŸ“¦    β”‚   β”‚ ← CAPTURED!
β”‚   β”‚ (DbContext)             β”‚   β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Problem: The DbContext lives as long as
the Singleton, causing stale data, memory
leaks, and threading issues!

Example: The Bug

// ❌ WRONG: Singleton capturing Scoped dependency
public class CacheService // Registered as Singleton
{
    private readonly AppDbContext _context; // Scoped!
    
    public CacheService(AppDbContext context)
    {
        _context = context; // Captive dependency!
    }
    
    public async Task<User> GetUserAsync(int id)
    {
        // This DbContext will never be disposed properly
        // and will become stale across requests!
        return await _context.Users.FindAsync(id);
    }
}

Solution Patterns

Solution 1: Inject IServiceProvider and resolve per-operation

// βœ… CORRECT: Resolve scoped dependency when needed
public class CacheService // Singleton
{
    private readonly IServiceProvider _serviceProvider;
    
    public CacheService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public async Task<User> GetUserAsync(int id)
    {
        using var scope = _serviceProvider.CreateScope();
        var context = scope.ServiceProvider
            .GetRequiredService<AppDbContext>();
        return await context.Users.FindAsync(id);
    }
}

Solution 2: Use IServiceScopeFactory

// βœ… BETTER: Use IServiceScopeFactory for cleaner code
public class CacheService // Singleton
{
    private readonly IServiceScopeFactory _scopeFactory;
    
    public CacheService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }
    
    public async Task<User> GetUserAsync(int id)
    {
        using var scope = _scopeFactory.CreateScope();
        var context = scope.ServiceProvider
            .GetRequiredService<AppDbContext>();
        return await context.Users.FindAsync(id);
    }
}

πŸ’‘ Best Practice: In .NET 10, the runtime includes validation that detects captive dependencies during development. Enable it in Program.cs:

var builder = WebApplication.CreateBuilder(args);

if (builder.Environment.IsDevelopment())
{
    builder.Host.UseDefaultServiceProvider(options =>
    {
        options.ValidateScopes = true;
        options.ValidateOnBuild = true;
    });
}

Advanced Registration Patterns 🎯

Generic Type Registration

.NET 10's DI container supports open generic registration:

// Register all IRepository<T> implementations
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

// Now you can inject IRepository<Product>, IRepository<Order>, etc.
public class ProductService
{
    private readonly IRepository<Product> _productRepo;
    private readonly IRepository<Category> _categoryRepo;
    
    public ProductService(
        IRepository<Product> productRepo,
        IRepository<Category> categoryRepo)
    {
        _productRepo = productRepo;
        _categoryRepo = categoryRepo;
    }
}

Factory Pattern with DI

Sometimes you need runtime parameters to create services. Use factory functions:

// Register a factory that creates services based on runtime values
services.AddTransient<IPaymentProcessorFactory, PaymentProcessorFactory>();
services.AddTransient<CreditCardProcessor>();
services.AddTransient<PayPalProcessor>();

public interface IPaymentProcessorFactory
{
    IPaymentProcessor Create(PaymentMethod method);
}

public class PaymentProcessorFactory : IPaymentProcessorFactory
{
    private readonly IServiceProvider _serviceProvider;
    
    public PaymentProcessorFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public IPaymentProcessor Create(PaymentMethod method)
    {
        return method switch
        {
            PaymentMethod.CreditCard => 
                _serviceProvider.GetRequiredService<CreditCardProcessor>(),
            PaymentMethod.PayPal => 
                _serviceProvider.GetRequiredService<PayPalProcessor>(),
            _ => throw new ArgumentException("Unknown payment method")
        };
    }
}

Decorator Pattern Implementation

Decorators add behavior to existing services without modifying their code:

// Base interface
public interface INotificationService
{
    Task SendAsync(string message);
}

// Core implementation
public class EmailNotificationService : INotificationService
{
    public async Task SendAsync(string message)
    {
        // Send email logic
        await Task.CompletedTask;
    }
}

// Decorator that adds logging
public class LoggingNotificationDecorator : INotificationService
{
    private readonly INotificationService _inner;
    private readonly ILogger<LoggingNotificationDecorator> _logger;
    
    public LoggingNotificationDecorator(
        INotificationService inner,
        ILogger<LoggingNotificationDecorator> logger)
    {
        _inner = inner;
        _logger = logger;
    }
    
    public async Task SendAsync(string message)
    {
        _logger.LogInformation("Sending notification: {Message}", message);
        await _inner.SendAsync(message);
        _logger.LogInformation("Notification sent successfully");
    }
}

// Registration (manual decoration)
services.AddScoped<EmailNotificationService>();
services.AddScoped<INotificationService>(sp =>
{
    var emailService = sp.GetRequiredService<EmailNotificationService>();
    var logger = sp.GetRequiredService<ILogger<LoggingNotificationDecorator>>();
    return new LoggingNotificationDecorator(emailService, logger);
});

Keyed Services (New in .NET 8+)

.NET 10 continues to support keyed services, allowing multiple implementations with identifiers:

// Register multiple implementations with keys
services.AddKeyedScoped<IStorageService, AzureBlobStorage>("azure");
services.AddKeyedScoped<IStorageService, AWSS3Storage>("aws");
services.AddKeyedScoped<IStorageService, LocalFileStorage>("local");

// Inject specific implementation
public class FileController : ControllerBase
{
    private readonly IStorageService _storage;
    
    public FileController(
        [FromKeyedServices("azure")] IStorageService storage)
    {
        _storage = storage;
    }
    
    // Or resolve dynamically
    public void ProcessFile(
        string provider,
        [FromKeyedServices] IServiceProvider serviceProvider)
    {
        var storage = serviceProvider
            .GetRequiredKeyedService<IStorageService>(provider);
        // Use storage...
    }
}

Testing with Dependency Injection πŸ§ͺ

DI makes unit testing significantly easier by allowing test doubles (mocks, stubs, fakes).

Example: Testing a Service with Dependencies

// Service under test
public class OrderService
{
    private readonly IRepository<Order> _orderRepo;
    private readonly IEmailService _emailService;
    private readonly ILogger<OrderService> _logger;
    
    public OrderService(
        IRepository<Order> orderRepo,
        IEmailService emailService,
        ILogger<OrderService> logger)
    {
        _orderRepo = orderRepo;
        _emailService = emailService;
        _logger = logger;
    }
    
    public async Task<bool> PlaceOrderAsync(Order order)
    {
        await _orderRepo.AddAsync(order);
        await _emailService.SendOrderConfirmationAsync(order);
        _logger.LogInformation("Order {OrderId} placed", order.Id);
        return true;
    }
}

// Unit test using Moq
[Fact]
public async Task PlaceOrderAsync_SendsEmailAndLogsSuccess()
{
    // Arrange
    var mockRepo = new Mock<IRepository<Order>>();
    var mockEmail = new Mock<IEmailService>();
    var mockLogger = new Mock<ILogger<OrderService>>();
    
    var service = new OrderService(
        mockRepo.Object,
        mockEmail.Object,
        mockLogger.Object);
    
    var order = new Order { Id = 123, Total = 99.99m };
    
    // Act
    var result = await service.PlaceOrderAsync(order);
    
    // Assert
    Assert.True(result);
    mockRepo.Verify(r => r.AddAsync(order), Times.Once);
    mockEmail.Verify(e => 
        e.SendOrderConfirmationAsync(order), Times.Once);
}

Integration Testing with WebApplicationFactory

.NET 10's WebApplicationFactory makes integration testing seamless:

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove real database
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor != null)
                services.Remove(descriptor);
            
            // Add in-memory database
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseInMemoryDatabase("TestDb");
            });
            
            // Replace real email service with fake
            services.Replace(ServiceDescriptor.Scoped<IEmailService, FakeEmailService>());
        });
    }
}

[Fact]
public async Task CreateProduct_ReturnsCreatedProduct()
{
    // Arrange
    await using var factory = new CustomWebApplicationFactory();
    var client = factory.CreateClient();
    
    var newProduct = new { Name = "Widget", Price = 29.99 };
    
    // Act
    var response = await client.PostAsJsonAsync("/api/products", newProduct);
    
    // Assert
    response.EnsureSuccessStatusCode();
    var product = await response.Content.ReadFromJsonAsync<Product>();
    Assert.Equal("Widget", product.Name);
}

Common Mistakes to Avoid ⚠️

1. Injecting Scoped Services into Singletons

// ❌ WRONG: This will throw at runtime with validation enabled
public class MySingleton
{
    private readonly AppDbContext _context; // Scoped!
    
    public MySingleton(AppDbContext context) // CAPTIVE!
    {
        _context = context;
    }
}

services.AddSingleton<MySingleton>();
services.AddDbContext<AppDbContext>(); // Scoped by default

Fix: Use IServiceScopeFactory to resolve scoped dependencies on-demand.

2. Disposing Services You Didn't Create

// ❌ WRONG: Never dispose services resolved from DI
public class MyController : ControllerBase
{
    public async Task<IActionResult> Get(
        [FromServices] AppDbContext context)
    {
        try
        {
            var data = await context.Users.ToListAsync();
            return Ok(data);
        }
        finally
        {
            context.Dispose(); // DON'T DO THIS!
        }
    }
}

Rule: The DI container manages the lifetime of services it creates. Only dispose services you instantiate with new.

3. Overusing Service Locator Pattern

// ❌ ANTI-PATTERN: Service Locator hides dependencies
public class OrderProcessor
{
    private readonly IServiceProvider _serviceProvider;
    
    public OrderProcessor(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public void Process(Order order)
    {
        // Hidden dependencies!
        var repo = _serviceProvider.GetRequiredService<IRepository<Order>>();
        var email = _serviceProvider.GetRequiredService<IEmailService>();
        var logger = _serviceProvider.GetRequiredService<ILogger>();
        // ...
    }
}
// βœ… BETTER: Explicit constructor injection
public class OrderProcessor
{
    private readonly IRepository<Order> _repo;
    private readonly IEmailService _email;
    private readonly ILogger<OrderProcessor> _logger;
    
    public OrderProcessor(
        IRepository<Order> repo,
        IEmailService email,
        ILogger<OrderProcessor> logger)
    {
        _repo = repo;
        _email = email;
        _logger = logger;
    }
}

πŸ’‘ Exception: Service Locator is acceptable for factories, middleware, and when you genuinely need dynamic resolution.

4. Circular Dependencies

// ❌ WRONG: Circular dependency
public class ServiceA
{
    public ServiceA(ServiceB serviceB) { }
}

public class ServiceB
{
    public ServiceB(ServiceA serviceA) { } // Circular!
}

Detection: This throws immediately: "A circular dependency was detected"

Fixes:

  1. Refactor to remove the cycle (extract shared logic)
  2. Use Lazy<T> to break the cycle
  3. Inject IServiceProvider into one service

5. Registering the Same Service Multiple Times

// What happens here?
services.AddScoped<IEmailService, SmtpEmailService>();
services.AddScoped<IEmailService, SendGridEmailService>();

// Answer: The LAST registration wins
// But GetServices<IEmailService>() returns ALL registrations

πŸ’‘ Tip: Use TryAdd methods to register only if not already registered:

services.TryAddScoped<IEmailService, SmtpEmailService>();
services.TryAddScoped<IEmailService, SendGridEmailService>(); // Ignored

Performance Considerations πŸš€

Service Resolution Overhead

Resolving services has a small cost. For hot paths:

// ❌ SLOWER: Resolve on every request
public class MyMiddleware
{
    private readonly RequestDelegate _next;
    
    public MyMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    
    public async Task InvokeAsync(
        HttpContext context,
        IServiceProvider serviceProvider)
    {
        // Resolving in the hot path
        var service = serviceProvider.GetRequiredService<IMyService>();
        await service.DoWorkAsync();
        await _next(context);
    }
}

// βœ… FASTER: Constructor injection when possible
public class MyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IMyService _service;
    
    public MyMiddleware(RequestDelegate next, IMyService service)
    {
        _next = next;
        _service = service; // Resolved once
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        await _service.DoWorkAsync();
        await _next(context);
    }
}

Avoiding Transient Disposables

// ⚠️ CAUTION: Transient + IDisposable = memory leak risk
services.AddTransient<IHeavyService, HeavyService>(); // IDisposable!

// Problem: Container tracks transients for disposal,
// but doesn't dispose them until scope ends.
// If you create many transients in a long-running scope,
// they accumulate in memory!

Solution: Use Scoped for disposable services, or manage disposal manually.

Key Takeaways 🎯

πŸ“‹ DI Mastery Quick Reference

Concept Key Point
Transient New instance every time - use for stateless, lightweight services
Scoped One per request - default for DbContext, Unit of Work
Singleton One forever - must be thread-safe, use for config/caching
Captive Dependencies Never inject shorter-lived services into longer-lived ones
IServiceScopeFactory Safe way for Singletons to resolve Scoped services
Validation Enable ValidateScopes and ValidateOnBuild in Development
Testing DI enables easy mocking and WebApplicationFactory for integration tests
Keyed Services Use for multiple implementations of same interface
Disposal Container manages disposal - never dispose DI-resolved services
Service Locator Avoid except for factories, middleware, dynamic scenarios

🧠 Memory Devices for DI Lifetimes

TSS = "This Stays Short"

  • Transient - new instance every time
  • Scoped - one per request (Short-lived)
  • Singleton - one forever (Stays longest)

Remember: "Singleton Should be Safe" (thread-safe)

πŸ”§ Try This: Hands-On Exercise

Create a simple API that demonstrates all three lifetimes:

public interface IOperationTransient { Guid Id { get; } }
public interface IOperationScoped { Guid Id { get; } }
public interface IOperationSingleton { Guid Id { get; } }

public class Operation : 
    IOperationTransient, IOperationScoped, IOperationSingleton
{
    public Guid Id { get; } = Guid.NewGuid();
}

// In Program.cs
services.AddTransient<IOperationTransient, Operation>();
services.AddScoped<IOperationScoped, Operation>();
services.AddSingleton<IOperationSingleton, Operation>();

// In a controller
[ApiController]
[Route("api/[controller]")]
public class OperationsController : ControllerBase
{
    private readonly IOperationTransient _transient1;
    private readonly IOperationTransient _transient2;
    private readonly IOperationScoped _scoped1;
    private readonly IOperationScoped _scoped2;
    private readonly IOperationSingleton _singleton1;
    private readonly IOperationSingleton _singleton2;
    
    public OperationsController(
        IOperationTransient transient1,
        IOperationTransient transient2,
        IOperationScoped scoped1,
        IOperationScoped scoped2,
        IOperationSingleton singleton1,
        IOperationSingleton singleton2)
    {
        _transient1 = transient1;
        _transient2 = transient2;
        _scoped1 = scoped1;
        _scoped2 = scoped2;
        _singleton1 = singleton1;
        _singleton2 = singleton2;
    }
    
    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            Transient1 = _transient1.Id,
            Transient2 = _transient2.Id, // Different from Transient1
            Scoped1 = _scoped1.Id,
            Scoped2 = _scoped2.Id, // Same as Scoped1
            Singleton1 = _singleton1.Id,
            Singleton2 = _singleton2.Id // Same as Singleton1
        });
    }
}

What you'll observe:

  • Transient1 β‰  Transient2 (different GUIDs)
  • Scoped1 = Scoped2 (same GUID within request)
  • Singleton1 = Singleton2 = same across all requests

πŸ“š Further Study


πŸŽ“ Congratulations! You've mastered Dependency Injection in ASP.NET with .NET 10. You now understand service lifetimes, can avoid common pitfalls like captive dependencies, implement advanced patterns like decorators and factories, and write testable code using DI principles. Practice these patterns in your projects to internalize them!