top of page

Dependency Injection in .NET: Building Maintainable Applications at Scale

  • ShiftQuality Contributor
  • Apr 6
  • 5 min read

ASP.NET Core has dependency injection built in. Every tutorial shows the basics: register a service, inject it through a constructor, done. The basics work. The problems appear at scale — when you have 200 services registered, lifetimes that don't match, circular dependencies, and a DI container that takes 500ms to build because of reflection-heavy registrations.

DI at scale isn't about learning the syntax. It's about understanding the design decisions that make DI either a structural advantage or a source of subtle, hard-to-diagnose bugs.

Service Lifetimes: The Decision That Matters Most

.NET's built-in DI container supports three lifetimes, and choosing wrong produces bugs that are hard to reproduce and harder to diagnose.

Transient

A new instance every time the service is requested. Stateless services, lightweight operations, things that should never share state between consumers.

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

When to use: Services with no state, or services where shared state would be a bug. Most application services default here.

The cost: Memory allocation per request. For lightweight services, this is negligible. For services that are expensive to construct (database connections, HTTP clients), transient is wrong — you're paying construction cost repeatedly.

Scoped

One instance per scope — in ASP.NET Core, that's one per HTTP request. Every service that injects the scoped service within the same request gets the same instance. Different requests get different instances.

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

When to use: Services that should share state within a single request but not across requests. Database contexts (DbContext) are the canonical example — you want one transaction context per request, not one per service that touches the database.

The trap: Injecting a scoped service into a singleton. The singleton lives for the application's lifetime. The scoped service is captured and never released — it becomes a de facto singleton with a stale state. This is called a "captive dependency" and .NET will throw a runtime error in development mode (ValidateScopes = true) but may silently fail in production.

Singleton

One instance for the entire application lifetime. Created on first request, reused forever.

builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();

When to use: Stateless services that are expensive to construct, thread-safe caches, configuration objects, HTTP client factories.

The requirement: Thread safety. A singleton is shared across all requests on all threads simultaneously. If the service has mutable state, concurrent access will produce race conditions. Either make the state immutable, use thread-safe collections, or don't use singleton.

The Lifetime Rule

A service can only depend on services with equal or longer lifetimes.

  • Singleton can depend on: other singletons

  • Scoped can depend on: singletons and other scoped services

  • Transient can depend on: singletons, scoped, and other transients

Violating this rule — a singleton depending on a scoped service, for instance — creates captive dependencies. Enable ValidateScopes and ValidateOnBuild in development to catch these at startup:

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

Registration Patterns at Scale

Interface Segregation

Register services by interface, not by concrete class. This is basic DI advice, but it matters more at scale because it determines your refactoring surface.

// Good: consumers depend on the interface
builder.Services.AddScoped<IOrderService, OrderService>();

// Problematic at scale: consumers depend on the concrete class
builder.Services.AddScoped<OrderService>();

When you later need to swap OrderService for CachedOrderService or OrderServiceV2, the interface registration lets you change one line. The concrete registration means finding every constructor that injects OrderService and evaluating the change.

Module-Based Registration

When you have 200+ service registrations, Program.cs becomes unreadable. Group registrations into extension methods by feature or module:

// In Program.cs
builder.Services.AddAuthenticationServices();
builder.Services.AddOrderServices();
builder.Services.AddPaymentServices();

// In a separate file
public static class OrderServiceExtensions
{
    public static IServiceCollection AddOrderServices(
        this IServiceCollection services)
    {
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<IOrderRepository, EfOrderRepository>();
        services.AddScoped<IOrderValidator, OrderValidator>();
        return services;
    }
}

Each module owns its registrations. When a module changes, only its registration extension changes. This also makes it clear which services belong together — useful for understanding dependencies and planning refactoring.

Assembly Scanning (With Caution)

Libraries like Scrutor can scan assemblies and register services by convention — every class implementing IService gets registered automatically. This reduces boilerplate but obscures what's actually registered.

Use it with constraints:

  • Scan specific assemblies, not everything

  • Use explicit conventions (naming patterns, marker interfaces)

  • Verify the result with integration tests that resolve key services

Unconstrained scanning produces registrations you didn't intend and makes it hard to understand what's in the container.

Keyed/Named Services

.NET 8 introduced keyed services for scenarios where you need multiple implementations of the same interface:

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

// Consuming
public class OrderService(
    [FromKeyedServices("email")] INotificationSender emailSender,
    [FromKeyedServices("sms")] INotificationSender smsSender)

Before keyed services, this required factory patterns or custom service providers. Keyed services make it explicit and type-safe.

Common Problems at Scale

Constructor Over-Injection

When a class has 8+ constructor parameters, the DI container will happily inject them all. But the class is doing too much. Constructor over-injection is a design smell — the class has too many responsibilities.

The fix isn't to use property injection or service locator to hide the problem. It's to decompose the class. If OrderService needs 8 dependencies, it probably contains logic that should be split into OrderValidator, OrderProcessor, OrderNotifier, each with focused dependencies.

Circular Dependencies

Service A depends on Service B, which depends on Service A. The DI container can't resolve this and throws at startup.

The fix is always a design change:

  • Extract the shared logic into a third service that both A and B depend on

  • Use an event/mediator pattern so A and B communicate without direct dependency

  • Reconsider whether the circular dependency indicates a responsibility boundary that's drawn incorrectly

Lazy injection (Lazy<T>) can work around circular dependencies technically, but it hides a design problem that will cause maintenance issues later.

Slow Container Build

With hundreds of registrations, container build time at startup can become noticeable. Sources of slowness:

  • Assembly scanning with broad filters

  • Heavy initialization in constructors (database connections, HTTP clients)

  • Validation of all registrations (ValidateOnBuild)

Keep ValidateOnBuild on in development — the startup cost is worth catching misconfigurations early. In production, consider disabling it if startup time is critical (e.g., serverless cold starts).

For slow constructors, use factory registrations that defer initialization:

builder.Services.AddSingleton<IExpensiveService>(sp =>
{
    // Construction is deferred until first use
    return new ExpensiveService(sp.GetRequiredService<IConfig>());
});

Key Takeaway

DI at scale requires understanding lifetime rules (a service can only depend on equal or longer-lived services), validating scopes to catch captive dependencies, organizing registrations by module, and treating constructor over-injection as a design smell. Enable ValidateScopes and ValidateOnBuild in development. Register by interface for flexibility. Group registrations by feature. And when the DI container tells you something is wrong — a circular dependency, a captive dependency, an over-injected class — listen. The container is reflecting your design back at you.

This completes the .NET at Scale learning path. You've covered async concurrency patterns, performance profiling, configuration and secrets management, and dependency injection architecture. The throughline: .NET at scale is about the architectural decisions that keep large applications maintainable — patterns that are invisible when they're right and painful when they're wrong.

Comments


bottom of page