Mert Özen Further With Every Line
Backend Advanced C# — #13

Dependency Injection Basics in C#: Lifetimes, the Container and Captive Dependencies

Mert Özen Nov 7, 2026 9 min 5 views
Dependency Injection Basics in C#: Lifetimes, the Container and Captive Dependencies

Constructor injection with Microsoft.Extensions.DependencyInjection, singleton/scoped/transient, the container's disposal duty, captive dependencies and ValidateScopes, multiple registrations, TryAdd and factories.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We register three empty classes in a container with three different lifetimes, open two scopes, and ask for the same service twice to see whether we get the same instance. Which of the four lines are True?

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<Clock>();
services.AddScoped<Cart>();
services.AddTransient<PriceFormatter>();

using ServiceProvider provider = services.BuildServiceProvider();

using IServiceScope first = provider.CreateScope();
using IServiceScope second = provider.CreateScope();

Console.WriteLine(ReferenceEquals(
    first.ServiceProvider.GetRequiredService<Clock>(),
    second.ServiceProvider.GetRequiredService<Clock>()));

Console.WriteLine(ReferenceEquals(
    first.ServiceProvider.GetRequiredService<Cart>(),
    first.ServiceProvider.GetRequiredService<Cart>()));

Console.WriteLine(ReferenceEquals(
    first.ServiceProvider.GetRequiredService<Cart>(),
    second.ServiceProvider.GetRequiredService<Cart>()));

Console.WriteLine(ReferenceEquals(
    first.ServiceProvider.GetRequiredService<PriceFormatter>(),
    first.ServiceProvider.GetRequiredService<PriceFormatter>()));

public sealed class Clock { }
public sealed class Cart { }
public sealed class PriceFormatter { }

Three Lifetimes

Output:

True
True
False
False

Singleton means one instance for the lifetime of the container: both scopes got the same Clock. Scoped means one instance per scope: the Cart requested twice in the same scope is the same, the ones in different scopes are different. Transient means a new instance on every request: even within the same scope, the two PriceFormatters are different.

13-di-lifetimes-en.png

Think of a scope as a single customer visit to a bookstore. The clock at the till (singleton) is the same for everyone. The basket (scoped) stays with you for the visit; the next customer gets a new basket. A label from the price-label printer (transient) is new every time. In ASP.NET Core every HTTP request opens its own scope, which is why scoped services mean "per request". It's also why EF Core's DbContext is registered as scoped.

Why Dependency Injection

Lifetimes are the mechanics; the real question is why we need a container at all. Think of an order service: it reads prices from a repository and asks a policy for the discount. If the service creates these dependencies itself with new, it also decides which repository gets used: when testing you're stuck with the real database, and when the discount rule changes one day you open the service's code. With dependency injection, the service asks for what it needs in its constructor and doesn't care who they are:

using System.Globalization;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<IBookRepository, InMemoryBookRepository>();
services.AddSingleton<IDiscountPolicy, MemberDiscountPolicy>();
services.AddScoped<OrderService>();

using ServiceProvider provider = services.BuildServiceProvider();
using IServiceScope scope = provider.CreateScope();

var orders = scope.ServiceProvider.GetRequiredService<OrderService>();
decimal total = orders.Total("978-0441013593", quantity: 3);
Console.WriteLine(total.ToString(CultureInfo.InvariantCulture));

public interface IBookRepository
{
    decimal GetPrice(string isbn);
}

public interface IDiscountPolicy
{
    decimal Apply(decimal amount);
}

public sealed class InMemoryBookRepository : IBookRepository
{
    private readonly Dictionary<string, decimal> _prices = new()
    {
        ["978-0441013593"] = 9.99m,
        ["978-0141439518"] = 7.50m,
    };

    public decimal GetPrice(string isbn) => _prices[isbn];
}

public sealed class MemberDiscountPolicy : IDiscountPolicy
{
    public decimal Apply(decimal amount) => Math.Round(amount * 0.90m, 2);
}

public sealed class OrderService
{
    private readonly IBookRepository _books;
    private readonly IDiscountPolicy _discounts;

    public OrderService(IBookRepository books, IDiscountPolicy discounts)
    {
        _books = books;
        _discounts = discounts;
    }

    public decimal Total(string isbn, int quantity) =>
        _discounts.Apply(_books.GetPrice(isbn) * quantity);
}
26.97

OrderService only knows the IBookRepository and IDiscountPolicy interfaces. The registration lines at the program's entry point decide which implementation is handed over; that point is called the composition root. When we ask for OrderService, the container looks at its constructor, finds the registrations for the parameters, creates them (and their dependencies first, if they have any), and passes them to OrderService. 3 × 9.99 = 29.97, which is 26.97 after a 10 percent discount.

You can do DI without a container too: new OrderService(new InMemoryBookRepository(), new MemberDiscountPolicy()) is also constructor injection. The container builds this chain for dozens of services on your behalf and manages the lifetimes. The "D" of SOLID (dependency inversion) that we discussed in the OOP series becomes concrete here: high-level code depends on abstractions, not on low-level details.

Setting Up the Container

.NET's standard container lives in the Microsoft.Extensions.DependencyInjection package. It comes with ASP.NET Core and Worker Service projects already; in a console application you add the package:

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
</ItemGroup>

The flow is always the same: add registrations to a ServiceCollection, build the provider with BuildServiceProvider(), ask for services from a scope. GetRequiredService<T>() throws if there's no registration; GetService<T>() returns null. In application code you almost always want the first one, because quietly passing a missing registration along as null only moves the error further down the line.

The order of registrations doesn't matter in the sense of registering a service before or after its dependencies; the container resolves on the first request. The one place order matters is when there are several registrations for the same interface; we'll get to that shortly.

The Container Does the Disposing

Services the container created that implement IDisposable are disposed by the container when their lifetime ends:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<AuditLog>();
services.AddScoped<UnitOfWork>();

using (ServiceProvider provider = services.BuildServiceProvider())
{
    provider.GetRequiredService<AuditLog>();

    using (IServiceScope scope = provider.CreateScope())
    {
        scope.ServiceProvider.GetRequiredService<UnitOfWork>();
        Console.WriteLine("request done");
    }

    Console.WriteLine("app shutting down");
}

public sealed class AuditLog : IDisposable
{
    public void Dispose() => Console.WriteLine("dispose AuditLog");
}

public sealed class UnitOfWork : IDisposable
{
    public void Dispose() => Console.WriteLine("dispose UnitOfWork");
}
request done
dispose UnitOfWork
app shutting down
dispose AuditLog

The scoped UnitOfWork was disposed as soon as the scope closed; the singleton AuditLog lived until the provider was disposed, that is, until the application shut down. This is the DI version of the OOP series rule "whoever calls new calls Dispose": if the container created the service, the container cleans it up. You shouldn't dispose a dependency you received through your constructor; other services may be using it too.

Captive Dependency

The sneakiest DI bug is a long-lived service taking a short-lived service through its constructor. Let a singleton StockCache depend on a scoped UnitOfWork:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddScoped<UnitOfWork>();
services.AddSingleton<StockCache>();

using ServiceProvider loose = services.BuildServiceProvider();
StockCache a, b;
using (IServiceScope s1 = loose.CreateScope())
    a = s1.ServiceProvider.GetRequiredService<StockCache>();
using (IServiceScope s2 = loose.CreateScope())
    b = s2.ServiceProvider.GetRequiredService<StockCache>();
Console.WriteLine($"same UnitOfWork in two requests: {ReferenceEquals(a.Work, b.Work)}");

try
{
    using ServiceProvider strict = services.BuildServiceProvider(new ServiceProviderOptions
    {
        ValidateScopes = true,
        ValidateOnBuild = true,
    });
}
catch (AggregateException ex)
{
    Console.WriteLine(ex.InnerExceptions[0].Message);
}

public sealed class UnitOfWork { }

public sealed class StockCache
{
    public StockCache(UnitOfWork work) => Work = work;

    public UnitOfWork Work { get; }
}
same UnitOfWork in two requests: True
Error while validating the service descriptor 'ServiceType: StockCache Lifetime: Singleton ImplementationType: StockCache': Cannot consume scoped service 'UnitOfWork' from singleton 'StockCache'.

With the default settings the container didn't object. StockCache was created on the first request and got a UnitOfWork in its constructor. The second request used the same singleton, and therefore the same UnitOfWork. Worse, because the singleton was created in the root scope, its dependency was resolved from the root scope too: a service meant to be "per request" turned into a single object that lives until the application shuts down and is shared by every request. The scoped service was held "captive" inside the singleton. In real life this means a DbContext shared between requests: several requests touching an object that isn't thread-safe at the same time, and strange bugs you can't reproduce.

ValidateScopes and ValidateOnBuild catch this mistake when the provider is built. ASP.NET Core turns both on by default in the Development environment; turning them on in your own console or worker application is a good habit. The rule: a service shouldn't depend through its constructor on a service that lives shorter than it does. If a singleton really needs a scoped service, it takes an IServiceScopeFactory and opens its own scope for each piece of work.

Multiple Implementations and Factories

You can register the same interface more than once. When a single instance is requested, the last registration wins; when IEnumerable<T> is requested, all of them come in registration order:

using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

var services = new ServiceCollection();
services.AddSingleton<IDiscountRule, MemberDiscount>();
services.AddSingleton<IDiscountRule, BulkDiscount>();
services.TryAddSingleton<IDiscountRule, NoDiscount>();
services.AddSingleton(sp => new Checkout(sp.GetServices<IDiscountRule>(), taxRate: 0.18m));

using ServiceProvider provider = services.BuildServiceProvider();

Console.WriteLine(provider.GetRequiredService<IDiscountRule>().GetType().Name);
Console.WriteLine(string.Join(", ", provider.GetServices<IDiscountRule>().Select(r => r.GetType().Name)));
Console.WriteLine(provider.GetRequiredService<Checkout>().Total(100m, quantity: 12).ToString(CultureInfo.InvariantCulture));

public interface IDiscountRule
{
    decimal Apply(decimal amount, int quantity);
}

public sealed class MemberDiscount : IDiscountRule
{
    public decimal Apply(decimal amount, int quantity) => amount * 0.95m;
}

public sealed class BulkDiscount : IDiscountRule
{
    public decimal Apply(decimal amount, int quantity) => quantity >= 10 ? amount - 50m : amount;
}

public sealed class NoDiscount : IDiscountRule
{
    public decimal Apply(decimal amount, int quantity) => amount;
}

public sealed class Checkout
{
    private readonly IEnumerable<IDiscountRule> _rules;
    private readonly decimal _taxRate;

    public Checkout(IEnumerable<IDiscountRule> rules, decimal taxRate)
    {
        _rules = rules;
        _taxRate = taxRate;
    }

    public decimal Total(decimal unitPrice, int quantity)
    {
        decimal amount = unitPrice * quantity;
        foreach (IDiscountRule rule in _rules)
            amount = rule.Apply(amount, quantity);
        return Math.Round(amount * (1 + _taxRate), 2);
    }
}
BulkDiscount
MemberDiscount, BulkDiscount
1286.20

GetRequiredService<IDiscountRule>() returned BulkDiscount, the last one added. GetServices brought both rules in order; NoDiscount isn't in the list, because TryAddSingleton does nothing if that interface already has a registration. Libraries register their default implementations with TryAdd so the application can override them by registering its own implementation first.

Checkout's constructor wants a decimal the container knows nothing about. In this case you use a factory registration: the lambda receives the provider, resolves what it needs from it and creates the object itself. The math: 12 × 100 = 1200, 1140 after the 5 percent member discount, 1090 after the bulk discount, 1286.20 with 18 percent tax.

Keyed services, which came with .NET 8 (choosing one of several implementations of the same interface by a key), make this scenario even easier; we covered them in the .NET 8 post of the .NET Core Web API series.

Common Pitfalls

A missing registration. If the container can't resolve a constructor parameter, it reports the error on the first request, and the message says clearly what's missing:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddScoped<OrderService>();

using ServiceProvider provider = services.BuildServiceProvider();
using IServiceScope scope = provider.CreateScope();

try
{
    scope.ServiceProvider.GetRequiredService<OrderService>();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

public interface IBookRepository { }

public sealed class OrderService
{
    public OrderService(IBookRepository books) { }
}
Unable to resolve service for type 'IBookRepository' while attempting to activate 'OrderService'.

The message is long but easy to read: which type, while creating which service. ValidateOnBuild catches this error at application startup instead of on the first request.

Asking the root provider for a scoped service. If you ask the provider directly for a scoped service without opening a scope, that service effectively lives like a singleton and isn't cleaned up until the provider is disposed. With ValidateScopes on, this becomes an exception too:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddScoped<UnitOfWork>();

using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);

try
{
    provider.GetRequiredService<UnitOfWork>();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed class UnitOfWork { }
Cannot resolve scoped service 'UnitOfWork' from root provider.

Service locator. Injecting the container itself (IServiceProvider) everywhere and calling GetRequiredService wherever something is needed isn't DI; it's the service locator anti-pattern. Dependencies become invisible in the constructor, missing registrations blow up deep inside the run, and you need to set up a container just to test the class. Code that touches the container should stay in the composition root and a few infrastructure points (factories, background jobs using IServiceScopeFactory).

Disposable transients requested from the root. The container tracks the disposable transients it creates so it can dispose them. If you keep asking the root provider for disposable transients outside a scope, they all pile up in memory until the provider closes. That's a memory leak, and in a worker it gets noticed hours later. Always resolve short-lived disposables inside a scope.

A Small Experiment

★☆☆ In the opening example, make Cart a singleton and Clock transient. Guess the output of the four lines first, then run it.

★★☆ Fix StockCache so that it takes an IServiceScopeFactory through its constructor and, on every Refresh() call, opens its own scope and resolves UnitOfWork from there. What does ValidateOnBuild say now? Do two calls get the same UnitOfWork?

★★★ Add an Order property to the IDiscountRules and have Checkout apply the rules in that order. Shuffle the registration order and show that the result doesn't change. Then, as if you were writing a library, write an AddBookstoreDiscounts(this IServiceCollection services) extension method and register the default rule with TryAdd.

The biggest reward of constructor injection is being able to replace a class's dependencies with fakes. We use that in the last post of the series: writing unit tests with xUnit, data-driven tests with Theory and InlineData, and designing testable code.