Articles › ASP.NET Core

Dependency Injection in ASP.NET Core: Transient, Scoped and Singleton Explained

Understand dependency injection in ASP.NET Core: service lifetimes, captive dependencies, keyed services, the options pattern and how DI makes code testable.

Dependency Injection (DI) is built into the heart of ASP.NET Core. Controllers, middleware, background services and even the framework itself receive their dependencies through the built-in container. Understanding how it works — especially service lifetimes — prevents some of the nastiest bugs in .NET applications.

The problem DI solves

Without DI, classes create their own dependencies:

public class OrderService
{
    private readonly EmailSender _email = new EmailSender("smtp.example.com");

    public void PlaceOrder(Order order)
    {
        // ...
        _email.Send(order.CustomerEmail, "Order confirmed");
    }
}

This code is tightly coupled: you can't swap the email sender, you can't test PlaceOrder without sending real emails, and configuration is hard-coded.

With DI, the class declares what it needs and someone else provides it:

public interface IEmailSender
{
    Task SendAsync(string to, string subject, CancellationToken ct = default);
}

public class OrderService(IEmailSender email, ILogger<OrderService> logger)
{
    public async Task PlaceOrderAsync(Order order, CancellationToken ct)
    {
        // ...
        await email.SendAsync(order.CustomerEmail, "Order confirmed", ct);
        logger.LogInformation("Order {OrderId} placed", order.Id);
    }
}

This is the Dependency Inversion Principle in action: high-level code depends on abstractions, not on concrete implementations.

Registering services

Services are registered in Program.cs:

builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();

ASP.NET Core then injects them wherever they're requested: constructors of controllers, Razor pages, middleware, hosted services, or minimal API handlers.

app.MapPost("/orders", async (CreateOrder cmd, IOrderService orders, CancellationToken ct) =>
    Results.Ok(await orders.CreateAsync(cmd, ct)));

The three lifetimes

Transient

A new instance every time the service is requested.

  • Use for lightweight, stateless services.
  • If a single request asks for it three times, three instances are created.

Scoped

One instance per scope — in ASP.NET Core, one per HTTP request.

  • Use for anything holding per-request state, and above all for DbContext.
  • Every class within the same request shares the same instance, so they share the same unit of work.

Singleton

One instance for the lifetime of the application.

  • Use for stateless or thread-safe services that are expensive to create: caches, configuration wrappers, HttpClient handlers, clocks.
  • Must be thread-safe, because every request uses it concurrently.
Lifetime Instances Typical examples
Transient every resolve validators, mappers, small helpers
Scoped per request DbContext, repositories, unit of work, current-user service
Singleton per app IMemoryCache, configuration, IHttpClientFactory, in-memory registries

The captive dependency bug

A service must never depend on a service with a shorter lifetime. The classic mistake:

builder.Services.AddSingleton<ReportCache>();       // lives forever
builder.Services.AddScoped<AppDbContext>();          // should live one request

public class ReportCache(AppDbContext db) { ... }    // ❌ captive dependency

The singleton captures the first DbContext it receives and keeps it forever. That context is shared across all requests and threads — leading to concurrency exceptions, stale data and memory growth.

ASP.NET Core detects this in development with scope validation and throws on startup: "Cannot consume scoped service from singleton". Don't disable it.

If a singleton genuinely needs scoped services, create a scope explicitly:

public class ReportCache(IServiceScopeFactory scopeFactory)
{
    public async Task RefreshAsync(CancellationToken ct)
    {
        using var scope = scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // use db within this scope only
    }
}

This is exactly the pattern used inside a BackgroundService.

Registration variants worth knowing

// Register a concrete type without an interface
builder.Services.AddScoped<InvoiceGenerator>();

// Factory registration with access to other services
builder.Services.AddSingleton<IStorage>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return new BlobStorage(config["Storage:ConnectionString"]!);
});

// Don't overwrite an existing registration (useful in libraries)
builder.Services.TryAddSingleton<IClock, SystemClock>();

// Multiple implementations → inject IEnumerable<T>
builder.Services.AddScoped<INotifier, EmailNotifier>();
builder.Services.AddScoped<INotifier, SmsNotifier>();

public class Alerts(IEnumerable<INotifier> notifiers)
{
    public Task NotifyAllAsync(string msg) =>
        Task.WhenAll(notifiers.Select(n => n.NotifyAsync(msg)));
}

Keyed services (.NET 8+)

When you need to choose between implementations by name:

builder.Services.AddKeyedSingleton<IPaymentGateway, ZarinpalGateway>("zarinpal");
builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");

public class CheckoutService([FromKeyedServices("stripe")] IPaymentGateway gateway) { }

Organizing registrations with extension methods

As projects grow, Program.cs fills up. Group registrations per layer:

public static class InfrastructureServiceCollectionExtensions
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(o => o.UseSqlServer(config.GetConnectionString("Default")));
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddTransient<IEmailSender, SmtpEmailSender>();
        return services;
    }
}

// Program.cs
builder.Services.AddApplication().AddInfrastructure(builder.Configuration);

DI and the Options pattern

Inject strongly typed configuration instead of IConfiguration strings:

public class SmtpOptions
{
    public string Host { get; set; } = "";
    public int Port { get; set; } = 587;
}

builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));

public class SmtpEmailSender(IOptions<SmtpOptions> options) : IEmailSender { ... }

Anti-patterns

  1. Service Locator — injecting IServiceProvider everywhere and calling GetService hides dependencies and defeats the purpose of DI. Use it only in factories and infrastructure code.
  2. Constructor over-injection — a constructor with ten dependencies is a sign that the class does too much. Split it.
  3. Disposing injected services yourself — the container owns the objects it creates and disposes them at the end of their lifetime.
  4. Static state instead of singletons — static fields can't be replaced in tests and are invisible in the dependency graph.

DI makes testing easy

Because dependencies are interfaces, tests can substitute fakes:

[Fact]
public async Task PlaceOrder_sends_confirmation_email()
{
    var email = new FakeEmailSender();
    var service = new OrderService(email, NullLogger<OrderService>.Instance);

    await service.PlaceOrderAsync(new Order { CustomerEmail = "a@b.com" }, default);

    Assert.Single(email.Sent);
}

Summary

DI in ASP.NET Core lets classes declare their needs and receive them from a central container. Choose lifetimes deliberately: transient for lightweight stateless helpers, scoped for per-request state and DbContext, singleton for thread-safe shared services. Never let a long-lived service capture a short-lived one, and keep scope validation on. Get this right and your code becomes modular, testable and much easier to change.

FAQ

Which lifetime should I choose by default?

Scoped is a safe default for application services in web apps. Use singleton only for thread-safe, stateless services, and transient for very lightweight helpers.

Do I need a third-party container like Autofac?

Rarely. The built-in container covers most needs, including keyed services since .NET 8. Third-party containers add features like decorators and assembly scanning.

Is HttpClient a singleton?

Don't create HttpClient manually per request, and don't keep one forever without care. Use IHttpClientFactory (AddHttpClient), which manages handler lifetimes correctly.