Understanding Dependency Injection in ASP.NET Core — Lifetimes, Setup, and Best Practices

Dependency Injection (DI) is at the core of how ASP.NET Core applications are built. If you’ve ever wondered how services get wired together or why everything is passed into constructors — this article will make it all clear.

Let’s break it down step-by-step and make sure you walk away understanding how DI works, why it’s so powerful, and how to use it properly in your own applications.

What Is Dependency Injection?

Dependency Injection is a design pattern that allows you to write loosely coupled, testable, and maintainable code. Instead of a class creating its dependencies (e.g., new EmailService()), DI lets you request what you need and the system provides it.

In ASP.NET Core, DI is built in — you don’t need to install any extra library or framework. Just register your dependencies in the DI container and let ASP.NET Core do the rest.

Why Is It Useful?

DI helps you:

  • Decouple your classes and make them easier to test
  • Reuse components across the app
  • Replace implementations without touching consuming code (useful for mocking/testing)
  • Centralize configuration and lifetimes of services

Without DI, your classes would be tightly coupled to their dependencies, making testing and maintenance harder. Once you start using it consistently, your architecture becomes cleaner and easier to reason about.

Registering Services in Program.cs

Everything starts with registration. In ASP.NET Core, services are added to the built-in container in the Program.cs file using builder.Services.

C#
builder.Services.AddTransient<IEmailService, EmailService>();
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddSingleton<INotificationService, NotificationService>();

This tells ASP.NET Core:

Whenever something needs IEmailService, give it an instance of EmailService.

After registration, you can inject these anywhere in the application.

Service Lifetimes Explained

ASP.NET Core provides three service lifetimes. Understanding these is critical for avoiding memory leaks, performance issues, or bugs.

Transient Services

When you register a service using .AddTransient<TInterface, TImplementation>(), it means:

  • A new instance is created every time the service is requested.
  • Even within a single HTTP request, multiple classes asking for the same service will each receive their own instance.

When to use it:

  • The service does not maintain any state
  • It’s cheap to create (like formatters or lightweight logic handlers)

Caution:

Transient services should not depend on scoped services like DbContext, because their mismatch in lifetime can cause unexpected behavior or runtime errors.

Scoped Services

When registered with .AddScoped<TInterface, TImplementation>(), the service:

  • Lives for the duration of a single HTTP request
  • Is shared across all parts of your application within that request
  • A new instance is created for each new HTTP request

When to use it:

  • You need to maintain request-specific data
  • You’re working with EF Core’s DbContext or similar per-request resources

Caution:

Avoid injecting a scoped service into a singleton. Doing so can result in runtime errors, because the singleton lives across requests, while the scoped service is tied to just one.

Singleton Services

When registered with .AddSingleton<TInterface, TImplementation>(), the service:

  • Is created once and shared across the entire application’s lifetime
  • Every request and every class gets the same instance

When to use it:

  • Your service is stateless and thread-safe
  • You want to cache data, manage static configuration, or store shared logic

Caution:

Singletons must be thread-safe, because they’re accessed from many places concurrently. Avoid injecting scoped or transient services into a singleton — it will lead to lifetime mismatches and bugs.

Constructor Injection

Constructor injection is the most common and recommended way to use DI in ASP.NET Core. You simply ask for the service in the constructor, and ASP.NET Core automatically provides it.

Here’s the traditional approach:

C#
public sealed class EmailSender : IEmailSender
{
    private readonly ISmtpClient _client;

    public EmailSender(ISmtpClient client)
    {
        _client = client;
    }

    public void Send(string message)
    {
        _client.SendEmail(message);
    }
}

This is clean and works great. But from C# 12 onward, you can simplify this using primary constructors. It reduces boilerplate by combining the constructor and field in a single line:

C#
public sealed class EmailSender(ISmtpClient client) : IEmailSender
{
    public void Send(string message)
    {
        client.SendEmail(message);
    }
}

This is especially useful when the class is small and just needs DI-injected dependencies. The primary constructor keeps things concise and easier to maintain.

Use whichever version fits the complexity of your class — both are equally valid and supported by the DI container.

No new keyword. No manual wiring. Just clean, testable code.

Action Method Injection with FromServices

ASP.NET Core also supports injecting services directly into controller actions or Razor Page handlers using [FromServices]. This is useful when:

  • The service is only needed for that single action.
  • You want to avoid cluttering the constructor.
C#
[HttpGet("send")]
public IActionResult SendEmail([FromServices] IEmailSender emailSender)
{
    emailSender.Send("Welcome!");
    return Ok("Email sent.");
}

Injecting Keyed Services with FromKeyedServices

In .NET 8 and later, Keyed Services let you register multiple implementations of the same interface and resolve them by name (or key). This is especially useful when different parts of your app need different strategies or behaviors — like supporting both PayPal and Stripe under a shared IPaymentService.

Registering Keyed Services in Program.cs

C#
builder.Services.AddKeyedScoped<IPaymentService, PaypalPaymentService>("paypal");
builder.Services.AddKeyedScoped<IPaymentService, StripePaymentService>("stripe");

Each registration is associated with a unique key ("paypal" and "stripe"). When injecting the service, you specify which key you want.

Using FromKeyedServices in Action Methods

Whether you’re working in a controller or a minimal API, you can inject the exact implementation you want directly into an action method parameter using [FromKeyedServices]:

C#
[HttpPost("pay-with-paypal")]
public IActionResult PayWithPaypal([FromKeyedServices("paypal")] IPaymentService service)
{
    return Ok(service.Process());
}

[HttpPost("pay-with-stripe")]
public IActionResult PayWithStripe([FromKeyedServices("stripe")] IPaymentService service)
{
    return Ok(service.Process());
}

This eliminates the need for switch statements, factories, or manual service resolution. It keeps your actions clean and focused

This feature works consistently across both controller actions and Minimal API route handlers. The [FromKeyedServices] attribute tells the DI system exactly which implementation to inject, based on the key you registered.

In Minimal APIs

In Minimal APIs, there’s no class or constructor, so services are injected directly into the lambda parameters:

C#
app.MapGet("/orders", (IOrderService orderService) =>
{
    var orders = orderService.GetAll();
    return Results.Ok(orders);
});

Just add the interface to the delegate parameters — ASP.NET Core injects it for you.

Best Practices

ASP.NET Core makes it easy to register and resolve dependencies — but using DI correctly requires discipline. Prefer interfaces for testability, be mindful of lifetimes, and avoid common pitfalls like resolving services manually or injecting scoped services into long-lived objects.

  • Prefer interfaces over concrete types when registering services
  • Use constructor injection — it’s clean, testable, and supported everywhere
  • Pick the right service lifetime: use Scoped for most app services, Singleton for stateless ones, and Transient when needed
  • Avoid injecting IServiceProvider manually unless absolutely necessary
  • Don’t inject Scoped services into Singletons — this leads to lifetime mismatches
  • Group your service registrations using extension methods (e.g., AddApplicationServices()) to keep Program.cs clean
  • Keep services stateless when using Singleton or Transient lifetimes

Summary

Dependency Injection in ASP.NET Core is a built-in and essential tool for building clean, testable applications.

  • Register services in Program.cs
  • Choose the right lifetime: Transient, Scoped, or Singleton
  • Inject services using constructor injection or via parameters in Minimal APIs
  • Keep your architecture clean by depending on abstractions, not concrete types

Whether you’re building small APIs or large systems, DI will help you manage complexity with confidence.

Quick Takeaway

Once you understand DI, everything in ASP.NET Core starts to feel connected — because it is. Start with interfaces, register your services properly, and let the framework handle the plumbing.