Dependency injection (DI) is a fundamental design pattern in modern .NET development. It helps you write loosely coupled, testable code by inverting the control of dependency creation. Instead of classes creating their own dependencies, they receive them from an external source.

This guide covers everything from basic service registration to advanced patterns you will encounter in real applications.

Understanding Dependency Injection

Without dependency injection, classes create their own dependencies:

// Tightly coupled code
public class OrderService
{
    private readonly SqlOrderRepository _repository;
    private readonly EmailNotificationService _notifier;

    public OrderService()
    {
        // The class controls its dependencies
        _repository = new SqlOrderRepository();
        _notifier = new EmailNotificationService();
    }

    public async Task CreateOrderAsync(Order order)
    {
        await _repository.SaveAsync(order);
        await _notifier.SendOrderConfirmationAsync(order);
    }
}

This code is hard to test because you cannot substitute the repository or notification service with test doubles. With dependency injection:

// Loosely coupled code
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly INotificationService _notifier;

    public OrderService(IOrderRepository repository, INotificationService notifier)
    {
        _repository = repository;
        _notifier = notifier;
    }

    public async Task CreateOrderAsync(Order order)
    {
        await _repository.SaveAsync(order);
        await _notifier.SendOrderConfirmationAsync(order);
    }
}

Now dependencies are injected through the constructor, and you can easily swap implementations for testing or different environments.

Service Registration in ASP.NET Core

ASP.NET Core has a built-in DI container. Register services in Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Register services with different lifetimes
builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

var app = builder.Build();
app.Run();

Service Lifetimes

The three service lifetimes determine how long instances live:

Singleton

A single instance is created and reused for the entire application lifetime:

builder.Services.AddSingleton<ICache, MemoryCache>();

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

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

    public object? Get(string key)
    {
        return _cache.TryGetValue(key, out var value) ? value : null;
    }
}

Use singletons for:

  • Configuration services
  • Caches
  • Logging services
  • Services that are expensive to create and thread-safe

Scoped

A new instance is created for each scope. In web applications, each HTTP request creates a new scope:

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

public class SqlOrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;

    public SqlOrderRepository(ApplicationDbContext context)
    {
        // DbContext is also scoped, so we get a fresh one per request
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(int id)
    {
        return await _context.Orders.FindAsync(id);
    }
}

Use scoped for:

  • Database contexts
  • Repositories
  • Unit of work implementations
  • Services that maintain state during a request

Transient

A new instance is created every time the service is requested:

builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<IGuidGenerator, GuidGenerator>();

public class GuidGenerator : IGuidGenerator
{
    public Guid Generate() => Guid.NewGuid();
}

Use transient for:

  • Lightweight, stateless services
  • Services that should not be shared

Multiple Registration Methods

There are several ways to register services:

// Generic registration
builder.Services.AddSingleton<IService, ServiceImplementation>();

// Instance registration
var config = new ConfigurationService();
builder.Services.AddSingleton<IConfigurationService>(config);

// Factory registration
builder.Services.AddSingleton<IHttpClient>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var client = new HttpClient
    {
        BaseAddress = new Uri(config["ApiBaseUrl"]!),
        Timeout = TimeSpan.FromSeconds(30)
    };
    return new HttpClientWrapper(client);
});

// Self-registration (no interface)
builder.Services.AddScoped<OrderService>();

// Try methods (only register if not already registered)
builder.Services.TryAddSingleton<ICache, MemoryCache>();
builder.Services.TryAddScoped<IOrderRepository, SqlOrderRepository>();

Constructor Injection

The most common injection pattern is constructor injection:

public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly ILogger<OrderController> _logger;

    public OrderController(IOrderService orderService, ILogger<OrderController> logger)
    {
        _orderService = orderService;
        _logger = logger;
    }

    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
    {
        _logger.LogInformation("Creating order for customer {CustomerId}", request.CustomerId);
        var order = await _orderService.CreateAsync(request);
        return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    }
}

Property Injection

While less common, you can use property injection with the [FromServices] attribute in controllers:

public class ProductController : ControllerBase
{
    [FromServices]
    public IProductService ProductService { get; set; } = default!;

    [HttpGet]
    public async Task<IActionResult> GetProducts()
    {
        return Ok(await ProductService.GetAllAsync());
    }
}

Method Injection

Inject services directly into action methods:

public class ReportController : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> GenerateReport(
        [FromServices] IReportGenerator generator,
        [FromServices] IReportExporter exporter,
        [FromQuery] string format)
    {
        var report = await generator.GenerateAsync();
        var result = await exporter.ExportAsync(report, format);
        return File(result, "application/octet-stream");
    }
}

Options Pattern

The options pattern provides strongly-typed access to configuration:

// Configuration class
public class EmailSettings
{
    public string SmtpServer { get; set; } = string.Empty;
    public int Port { get; set; }
    public string Username { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public bool UseSsl { get; set; }
}

// Registration
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("Email"));

// Usage
public class EmailService : IEmailService
{
    private readonly EmailSettings _settings;

    public EmailService(IOptions<EmailSettings> options)
    {
        _settings = options.Value;
    }

    public async Task SendAsync(string to, string subject, string body)
    {
        using var client = new SmtpClient(_settings.SmtpServer, _settings.Port);
        client.EnableSsl = _settings.UseSsl;
        client.Credentials = new NetworkCredential(_settings.Username, _settings.Password);
        await client.SendMailAsync("noreply@example.com", to, subject, body);
    }
}

For settings that can change at runtime, use IOptionsMonitor:

public class DynamicFeatureService
{
    private readonly IOptionsMonitor<FeatureFlags> _options;

    public DynamicFeatureService(IOptionsMonitor<FeatureFlags> options)
    {
        _options = options;
    }

    public bool IsFeatureEnabled(string featureName)
    {
        // Always gets the current value
        return _options.CurrentValue.EnabledFeatures.Contains(featureName);
    }
}

Keyed Services (.NET 8+)

Keyed services let you register multiple implementations of the same interface:

// Registration
builder.Services.AddKeyedSingleton<IPaymentProcessor, StripePaymentProcessor>("stripe");
builder.Services.AddKeyedSingleton<IPaymentProcessor, PayPalPaymentProcessor>("paypal");
builder.Services.AddKeyedSingleton<IPaymentProcessor, BraintreePaymentProcessor>("braintree");

// Injection with attribute
public class PaymentController : ControllerBase
{
    private readonly IPaymentProcessor _stripeProcessor;

    public PaymentController(
        [FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor)
    {
        _stripeProcessor = stripeProcessor;
    }
}

// Resolution from service provider
public class PaymentService
{
    private readonly IServiceProvider _serviceProvider;

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

    public async Task ProcessPayment(string provider, decimal amount)
    {
        var processor = _serviceProvider.GetRequiredKeyedService<IPaymentProcessor>(provider);
        await processor.ProcessAsync(amount);
    }
}

Factory Pattern

Use factories when you need to create services with runtime parameters:

public interface IConnectionFactory
{
    IDatabaseConnection Create(string connectionString);
}

public class ConnectionFactory : IConnectionFactory
{
    private readonly ILogger<ConnectionFactory> _logger;

    public ConnectionFactory(ILogger<ConnectionFactory> logger)
    {
        _logger = logger;
    }

    public IDatabaseConnection Create(string connectionString)
    {
        _logger.LogInformation("Creating connection to {Server}",
            ExtractServer(connectionString));
        return new SqlConnection(connectionString);
    }

    private string ExtractServer(string connectionString)
    {
        // Extract server name from connection string
        return "server";
    }
}

// Registration
builder.Services.AddSingleton<IConnectionFactory, ConnectionFactory>();

// Usage
public class MultiDatabaseService
{
    private readonly IConnectionFactory _connectionFactory;
    private readonly IConfiguration _configuration;

    public MultiDatabaseService(
        IConnectionFactory connectionFactory,
        IConfiguration configuration)
    {
        _connectionFactory = connectionFactory;
        _configuration = configuration;
    }

    public async Task<T> ExecuteOnDatabaseAsync<T>(string databaseName, Func<IDatabaseConnection, Task<T>> action)
    {
        var connectionString = _configuration.GetConnectionString(databaseName);
        using var connection = _connectionFactory.Create(connectionString!);
        return await action(connection);
    }
}

Decorators

Decorate services to add cross-cutting concerns:

public interface IOrderService
{
    Task<Order> CreateOrderAsync(CreateOrderRequest request);
}

public class OrderService : IOrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        var order = new Order(request);
        await _repository.SaveAsync(order);
        return order;
    }
}

// Decorator that adds logging
public class LoggingOrderServiceDecorator : IOrderService
{
    private readonly IOrderService _inner;
    private readonly ILogger<LoggingOrderServiceDecorator> _logger;

    public LoggingOrderServiceDecorator(IOrderService inner, ILogger<LoggingOrderServiceDecorator> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        _logger.LogInformation("Creating order for customer {CustomerId}", request.CustomerId);
        var stopwatch = Stopwatch.StartNew();

        var order = await _inner.CreateOrderAsync(request);

        _logger.LogInformation("Order {OrderId} created in {ElapsedMs}ms",
            order.Id, stopwatch.ElapsedMilliseconds);
        return order;
    }
}

// Registration with decorator
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<IOrderService>(sp =>
{
    var inner = sp.GetRequiredService<OrderService>();
    var logger = sp.GetRequiredService<ILogger<LoggingOrderServiceDecorator>>();
    return new LoggingOrderServiceDecorator(inner, logger);
});

Generic Service Registration

Register open generic types:

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(int id);
}

public class Repository<T> : IRepository<T> where T : class
{
    private readonly ApplicationDbContext _context;

    public Repository(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<T?> GetByIdAsync(int id)
    {
        return await _context.Set<T>().FindAsync(id);
    }

    public async Task<IEnumerable<T>> GetAllAsync()
    {
        return await _context.Set<T>().ToListAsync();
    }

    public async Task AddAsync(T entity)
    {
        await _context.Set<T>().AddAsync(entity);
        await _context.SaveChangesAsync();
    }

    public async Task UpdateAsync(T entity)
    {
        _context.Set<T>().Update(entity);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var entity = await GetByIdAsync(id);
        if (entity is not null)
        {
            _context.Set<T>().Remove(entity);
            await _context.SaveChangesAsync();
        }
    }
}

// Registration
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

// Usage
public class ProductService
{
    private readonly IRepository<Product> _productRepository;
    private readonly IRepository<Category> _categoryRepository;

    public ProductService(
        IRepository<Product> productRepository,
        IRepository<Category> categoryRepository)
    {
        _productRepository = productRepository;
        _categoryRepository = categoryRepository;
    }
}

Common Pitfalls

Captive Dependencies

A singleton should never depend on a scoped or transient service:

// BAD: Singleton captures scoped service
public class SingletonService
{
    private readonly IScopedService _scoped; // This is wrong!

    public SingletonService(IScopedService scoped)
    {
        _scoped = scoped;
    }
}

// GOOD: Use IServiceScopeFactory instead
public class SingletonService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public SingletonService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public async Task DoWorkAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var scopedService = scope.ServiceProvider.GetRequiredService<IScopedService>();
        await scopedService.DoSomethingAsync();
    }
}

Service Locator Anti-Pattern

Avoid injecting IServiceProvider and resolving services manually when possible:

// BAD: Service locator
public class OrderService
{
    private readonly IServiceProvider _serviceProvider;

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

    public async Task ProcessOrderAsync(Order order)
    {
        var repository = _serviceProvider.GetRequiredService<IOrderRepository>();
        var notifier = _serviceProvider.GetRequiredService<INotificationService>();
        // ...
    }
}

// GOOD: Explicit dependencies
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly INotificationService _notifier;

    public OrderService(IOrderRepository repository, INotificationService notifier)
    {
        _repository = repository;
        _notifier = notifier;
    }
}

Conclusion

Dependency injection is central to building maintainable .NET applications. The built-in DI container handles most scenarios, and understanding service lifetimes, registration patterns, and common pitfalls will help you design better applications.

Key takeaways:

  • Use constructor injection as the default approach
  • Choose the right lifetime for each service
  • Use the options pattern for configuration
  • Avoid captive dependencies
  • Prefer explicit dependencies over service locator
  • Use keyed services for multiple implementations of the same interface

With these patterns, you can build applications that are loosely coupled, easy to test, and straightforward to maintain.