ASP.NET Core has a built-in IoC container that manages service lifetimes and resolves dependencies automatically. You register services in Program.cs and the container injects them via constructor injection throughout the application — controllers, middleware, filters, background services, and hosted services all participate.

This page covers the ASP.NET Core DI container specifically. For the general Dependency Injection pattern and its design benefits, see Dependency Injection.

Service Lifetimes

The three lifetimes control how long a service instance lives:

LifetimeInstance perUse for
SingletonApplication lifetimeStateless services, caches, configuration wrappers
ScopedHTTP requestDbContext, unit-of-work, per-request state
TransientEach injectionLightweight stateless services, factories
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddTransient<IReportGenerator, PdfReportGenerator>();
 
// Shorthand for common patterns
builder.Services.AddDbContext<AppDbContext>(opts =>
    opts.UseSqlServer(connectionString));  // Scoped by default

Constructor Injection

The container resolves constructor parameters automatically:

public sealed class OrdersController(IOrderRepository orders, IEmailSender email)
    : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Place(PlaceOrderRequest req, CancellationToken ct)
    {
        var order = Order.Create(req.CustomerId, req.Items);
        await orders.SaveAsync(order, ct);
        await email.SendAsync(req.Email, "Order confirmed", $"Order {order.Id} placed.", ct);
        return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
    }
}

No new keyword — the container creates and injects IOrderRepository and IEmailSender with the correct lifetimes.

Registering Multiple Implementations

// Register multiple implementations of the same interface
builder.Services.AddScoped<INotificationSender, EmailNotificationSender>();
builder.Services.AddScoped<INotificationSender, SmsNotificationSender>();
 
// Inject all implementations as IEnumerable<T>
public sealed class NotificationService(IEnumerable<INotificationSender> senders)
{
    public async Task NotifyAllAsync(string message, CancellationToken ct)
    {
        foreach (var sender in senders)
            await sender.SendAsync(message, ct);
    }
}

Keyed Services (.NET 8)

When you have multiple implementations and want to pick a specific one (not all of them), register and resolve by key:

builder.Services.AddKeyedScoped<INotificationSender, EmailNotificationSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsNotificationSender>("sms");
 
public sealed class OrderConfirmation(
    [FromKeyedServices("email")] INotificationSender sender) { /* ... */ }

This replaces the old workarounds (factory delegates, marker interfaces) for “same interface, choose by name.”

Advanced Registration

  • Open generics — register a generic interface to a generic implementation once: services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));. Resolving IRepository<Order> constructs EfRepository<Order>.
  • TryAdd* / TryAddEnumerable — register only if not already present (libraries use these so apps can override defaults without duplicate registrations).
  • Decorators — the built-in container has no native decoration; use Scrutor’s Decorate<TService, TDecorator>() (or assembly scanning) for cross-cutting wrappers.
  • ActivatorUtilities.CreateInstance<T>(provider, args) — construct a type with a mix of DI-resolved and explicit constructor args without registering it.

Pitfalls

Captive Dependency (Singleton Consuming Scoped)

What goes wrong: a Singleton service injects a Scoped service. The Scoped service is captured at the Singleton’s creation time and reused across all requests — effectively becoming a Singleton itself. For DbContext, this means a single context is shared across concurrent requests, causing data corruption.

Why it happens: the container doesn’t prevent this by default (though it validates in development mode with ValidateScopes = true).

Mitigation: never inject Scoped or Transient services into Singletons. If a Singleton needs a Scoped service, inject IServiceScopeFactory and create a scope explicitly.

public sealed class BackgroundWorker(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var scope = scopeFactory.CreateScope();
        var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
        // Use repo within this scope
    }
}

Registering DbContext as Singleton

What goes wrong: DbContext is not thread-safe. Registering it as Singleton causes concurrent requests to share the same context, leading to race conditions and incorrect query results.

Why it happens: AddDbContext<T>() defaults to Scoped, but developers sometimes override this.

Mitigation: always use AddDbContext<T>() without overriding the lifetime. If you need a DbContext in a Singleton, use IDbContextFactory<T> (registered with AddDbContextFactory<T>()).

Disposal and the Transient IDisposable Trap

The container owns disposal of the instances it creates: when a scope ends it disposes the IDisposable/IAsyncDisposable services it resolved in that scope, and singletons are disposed when the root provider is disposed. Two consequences:

  • A transient IDisposable resolved from the root provider lives until the app shuts down — the container holds it to dispose it later, so a “transient” disposable can pile up as a leak. Resolve disposables from a scope, or don’t make hot transients disposable.
  • Don’t register an instance you also dispose yourself (AddSingleton(myInstance) then using) — you’ll double-dispose. If you own the lifetime, register a factory that returns it without the container taking ownership, or let the container own it exclusively.

Tradeoffs

  • Constructor vs property injection: constructor injection is the standard in ASP.NET Core — all dependencies are explicit, required, and available at construction time. Property injection (not natively supported by the built-in container) is appropriate only for optional dependencies or legacy frameworks. Constructor injection fails loudly at startup if a registration is missing; property injection fails silently at call time.
  • Scoped vs Transient for stateful services: Scoped creates one instance per request and shares it across the full request graph — appropriate for DbContext (unit-of-work). Transient creates a new instance on every injection — appropriate for lightweight, stateless services. Choosing Scoped when you mean Transient causes unintended state sharing within a request.
  • Built-in container vs Autofac/others: ASP.NET Core’s built-in container covers constructor injection, lifetimes, and open-generic registration with zero extra dependencies. Autofac, StructureMap, and Lamar add named registrations, decorators, and convention-based scanning. Prefer the built-in container unless you specifically need a missing feature.

Questions

References