Skip to main content
Technology & EngineeringCsharp Dotnet238 lines

Dependency Injection

Dependency injection patterns and service registration in .NET's built-in DI container

Quick Summary31 lines
You are an expert in dependency injection for building loosely coupled, testable .NET applications.

## Key Points

- **Depend on abstractions** (`IOrderService`) not concrete types (`OrderService`) to enable testing and substitution.
- **Keep constructors simple** — only assign fields. Do not perform I/O, heavy computation, or call virtual methods in constructors.
- **Avoid the service locator pattern** — do not inject `IServiceProvider` and resolve services manually. Prefer constructor injection.
- **Validate options on startup** — use `ValidateOnStart()` so misconfiguration fails fast during application boot, not at first request.
- **Use `AddHttpClient`** for HTTP dependencies — it manages `HttpMessageHandler` lifetimes and avoids socket exhaustion.
- **Resolving scoped services from the root provider** — creating a scope is required outside of an HTTP request context (e.g., in a background service).
- **Registering the same interface multiple times unintentionally** — the last registration wins for single-instance resolution. Use `TryAdd*` methods to avoid overwriting.

## Quick Example

```csharp
// Register a generic interface with a generic implementation
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddTransient(typeof(IValidator<>), typeof(DataAnnotationsValidator<>));

// Now IRepository<Product>, IRepository<Order>, etc. all resolve automatically
```

```csharp
builder.Services.Scan(scan => scan
    .FromAssemblyOf<IOrderService>()
    .AddClasses(c => c.AssignableTo<IOrderService>())
    .AsImplementedInterfaces()
    .WithScopedLifetime());
```
skilldb get csharp-dotnet-skills/Dependency InjectionFull skill: 238 lines
Paste into your CLAUDE.md or agent config

Dependency Injection — C#/.NET

You are an expert in dependency injection for building loosely coupled, testable .NET applications.

Core Philosophy

Overview

.NET ships with a built-in DI container (Microsoft.Extensions.DependencyInjection) that is the backbone of ASP.NET Core, Worker Services, and MAUI. It supports constructor injection, service lifetimes, keyed services, and open-generic registration. Understanding DI is fundamental to writing idiomatic .NET code.

Core Concepts

Service Lifetimes

var builder = WebApplication.CreateBuilder(args);

// Transient: new instance every time it is requested
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

// Scoped: one instance per HTTP request (or per scope)
builder.Services.AddScoped<IOrderService, OrderService>();

// Singleton: one instance for the entire application lifetime
builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();
LifetimeCreated WhenDisposed WhenTypical Use
TransientEvery resolutionEnd of scopeLightweight, stateless logic
ScopedFirst resolution in scopeEnd of scopeDbContext, per-request state
SingletonFirst resolutionApplication shutdownCaches, configuration, HttpClient factories

Constructor Injection

public class OrderService : IOrderService
{
    private readonly IOrderRepository _repo;
    private readonly IEmailSender _email;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository repo,
        IEmailSender email,
        ILogger<OrderService> logger)
    {
        _repo = repo;
        _email = email;
        _logger = logger;
    }

    public async Task<Order> PlaceOrderAsync(CreateOrderRequest request)
    {
        _logger.LogInformation("Placing order for {Customer}", request.CustomerId);
        var order = await _repo.CreateAsync(request);
        await _email.SendOrderConfirmationAsync(order);
        return order;
    }
}

Keyed Services (.NET 8+)

builder.Services.AddKeyedSingleton<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedSingleton<INotificationSender, SmsSender>("sms");

public class NotificationService(
    [FromKeyedServices("email")] INotificationSender email,
    [FromKeyedServices("sms")] INotificationSender sms)
{
    public Task NotifyAsync(string message, string channel) => channel switch
    {
        "email" => email.SendAsync(message),
        "sms" => sms.SendAsync(message),
        _ => throw new ArgumentException($"Unknown channel: {channel}")
    };
}

Implementation Patterns

Options Pattern for Configuration

public class SmtpOptions
{
    public const string SectionName = "Smtp";
    public required string Host { get; set; }
    public int Port { get; set; } = 587;
    public required string Username { get; set; }
    public required string Password { get; set; }
}

// Registration
builder.Services
    .AddOptions<SmtpOptions>()
    .BindConfiguration(SmtpOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Injection
public class SmtpEmailSender(IOptions<SmtpOptions> options) : IEmailSender
{
    private readonly SmtpOptions _smtp = options.Value;
}

Factory Registration

// Register using a factory delegate when construction is non-trivial
builder.Services.AddScoped<IPaymentGateway>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var env = sp.GetRequiredService<IHostEnvironment>();

    return env.IsDevelopment()
        ? new FakePaymentGateway()
        : new StripePaymentGateway(config["Stripe:ApiKey"]!);
});

Decorator Pattern

public class CachedProductRepository : IProductRepository
{
    private readonly IProductRepository _inner;
    private readonly IMemoryCache _cache;

    public CachedProductRepository(IProductRepository inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<Product?> GetByIdAsync(int id)
    {
        return await _cache.GetOrCreateAsync($"product:{id}",
            async entry =>
            {
                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
                return await _inner.GetByIdAsync(id);
            });
    }
}

// Registration (manual decorator wiring)
builder.Services.AddScoped<ProductRepository>();
builder.Services.AddScoped<IProductRepository>(sp =>
    new CachedProductRepository(
        sp.GetRequiredService<ProductRepository>(),
        sp.GetRequiredService<IMemoryCache>()));

Open Generic Registration

// Register a generic interface with a generic implementation
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddTransient(typeof(IValidator<>), typeof(DataAnnotationsValidator<>));

// Now IRepository<Product>, IRepository<Order>, etc. all resolve automatically

Assembly Scanning with Scrutor

builder.Services.Scan(scan => scan
    .FromAssemblyOf<IOrderService>()
    .AddClasses(c => c.AssignableTo<IOrderService>())
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Best Practices

  • Depend on abstractions (IOrderService) not concrete types (OrderService) to enable testing and substitution.
  • Keep constructors simple — only assign fields. Do not perform I/O, heavy computation, or call virtual methods in constructors.
  • Avoid the service locator pattern — do not inject IServiceProvider and resolve services manually. Prefer constructor injection.
  • Validate options on startup — use ValidateOnStart() so misconfiguration fails fast during application boot, not at first request.
  • Use AddHttpClient for HTTP dependencies — it manages HttpMessageHandler lifetimes and avoids socket exhaustion.
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
    client.BaseAddress = new Uri("https://api.github.com/");
    client.DefaultRequestHeaders.UserAgent.ParseAdd("MyApp/1.0");
});

Common Pitfalls

  • Captive dependency — a singleton holding a reference to a scoped or transient service keeps it alive for the entire application lifetime, bypassing its intended disposal. The DI container logs a warning for this; enable ValidateScopes in development.
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});
  • Resolving scoped services from the root provider — creating a scope is required outside of an HTTP request context (e.g., in a background service).
public class MyBackgroundService(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var scope = scopeFactory.CreateScope();
        var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>();
        await orderService.ProcessPendingAsync(ct);
    }
}
  • Registering the same interface multiple times unintentionally — the last registration wins for single-instance resolution. Use TryAdd* methods to avoid overwriting.
  • Injecting IOptions<T> when IOptionsSnapshot<T> is neededIOptions<T> reads configuration once at startup; IOptionsSnapshot<T> re-reads per scope, which is necessary for reloadable configuration.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add csharp-dotnet-skills

Get CLI access →