Skip to main content

Dependency Injection Deep Dive

Overview

Dependency Injection (DI) is a fundamental design pattern in ASP.NET Core, built into the framework's core. It promotes loose coupling, testability, and maintainability.

Service Lifetimes

ASP.NET Core supports three service lifetimes:

1. Transient

New instance created every time the service is requested.

builder.Services.AddTransient<IEmailService, EmailService>();

When to use:

  • Lightweight, stateless services
  • Services with no shared state
  • Short-lived operations

Example:

public interface IEmailService
{
Task SendEmailAsync(string to, string subject, string body);
}

public class EmailService : IEmailService
{
public async Task SendEmailAsync(string to, string subject, string body)
{
// Each call gets a new instance
await Task.Delay(100); // Simulate sending
}
}

2. Scoped

One instance per HTTP request (or scope).

builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<DbContext, ApplicationDbContext>();

When to use:

  • Database contexts (EF Core)
  • Services that should share state within a request
  • Unit of Work pattern

Example:

public class ProductService : IProductService
{
private readonly ApplicationDbContext _context;

public ProductService(ApplicationDbContext context)
{
_context = context; // Same context instance for entire request
}

public async Task<Product> GetByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}
}

Important: Scoped services are disposed at the end of the request.

3. Singleton

One instance for the entire application lifetime.

builder.Services.AddSingleton<ICacheService, CacheService>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);

When to use:

  • Configuration objects
  • Caching services
  • Logging
  • Thread-safe shared state

Example:

public class CacheService : ICacheService
{
private readonly ConcurrentDictionary<string, object> _cache = new();

public void Set(string key, object value)
{
_cache[key] = value;
}

public T Get\<T\>(string key)
{
_cache.TryGetValue(key, out var value);
return (T)value;
}
}

⚠️ Warning: Singletons must be thread-safe!

Lifetime Comparison Table

LifetimeCreatedDisposedUse Case
TransientEvery requestAfter useLightweight, stateless
ScopedOnce per HTTP requestEnd of requestDbContext, request data
SingletonOnce per appApp shutdownConfig, cache, logging

Common Pitfalls

❌ Captive Dependencies

Problem: Longer-lived service depends on shorter-lived service

// WRONG! Singleton depends on Scoped
public class SingletonService
{
private readonly ScopedService _scoped; // BAD!

public SingletonService(ScopedService scoped)
{
_scoped = scoped; // This scoped instance will never be disposed!
}
}

Solution: Use IServiceProvider or refactor to proper lifetime

// CORRECT
public class SingletonService
{
private readonly IServiceProvider _serviceProvider;

public SingletonService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

public void DoWork()
{
using var scope = _serviceProvider.CreateScope();
var scopedService = scope.ServiceProvider.GetRequiredService<ScopedService>();
// Use scoped service
}
}

Registration Patterns

Basic Registration

// Interface → Implementation
builder.Services.AddScoped<IProductService, ProductService>();

// Concrete type only
builder.Services.AddScoped<ProductService>();

// Factory pattern
builder.Services.AddScoped<IProductService>(sp =>
{
var logger = sp.GetRequiredService<ILogger<ProductService>>();
return new ProductService(logger);
});

Multiple Implementations

// Register multiple implementations
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<INotificationService, SmsNotificationService>();

// Retrieve all
public class NotificationDispatcher
{
private readonly IEnumerable<INotificationService> _services;

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

public async Task NotifyAllAsync(string message)
{
foreach (var service in _services)
{
await service.SendAsync(message);
}
}
}

TryAdd Methods

// Only registers if not already registered
builder.Services.TryAddScoped<IProductService, ProductService>();

// Useful in libraries to provide default implementations

Replace and Remove

// Replace existing registration
builder.Services.Replace(ServiceDescriptor.Scoped<IProductService, NewProductService>());

// Remove registration
builder.Services.RemoveAll<IProductService>();

Constructor Injection

Preferred method - framework automatically resolves dependencies:

public class ProductController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger<ProductController> _logger;

public ProductController(
IProductService productService,
ILogger<ProductController> logger)
{
_productService = productService;
_logger = logger;
}
}

Method Injection (Minimal APIs)

app.MapGet("/api/products", async (IProductService productService) =>
{
var products = await productService.GetAllAsync();
return Results.Ok(products);
});

Property Injection

Not supported by default - use [FromServices] in controllers:

[ApiController]
public class ProductController : ControllerBase
{
[FromServices]
public IProductService ProductService { get; set; }
}

Service Locator Anti-Pattern

Avoid using IServiceProvider directly in business logic:

// ANTI-PATTERN
public class ProductService
{
private readonly IServiceProvider _serviceProvider;

public ProductService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

public void DoSomething()
{
var dependency = _serviceProvider.GetService<IDependency>(); // BAD!
}
}

Use constructor injection instead:

// GOOD PATTERN
public class ProductService
{
private readonly IDependency _dependency;

public ProductService(IDependency dependency)
{
_dependency = dependency;
}

public void DoSomething()
{
_dependency.Execute(); // GOOD!
}
}

Real-World Example

// Service Registration
public static class ServiceRegistration
{
public static IServiceCollection AddApplicationServices(
this IServiceCollection services)
{
// Scoped - per request
services.AddScoped<IProductService, ProductService>();
services.AddScoped<IOrderService, OrderService>();

// Singleton - shared state
services.AddSingleton<ICacheService, CacheService>();

// Transient - stateless
services.AddTransient<IEmailService, EmailService>();
services.AddTransient<IPdfGenerator, PdfGenerator>();

return services;
}
}

// Usage in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplicationServices();

Interview Questions

Q: Explain the three DI lifetimes and when to use each. A:

  • Transient: New instance every time, used for lightweight stateless services
  • Scoped: One instance per HTTP request, used for DbContext and request-specific data
  • Singleton: One instance for app lifetime, used for configuration, caching, must be thread-safe

Q: What is a captive dependency and why is it a problem? A: When a longer-lived service (Singleton) holds a reference to a shorter-lived service (Scoped/Transient), preventing proper disposal and potentially causing memory leaks or stale data.

Q: Why is IServiceProvider considered an anti-pattern? A: It hides dependencies, makes testing harder, and defeats the purpose of dependency injection. Constructor injection makes dependencies explicit and testable.

Resources