.NET  

The ASP.NET Core Dependency Injection System (with .NET 9 Patterns & Pitfalls)

Introduction

Dependency Injection (DI) is one of the most foundational patterns in modern .NET development. Since ASP.NET Core launched, DI has been a first-class citizen of the framework — no external containers required for most applications.

With .NET 9, the DI container has evolved even further:

  • Source-generated DI for faster startup & AOT

  • Keyed services for multiple implementations

  • Improved scope validation to avoid runtime bugs

  • Better async disposal support

This article explains the key DI concepts every .NET developer should master, along with practical examples that reflect real enterprise applications.

Why Dependency Injection Matters

BenefitExplanation
Loose CouplingSwap implementations without changing consumers
TestabilityReplace services with mocks for clean unit & integration tests
Separation of ConcernsEach component has a single responsibility
MaintainabilitySmaller, modular codebase
Performance & FlexibilityDI container lifecycle controls object lifetimes

Service Lifecycles

LifetimeBehaviorExample Use
TransientNew instance every requestLightweight stateless logic
ScopedOne instance per requestDbContext, user context, UoW
SingletonSingle instance for app lifetimeCaching, config, stateless services

Example registration

builder.Services.AddTransient<IEmailSender, EmailSender>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddSingleton<IAppClock, SystemClock>();

Constructor Injection (Recommended)

public class OrderService(IOrderRepository repo, ILogger<OrderService> log)
{
    public async Task<Order> GetAsync(int id)
    {
        log.LogInformation("Fetching order {Id}", id);
        return await repo.GetAsync(id);
    }
}

Why constructor injection wins

  • Explicit dependencies

  • Immutable services

  • Startup validation

  • Best fit for testability

Avoid Property Injection

ASP.NET Core DI does not support property injection by default, and required dependencies should never be optional anyway.

// ❌ Avoid unless truly optional
public ILogger<ProductService>? Logger { get; set; }

Options Pattern

builder.Services.Configure<ApiSettings>(builder.Configuration.GetSection("ExternalApi"));
public class ApiClient(IOptions<ApiSettings> options, HttpClient http)
{
    http.BaseAddress = new Uri(options.Value.BaseUrl);
}
InterfaceLifetimeUse-case
IOptions<T>SingletonStatic config
IOptionsSnapshot<T>ScopedPer-request config
IOptionsMonitor<T>Singleton w/callbackLive reload scenarios

Avoid Captive Dependencies

Rule: Don't inject scoped services into singletons.

❌ Wrong

builder.Services.AddSingleton<WorkerService>();
builder.Services.AddScoped<AppDbContext>(); // Will cause captive dependency issues

✅ Correct: Use IServiceScopeFactory

public class Worker(ILogger<Worker> log, IServiceScopeFactory scopeFactory)
{
    protected async Task Execute()
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
        await repo.ProcessAsync();
    }
}

Background Services & DI

When using BackgroundService, always create a scope:

public class OrderWorker(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken token)
    {
        while(!token.IsCancellationRequested)
        {
            await using var scope = scopeFactory.CreateAsyncScope();
            var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
            await repo.SyncOrdersAsync();
            await Task.Delay(30000, token);
        }
    }
}

Async Disposal

Use IAsyncDisposable when your service owns async disposable resources:

public class LogWriter : IAsyncDisposable
{
    private readonly StreamWriter _writer = new("log.txt", append: true);

    public async ValueTask DisposeAsync()
    {
        await _writer.DisposeAsync();
    }
}

Decorators for Cross-Cutting Concerns

builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderServiceDecorator>();

Logging, caching, validation → all great decorator use cases.

.NET 9 DI Enhancements

FeaturePurpose
Source-Generated DIFaster startup / AOT-ready
Keyed ServicesMultiple implementations per contract
Lifetime ValidationCatch DI mistakes early
Better DisposalAsync cleanup by default

Testing with DI

Override services in integration tests:

factory.WithWebHostBuilder(builder =>
{
    builder.ConfigureTestServices(services =>
    {
        services.AddScoped<IEmailSender, FakeEmailSender>();
    });
});

ASP.NET Core DI has matured into a fast, capable, production-grade container.

To write clean and efficient .NET applications:

  • ✔ Prefer constructor injection

  • ✔ Understand lifetimes (avoid captive deps)

  • ✔ Use scopes in background workers

  • ✔ Implement async disposal when needed

  • ✔ Leverage Options & TryAdd

  • ✔ Use source-generated DI & keyed services when appropriate