Dependency Injection in .NET: Core Concepts and Implementation

14 min readFebruary 9, 2026Updated: Mar 9, 2026
Dependency Injection .NETDI .NET Core.NET service lifetimesTransient Scoped Singleton.NET IoC containerConstructor injection C#.NET best practicesLoose coupling .NET

# Dependency Injection in .NET: Core Concepts and Implementation

Dependency Injection (DI) is central to ASP.NET Core architecture. It is not just a pattern you bolt on -- it is woven into the framework itself, from middleware to controllers to Razor Pages. Used correctly, DI keeps modules decoupled, testable, and easier to evolve. Used poorly, it introduces subtle bugs that only surface under load or in production.

In large .NET codebases, I've found that the quality of your DI setup is often a reliable predictor of overall code health. Teams that take time to understand lifetimes, registration strategies, and composition roots tend to produce systems that are far easier to maintain and extend.

Understanding Service Lifetimes

The built-in container in ASP.NET Core supports three lifetimes. Choosing the wrong one is one of the most common sources of bugs in .NET applications.

Transient

A new instance is created every time the service is resolved. This is the safest default for lightweight, stateless services.

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

Use transient when the service holds no shared state and is cheap to construct. If the service opens a database connection or allocates significant memory, transient may be wasteful.

Scoped

One instance per request scope. In ASP.NET Core, this means one instance per HTTP request. This is the standard choice for services that work with request-level data.

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

Entity Framework's `DbContext` is registered as scoped by default, and for good reason -- it tracks entity changes across a single unit of work. Sharing it across requests would cause concurrency issues and stale data.

Singleton

One instance for the entire application lifetime. The instance is created on first resolution and reused for every subsequent request.

csharp
builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    ConnectionMultiplexer.Connect(configuration.GetConnectionString(class="code-string">"Redis")!));

Singletons must be thread-safe. They must not depend on scoped or transient services -- doing so creates the captive dependency problem discussed below.

Service Registration Patterns

Basic Interface Registration

The most common and recommended pattern is registering by interface. This creates a clear seam between the consumer and the implementation.

csharp
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<INotificationService, EmailNotificationService>();

Multiple Implementations

When you need multiple implementations of the same interface, you can register all of them and inject `IEnumerable`:

csharp
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<INotificationService, SmsNotificationService>();
builder.Services.AddScoped<INotificationService, PushNotificationService>();

class=class="code-string">"code-comment">// In the consumer:
public class OrderProcessor
{
    private readonly IEnumerable<INotificationService> _notifiers;

    public OrderProcessor(IEnumerable<INotificationService> notifiers)
    {
        _notifiers = notifiers;
    }

    public async Task NotifyAll(Order order)
    {
        foreach (var notifier in _notifiers)
            await notifier.SendAsync(order.ToNotification());
    }
}

Factory Registrations

Sometimes object creation needs runtime decisions. Factory delegates handle this cleanly:

csharp
builder.Services.AddScoped<IPaymentGateway>(sp =>
{
    var config = sp.GetRequiredService<IOptions<PaymentOptions>>().Value;
    return config.Provider switch
    {
        class="code-string">"Stripe" => new StripeGateway(config.ApiKey),
        class="code-string">"PayPal" => new PayPalGateway(config.ClientId, config.Secret),
        _ => throw new InvalidOperationException($class="code-string">"Unknown payment provider: {config.Provider}")
    };
});

Use factory registrations only when object creation truly needs runtime branching. If you find yourself writing factories for most registrations, that is a sign of a deeper design issue.

Keyed Services (.NET 8+)

.NET 8 introduced keyed services, which solve the "multiple implementations" problem more elegantly than factory hacks:

csharp
builder.Services.AddKeyedScoped<IStorageService, AzureBlobStorage>(class="code-string">"azure");
builder.Services.AddKeyedScoped<IStorageService, S3Storage>(class="code-string">"aws");
builder.Services.AddKeyedScoped<IStorageService, LocalFileStorage>(class="code-string">"local");

class=class="code-string">"code-comment">// In the consumer:
public class DocumentService
{
    private readonly IStorageService _storage;

    public DocumentService([FromKeyedServices(class="code-string">"azure")] IStorageService storage)
    {
        _storage = storage;
    }
}

Before .NET 8, this pattern required awkward workarounds with factory delegates or wrapper types. Keyed services make multi-implementation scenarios straightforward. In large .NET codebases, I've found that keyed services dramatically reduce the boilerplate that teams used to write for strategy patterns.

Options Pattern

For configuration-heavy services, the Options pattern provides a strongly-typed, validatable approach:

csharp
class=class="code-string">"code-comment">// Define the options class
public class SmtpOptions
{
    public const string SectionName = class="code-string">"Smtp";

    public string Host { get; set; } = string.Empty;
    public int Port { get; set; } = class="code-number">587;
    public string Username { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public bool UseSsl { get; set; } = true;
}

class=class="code-string">"code-comment">// Register with validation
builder.Services
    .AddOptions<SmtpOptions>()
    .BindConfiguration(SmtpOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

class=class="code-string">"code-comment">// Inject in the service
public class SmtpEmailSender : IEmailSender
{
    private readonly SmtpOptions _options;

    public SmtpEmailSender(IOptions<SmtpOptions> options)
    {
        _options = options.Value;
    }
}

Note the difference between `IOptions`, `IOptionsSnapshot`, and `IOptionsMonitor`. The first is a singleton that reads config once at startup. `IOptionsSnapshot` is scoped and re-reads on each request. `IOptionsMonitor` is singleton but supports change notifications. Pick the one that matches your reload requirements.

Lifetime Pitfalls

The Captive Dependency Problem

This is the single most dangerous DI mistake. A captive dependency occurs when a longer-lived service captures a shorter-lived one:

csharp
class=class="code-string">"code-comment">// WRONG: Singleton captures a scoped service
builder.Services.AddSingleton<ICacheWarmingService, CacheWarmingService>();
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();

public class CacheWarmingService : ICacheWarmingService
{
    private readonly IProductRepository _repo; class=class="code-string">"code-comment">// This is CAPTURED

    public CacheWarmingService(IProductRepository repo)
    {
        _repo = repo; class=class="code-string">"code-comment">// This scoped instance will live as long as the singleton
    }
}

What happens here? The `SqlProductRepository` was meant to live for one request, but the singleton holds a reference to it forever. If the repository uses `DbContext`, that context's change tracker will grow unbounded, connections will be held open, and you will see memory leaks and stale data -- often only visible under production load.

In development, you can catch this by enabling scope validation:

csharp
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;    class=class="code-string">"code-comment">// Throws on captive dependencies
    options.ValidateOnBuild = true;   class=class="code-string">"code-comment">// Validates all registrations at startup
});

Always enable both in development environments. The small startup cost is worth the bugs it prevents.

Disposing Transient Services

The DI container tracks and disposes `IDisposable` transient services. This means they are not garbage collected until their scope ends. If you resolve many disposable transients within a scope, memory will accumulate:

csharp
class=class="code-string">"code-comment">// Be careful: each resolved instance is tracked by the container
builder.Services.AddTransient<IDbConnection>(sp =>
    new SqlConnection(connectionString)); class=class="code-string">"code-comment">// Each one is held until scope disposes

For disposable transients, consider managing the lifetime yourself or using a factory that returns a non-tracked instance.

Service Locator Anti-Pattern

The service locator pattern means resolving services by calling `GetService` or `GetRequiredService` directly inside your business logic. This is an anti-pattern because it hides dependencies and makes code harder to test and reason about.

csharp
class=class="code-string">"code-comment">// BAD: Service locator -- dependencies are hidden
public class OrderProcessor
{
    private readonly IServiceProvider _provider;

    public OrderProcessor(IServiceProvider provider)
    {
        _provider = provider;
    }

    public void Process(Order order)
    {
        var repo = _provider.GetRequiredService<IOrderRepository>();
        var mailer = _provider.GetRequiredService<IEmailSender>();
        class=class="code-string">"code-comment">// ...
    }
}

class=class="code-string">"code-comment">// GOOD: Constructor injection -- dependencies are explicit
public class OrderProcessor
{
    private readonly IOrderRepository _repo;
    private readonly IEmailSender _mailer;

    public OrderProcessor(IOrderRepository repo, IEmailSender mailer)
    {
        _repo = repo;
        _mailer = mailer;
    }

    public void Process(Order order)
    {
        class=class="code-string">"code-comment">// Use _repo and _mailer directly
    }
}

The constructor injection version immediately tells you what the class needs. The service locator version hides those needs behind `IServiceProvider`, making it impossible to know the real dependencies without reading the entire implementation.

There are legitimate uses of `IServiceProvider` -- inside factory registrations, in middleware, or when building scopes manually for background tasks. But in domain and application services, it should never appear.

In large .NET codebases, I've found that service locator usage tends to spread once it is introduced. One developer reaches for it as a shortcut, and others follow. Catching it early in code reviews saves significant refactoring later.

Testing with DI

One of the biggest payoffs of proper DI is testability. When every dependency is injected through the constructor, swapping real implementations for test doubles is trivial.

Unit Testing with Mocks

csharp
public class OrderServiceTests
{
    [Fact]
    public async Task PlaceOrder_WithValidItems_SendsConfirmation()
    {
        class=class="code-string">"code-comment">// Arrange
        var mockRepo = new Mock<IOrderRepository>();
        mockRepo.Setup(r => r.SaveAsync(It.IsAny<Order>()))
                .ReturnsAsync(true);

        var mockMailer = new Mock<IEmailSender>();

        var service = new OrderService(mockRepo.Object, mockMailer.Object);

        class=class="code-string">"code-comment">// Act
        await service.PlaceOrderAsync(new Order { Items = testItems });

        class=class="code-string">"code-comment">// Assert
        mockMailer.Verify(m => m.SendAsync(
            It.Is<EmailMessage>(e => e.Subject.Contains(class="code-string">"Confirmation"))),
            Times.Once);
    }
}

Integration Testing with WebApplicationFactory

ASP.NET Core's `WebApplicationFactory` lets you replace real services with test implementations for integration tests:

csharp
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public OrdersApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                class=class="code-string">"code-comment">// Remove the real database context
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
                if (descriptor != null)
                    services.Remove(descriptor);

                class=class="code-string">"code-comment">// Add an in-memory database for testing
                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase(class="code-string">"TestDb"));

                class=class="code-string">"code-comment">// Swap the email sender with a fake
                services.AddScoped<IEmailSender, FakeEmailSender>();
            });
        });
    }

    [Fact]
    public async Task PostOrder_ReturnsCreated()
    {
        var client = _factory.CreateClient();

        var response = await client.PostAsJsonAsync(class="code-string">"/api/orders", new
        {
            Items = new[] { new { ProductId = class="code-number">1, Quantity = class="code-number">2 } }
        });

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    }
}

This approach gives you real HTTP pipeline testing without touching your production database or sending actual emails. The DI container makes these swaps clean and predictable.

Common DI Mistakes

1. Registering Everything

Not every class needs to go through DI. Simple data objects, value types, extension methods, and internal helpers do not belong in the container. Over-registering clutters the composition root and makes it harder to understand the real dependency graph.

2. Constructor Over-Injection

If a constructor takes more than four or five dependencies, the class is likely violating the Single Responsibility Principle. DI makes this visible -- that is a feature, not a problem to work around:

csharp
class=class="code-string">"code-comment">// This is a code smell, not a DI problem
public class MegaService(
    IOrderRepo orders,
    IProductRepo products,
    IUserRepo users,
    IEmailSender email,
    ILogger<MegaService> logger,
    IPaymentGateway payment,
    IInventoryService inventory,
    IShippingService shipping) { }

Refactor the class into smaller, focused services rather than hiding the complexity.

3. Leaking Container Abstractions

Avoid tying your domain or application layer to `Microsoft.Extensions.DependencyInjection`. Your business logic should know nothing about containers:

csharp
class=class="code-string">"code-comment">// BAD: Domain service depends on DI container types
using Microsoft.Extensions.DependencyInjection;

public class PricingEngine
{
    [FromKeyedServices(class="code-string">"premium")]
    public IDiscountStrategy Discount { get; set; } class=class="code-string">"code-comment">// Don't do this
}

Keep DI wiring in the composition root -- typically `Program.cs` or extension methods on `IServiceCollection`.

4. Ignoring Async Initialization

Some services need async setup (connecting to a database, warming a cache). Do not block the constructor with `.Result` or `.GetAwaiter().GetResult()`. Use `IHostedService` or `BackgroundService` instead:

csharp
public class CacheWarmupService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

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

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var scope = _scopeFactory.CreateScope();
        var cache = scope.ServiceProvider.GetRequiredService<ICacheService>();
        await cache.WarmAsync(stoppingToken);
    }
}

Notice the use of `IServiceScopeFactory` to create a scope manually -- this is the correct way to access scoped services from a singleton background service.

Composition Root Best Practices

Keep your `Program.cs` clean by grouping registrations into extension methods:

csharp
class=class="code-string">"code-comment">// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddPersistence(builder.Configuration)
    .AddApplicationServices()
    .AddInfrastructure(builder.Configuration);

class=class="code-string">"code-comment">// ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddPersistence(
        this IServiceCollection services, IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(config.GetConnectionString(class="code-string">"Default")));

        services.AddScoped<IOrderRepository, SqlOrderRepository>();
        services.AddScoped<IProductRepository, SqlProductRepository>();

        return services;
    }

    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<IPricingEngine, PricingEngine>();

        return services;
    }
}

This keeps the composition root readable even as the application grows. In large .NET codebases, I've found that teams which adopt this pattern early avoid the "500-line Program.cs" problem that plagues many projects.

Conclusion

Good DI is not just container usage -- it is explicit dependency design. The built-in container in ASP.NET Core is powerful enough for most applications, and understanding its lifetimes, registration patterns, and constraints will save you from subtle, hard-to-diagnose bugs. That design directly impacts maintainability, testability, and change safety across the entire system.

I can review your registration graph and highlight lifetime risks -- feel free to reach out.

Related Articles

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch