Mert Özen Further With Every Line
Backend .NET Core Web API — #11

Dependency Injection in ASP.NET Core: Lifetimes and the Captive Dependency Trap

Mert Özen Nov 4, 2026 10 min 2 views
Dependency Injection in ASP.NET Core: Lifetimes and the Captive Dependency Trap

We explore dependency injection in ASP.NET Core with real output: constructor injection, singleton/scoped/transient lifetimes, captive dependencies, scope validation, IServiceProvider and the [FromServices] detail in .NET 6.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

In the minimal API post, when you added a parameter to a handler, it sometimes came from the route, sometimes from the body, and sometimes from "somewhere" without you doing anything. That "somewhere" is the topic of this post: the dependency injection container. Start with a small question. You add these three registrations to Program.cs:

builder.Services.AddSingleton<SingletonOperation>();
builder.Services.AddScoped<ScopedOperation>();
builder.Services.AddTransient<TransientOperation>();

Each one generates a short ID the moment it is created. An endpoint reads these three IDs from two places: the controller's own constructor, and another service injected into the controller. If you send two requests to that endpoint in a row, which ID stays the same across both requests, and which one shows two different values even within a single request? We will see the answer shortly, with real output.

The Hidden Cost of the new Keyword

In the controller post we kept the product list as a static field inside the controller. Imagine you move the list into a separate class called InMemoryProductStore, and the controller creates that class itself:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly InMemoryProductStore _store = new();

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        Product? product = _store.GetById(id);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPost]
    public ActionResult<Product> Create(Product product)
    {
        Product created = _store.Add(product);
        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }
}

It compiles and seems to work at first glance. But add a product with POST and ask for it right after, and you get this:

HTTP/1.1 201 Created
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"USB-C Hub","price":890}

HTTP/1.1 404 Not Found

ASP.NET Core creates a new controller instance for every request. Once the static field is gone, new() means a brand-new store, and therefore a brand-new list, on every request. The real problem goes deeper though: the controller is now riveted to a specific class. When you want to read data from a database tomorrow, you will have to change the controller too, and when you write tests you will not be able to swap in a fake store.

Inversion of Control (IoC) flips this relationship: a class does not build what it needs, it asks for it from the outside. Dependency injection is the most common way to do that. Picture a restaurant kitchen: the chef does not go to the field to pick tomatoes, they say what the station needs and the supplier brings it. In ASP.NET Core the supplier is called the DI container. Every registration you make through builder.Services tells it "if someone asks for this, give them that."

Moving the Store Out with Constructor Injection

The first step is to describe what the controller needs with an interface:

using ProductCatalog.Models;

namespace ProductCatalog.Services;

public interface IProductStore
{
    IReadOnlyList<Product> GetAll();
    Product? GetById(int id);
    Product Add(Product product);
}
using ProductCatalog.Models;

namespace ProductCatalog.Services;

public class InMemoryProductStore : IProductStore
{
    private readonly List<Product> _products = new()
    {
        new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
        new Product { Id = 2, Name = "Wireless Mouse", Price = 520m }
    };
    private readonly object _lock = new();

    public IReadOnlyList<Product> GetAll()
    {
        lock (_lock)
        {
            // We hand out a copy of the list
            return _products.ToList();
        }
    }

    public Product? GetById(int id)
    {
        lock (_lock)
        {
            return _products.FirstOrDefault(p => p.Id == id);
        }
    }

    public Product Add(Product product)
    {
        lock (_lock)
        {
            product.Id = _products.Count == 0 ? 1 : _products.Max(p => p.Id) + 1;
            _products.Add(product);
            return product;
        }
    }
}

This store will be shared as a single instance across the whole application. We guard access with lock so that two requests arriving at the same time do not corrupt the same List<Product>. The controller now knows only the contract:

using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Models;
using ProductCatalog.Services;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductStore _store;

    public ProductsController(IProductStore store)
    {
        _store = store;
    }

    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll()
    {
        return Ok(_store.GetAll());
    }

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        Product? product = _store.GetById(id);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPost]
    public ActionResult<Product> Create(Product product)
    {
        Product created = _store.Add(product);
        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }
}

The constructor parameters are the controller's dependency list. You wire them up in Program.cs:

builder.Services.AddControllers();
builder.Services.AddSingleton<IProductStore, InMemoryProductStore>();

This line tells the container "if someone asks for IProductStore, give them InMemoryProductStore, and always the same one for the lifetime of the app." When a request arrives, the container looks at the constructor while creating the controller, resolves the parameters one by one, and hands over a ready instance. Try the same POST and GET pair again; this time GET returns 200 and {"id":3,"name":"USB-C Hub","price":890}, because everyone talks to the same store.

Three Lifetimes: Singleton, Scoped, Transient

The "always the same one" part is the registration's lifetime. There are three options, and the best way to see the difference is to measure it. These are the classes from the guessing question:

namespace ProductCatalog.Services;

public abstract class Operation
{
    // Generates a short ID when the instance is created
    public string Id { get; } = Guid.NewGuid().ToString()[..8];
}

public class SingletonOperation : Operation { }
public class ScopedOperation : Operation { }
public class TransientOperation : Operation { }

public class OperationReporter
{
    private readonly SingletonOperation _singleton;
    private readonly ScopedOperation _scoped;
    private readonly TransientOperation _transient;

    public OperationReporter(SingletonOperation singleton, ScopedOperation scoped, TransientOperation transient)
    {
        _singleton = singleton;
        _scoped = scoped;
        _transient = transient;
    }

    public object Report() => new { singleton = _singleton.Id, scoped = _scoped.Id, transient = _transient.Id };
}
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Services;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/[controller]")]
public class LifetimesController : ControllerBase
{
    private readonly SingletonOperation _singleton;
    private readonly ScopedOperation _scoped;
    private readonly TransientOperation _transient;
    private readonly OperationReporter _reporter;

    public LifetimesController(
        SingletonOperation singleton,
        ScopedOperation scoped,
        TransientOperation transient,
        OperationReporter reporter)
    {
        _singleton = singleton;
        _scoped = scoped;
        _transient = transient;
        _reporter = reporter;
    }

    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            controller = new { singleton = _singleton.Id, scoped = _scoped.Id, transient = _transient.Id },
            reporter = _reporter.Report()
        });
    }
}

Add builder.Services.AddTransient<OperationReporter>(); next to the three registrations in Program.cs and send two requests:

curl http://localhost:5080/api/lifetimes
{"controller":{"singleton":"6c6364e0","scoped":"60aeb626","transient":"c46448de"},"reporter":{"singleton":"6c6364e0","scoped":"60aeb626","transient":"3df88c69"}}

curl http://localhost:5080/api/lifetimes
{"controller":{"singleton":"6c6364e0","scoped":"4d0513be","transient":"78103649"},"reporter":{"singleton":"6c6364e0","scoped":"4d0513be","transient":"6a68f1c9"}}

Did your guess hold? The singleton ID is 6c6364e0 in both requests; as long as the app is running there is a single instance. The scoped ID is the same in the controller and in the reporter within one request, but it changes in the second request. ASP.NET Core opens a scope for every HTTP request, and a scoped service is unique for the duration of that scope. A transient is created again everywhere it is requested; even within the same request, the controller and the reporter see different IDs.

Think of a hotel. The singleton is the lobby, every guest uses the same one. Scoped is your room; it is yours while you stay, and when you check out a fresh one is prepared for the next guest. Transient is the paper cup at breakfast, you get a new one every time you reach for it.

A short rule for choosing: things that hold no state, or can be shared in a thread-safe way (a cache, our store), are singletons; a unit of work that belongs to a request (a database connection, the current user's information) is scoped; light, stateless helpers are transient. EF Core's DbContext is registered as scoped by default, and we will see why in the data layer post.

Captive Dependency: A Scoped Service Trapped in a Singleton

Lifetimes come with one rule: a service should not depend on a service that lives shorter than itself. The classic mistake that breaks this rule looks like this:

namespace ProductCatalog.Services;

public class PriceCache
{
    private readonly ScopedOperation _operation;

    public PriceCache(ScopedOperation operation)
    {
        _operation = operation;
    }

    public string OperationId => _operation.Id;
}
builder.Services.AddSingleton<PriceCache>();

// ...

app.MapGet("/cache", (PriceCache cache) => cache.OperationId);

A singleton is created once and holds on to the scoped instance it received in its constructor for its entire life. That instance no longer belongs to any request; it is a guest forgotten in the first room, with the door locked from the outside. If that scoped service were a DbContext, every request would be sharing a single DbContext, which is not thread-safe.

Run the app in the Development environment and it does not even start:

Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: ProductCatalog.Services.PriceCache Lifetime: Singleton ImplementationType: ProductCatalog.Services.PriceCache': Cannot consume scoped service 'ProductCatalog.Services.ScopedOperation' from singleton 'ProductCatalog.Services.PriceCache'.)

Run the same code in the Production environment, though, and the app starts quietly. No matter how many requests you send to /cache, it always returns the same ID (c559c5a7), while /api/lifetimes shows a new scoped ID on every request. The bug did not go away, it just became invisible.

Scope Validation and IServiceProvider

The protection in Development is not an accident. When the environment is Development, WebApplication.CreateBuilder turns on two container checks. ValidateScopes catches a scoped service being resolved from a singleton or from the root provider. ValidateOnBuild tries, at startup, whether every registration can actually be constructed. Both are off in Production. If you want to catch these bugs in every environment, you can turn them on yourself:

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

With this setting the PriceCache example stops at startup with the same exception in Production as well. The price is a slightly longer startup time, which is worth paying for most APIs.

The container itself is represented by the IServiceProvider interface, and you reach it through app.Services. But that is the root provider. Ask it directly for a scoped service and in Development you get this:

ScopedOperation op = app.Services.GetRequiredService<ScopedOperation>();
Unhandled exception. System.InvalidOperationException: Cannot resolve scoped service 'ProductCatalog.Services.ScopedOperation' from root provider.

If you need a scoped service outside a request, for example for a one-off job at startup, you open the scope yourself:

using (IServiceScope scope = app.Services.CreateScope())
{
    ScopedOperation op = scope.ServiceProvider.GetRequiredService<ScopedOperation>();
    Console.WriteLine($"scope resolved {op.Id}");
}

When the using block ends, the scope closes and the disposable services created inside it are cleaned up. Let's be clear about one thing: you can inject IServiceProvider into a controller and pull everything with GetRequiredService. That is called a service locator; it hides dependencies from the constructor and makes tests harder to write. Make constructor injection your default.

Getting Services in an Action and a Minimal API Handler

If only one action uses a service, you can take it as an action parameter instead of a constructor parameter. In .NET 6 there is a small but sneaky detail here:

[HttpGet("reporter")]
public IActionResult FromAction([FromServices] OperationReporter reporter)
{
    return Ok(reporter.Report());
}

Remove the [FromServices] attribute and send the same request on .NET 6, and this is the response:

HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.13","title":"Unsupported Media Type","status":415,"traceId":"00-b0b787e91adeb9a6136e863157b3141e-a6c5918b0ca5d881-00"}

[ApiController] tries to read a complex-typed parameter with no declared source from the body; a GET request has no body and no content type, so it returns 415. Starting with .NET 7, the framework notices that the parameter type is registered in the container and resolves it as a service; the same code returns 200 on .NET 10 even without the attribute. Since our backbone is .NET 6, always write the attribute; it works on both versions.

Minimal APIs have behaved differently from the start. A .NET 6 handler already resolves types registered in the container as services on its own:

app.MapGet("/api/minimal/reporter", (OperationReporter reporter) => reporter.Report());

This is exactly the case where we said, in the minimal API post, that the parameter came from "somewhere."

One Interface, Several Registrations

What happens if you register more than one implementation for an interface?

namespace ProductCatalog.Services;

public interface INotifier
{
    string Name { get; }
}

public class EmailNotifier : INotifier
{
    public string Name => "email";
}

public class SmsNotifier : INotifier
{
    public string Name => "sms";
}
builder.Services.AddSingleton<INotifier, EmailNotifier>();
builder.Services.AddSingleton<INotifier, SmsNotifier>();
public NotifiersController(INotifier notifier, IEnumerable<INotifier> all)
{
    _notifier = notifier;
    _all = all;
}

[HttpGet]
public IActionResult Get()
{
    return Ok(new { single = _notifier.Name, all = _all.Select(n => n.Name) });
}
curl http://localhost:5080/api/notifiers
{"single":"sms","all":["email","sms"]}

Asking for a single INotifier gets you the last registration. Asking for IEnumerable<INotifier> gets you all of them in registration order, which is handy for fanning a notification out to several channels. If you are writing a library and do not want to overwrite the application's own registration, use methods such as TryAddSingleton from the Microsoft.Extensions.DependencyInjection.Extensions namespace; they do nothing if the same service type has already been registered. Registrations distinguished by name (keyed services) arrived in .NET 8; we will cover them in the upcoming version posts.

Common Pitfalls

First, forgetting a registration. The container cannot build the controller, and the error only shows up on the first request, because controllers are not registered as services in the container:

System.InvalidOperationException: Unable to resolve service for type 'ProductCatalog.Services.IProductStore' while attempting to activate 'ProductCatalog.Controllers.ProductsController'.

Second, calling builder.Services.BuildServiceProvider() in Program.cs to reach a service early. This creates a second container and your singletons get created twice. The compiler warns you too:

warning ASP0000: Calling 'BuildServiceProvider' from application code results in an additional copy of singleton services being created. Consider alternatives such as dependency injecting services as parameters to 'Configure'. (https://aka.ms/AA5k895)

Third, keeping non-thread-safe state in a singleton. A singleton is used by hundreds of requests at the same time; if the List<T> or Dictionary<TKey, TValue> inside it is not protected, data gets corrupted silently. That is why the lock is in the store.

Fourth, the many disguises of the captive dependency. Injecting a DbContext into a singleton, or taking the scoped IOptionsSnapshot<T> we will meet in the next post into a singleton, is the same mistake. It is caught in Development and quietly misbehaves in Production.

Fifth, resolving a transient service that implements IDisposable from the root provider. The root provider holds on to the disposable instances it creates until it is disposed itself, which means until the app shuts down. Do that in a loop and memory fills up slowly.

A Small Experiment

★☆☆ Change the registration of OperationReporter from AddTransient to AddScoped. Before sending the request, guess whether the transient ID inside the reporter will match the controller's, then check.

★★☆ Register InMemoryProductStore with AddScoped. Add a product with POST, then ask for it with GET. Explain what you see using the lifetime table. Then try again with AddTransient; is there a difference?

★★★ Fix PriceCache: keep it a singleton, but have its constructor take IServiceScopeFactory instead of ScopedOperation, open its own scope on every call and resolve the scoped service from that scope. Turn on both checks with UseDefaultServiceProvider and confirm that the app starts in the Production environment.

In this post both the product list and values such as page size are still hard-coded. In the next post we will move these values into appsettings.json, environment variables and user secrets with configuration and the Options pattern, and we will use today's lifetime knowledge to work out why IOptionsSnapshot cannot be injected into a singleton.