C# 14 · .NET 10 LTSNotes for .NET 11Beginner → Advanced

Dependency Injection in .NET, explained visually

Learn how IServiceCollection and IServiceProvider wire your app together: what to register, which lifetime to pick, when not to use DI, and how real ASP.NET Core, WPF and Worker apps do it. Every chapter has animations, a simulator or a bad-vs-good code toggle.

IServiceCollection AddTransient<IClock> AddScoped<AppDbContext> AddSingleton<ICache> AddHttpClient<IApi> DI container IServiceProvider OrdersController (IOrderService) OrderService (AppDbContext, ICache) InvoiceWorker (IServiceScopeFactory)

01The problem DI solves

A dependency is any object your class needs to do its job: a repository, an email sender, a clock, an HTTP client. The question is simply: who creates it?

Analogy: the restaurant kitchen. A chef (your class) needs ingredients (dependencies). In a badly run kitchen every chef goes out to buy their own tomatoes. In a well run kitchen the chef lists what they need on a ticket and the supply manager (the DI container) delivers it. The chef can cook anywhere, the supplier can be swapped, and a trainee can practise with plastic tomatoes (fakes in unit tests).
An order service that sends a confirmation email

The class builds everything with new. It is welded to SQL Server, to SMTP and to the real clock.

OrderService.cs (tightly coupled)
public class OrderService
{
    // ❌ Hard-coded: can't swap, can't fake, must know connection strings
    private readonly SqlOrderRepository _repo = new("Server=prod;Database=Shop;...");
    private readonly SmtpEmailSender _email = new("smtp.office365.com", 587);

    public async Task PlaceAsync(Order order)
    {
        order.CreatedAt = DateTime.Now;          // ❌ untestable time
        await _repo.SaveAsync(order);
        await _email.SendAsync(order.CustomerEmail, "Thanks for your order!");
    }
}
  • Changing SMTP to SendGrid means editing OrderService (and every other class that did the same).
  • A unit test would hit a real database and send real email.
  • Configuration is scattered: every class must know how to build its dependencies, and theirs.
Real world
An Indian e-commerce startup moves from Razorpay to Stripe for international cards. With DI, CheckoutService depends on IPaymentGateway; the switch is one registration line plus the new class.
Real world
A hospital app sends lab results by SMS in production but must never text real patients from a staging server. Register ConsoleSmsSender when builder.Environment.IsStaging().
Real world
A bank's interest calculator depends on "today". Tests inject a FakeTimeProvider fixed to 31 March to verify the year-end calculation without waiting a year.

02The vocabulary, in plain words

DI conversations use a handful of terms. Learn these once and every doc page becomes readable.

Inversion of Control (IoC)

The general idea: your class gives up control of creating its collaborators. Control moves to the framework. DI is the most common way to achieve IoC.

Dependency Inversion Principle

The "D" in SOLID: high-level code (OrderService) and low-level code (SqlOrderRepository) both depend on an abstraction (IOrderRepository), not on each other.

Dependency Injection

The technique: dependencies are passed in (usually through the constructor) instead of being created inside.

Service

Anything registered in the container. Not a "web service": a logger, a repository or a TimeProvider are all services.

IServiceCollection

The recipe book: a list of ServiceDescriptor entries you add to at startup (builder.Services). Read-only after the host is built.

ServiceDescriptor

One recipe: service type (what you ask for), implementation (type, factory or instance), lifetime, and optionally a key.

IServiceProvider (container)

The kitchen built from the recipe book. It creates objects, caches singletons and scoped instances, and disposes what it created.

Scope (IServiceScope)

A bubble with its own cache for scoped services. ASP.NET Core creates one per HTTP request; you create your own in workers and desktop apps.

Object graph

The tree of dependencies of dependencies. Ask for a controller, get its service, its repository, its DbContext… all resolved recursively.

Composition root

The single place where the graph is configured: Program.cs (or App.xaml.cs in WPF). Only here should code know concrete types.

Lifetime

How long an instance lives and who shares it: Transient, Scoped or Singleton.

Keyed service

A registration with a name/key (.NET 8+) so several implementations of one interface can live side by side: "stripe", "razorpay".

Where does it live?

The contracts (IServiceCollection, ServiceDescriptor, [FromKeyedServices]…) are in Microsoft.Extensions.DependencyInjection.Abstractions; the default container is in Microsoft.Extensions.DependencyInjection (10.0.x for .NET 10). ASP.NET Core and the Generic Host (Microsoft.Extensions.Hosting) reference them for you. From .NET 11, the abstractions package ships inside the shared framework, so explicit references to it raise warning NU1510.

03Register → Build → Resolve, step by step

Press Next (or Play) to watch an ASP.NET Core app register services at startup, build the container, then resolve and dispose a real object graph for one HTTP request.

1 · RegisterIServiceCollection

250+framework services: ILogger<T>, IOptions<T>, IConfiguration…
ScopedIOrderService → OrderService
ScopedIOrderRepository → SqlOrderRepository
ScopedAppDbContext (AddDbContext)
SingletonIEmailSender → SmtpEmailSender
TransientIPriceCalculator → PriceCalculator

2 · Build

IServiceProviderroot container
Development: ValidateScopes + ValidateOnBuild
REQUEST SCOPE · GET /orders/42
HttpContext.RequestServices

3 · Resolveobject graph

  • ctrlOrdersController(IOrderService)
    • SOrderService(repo, email, calc, logger)
      • SSqlOrderRepository(AppDbContext)
        • SAppDbContext cached in scope
      • GSmtpEmailSender cached in root, survives
      • TPriceCalculator brand new
      • GILogger<OrderService> open generic
Step
Program.cs

The same three steps, outside ASP.NET Core

You can use the container in any .NET app, even a plain console app, without a host:

Program.cs (console, no host)
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();                 // 1. Register
services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
services.AddTransient<Greeter>();

using ServiceProvider provider = services.BuildServiceProvider(   // 2. Build
    new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true });

var greeter = provider.GetRequiredService<Greeter>();   // 3. Resolve (only here, at the root!)
greeter.Greet("Asha");

public sealed class Greeter(IMessageWriter writer)
{
    public void Greet(string name) => writer.Write($"Hello, {name}!");
}
Constructor selection rules

If a class has several public constructors, the container picks the one with the most parameters it can resolve. If two constructors tie, it throws (ambiguous). Parameters that DI can't supply must have default values. Keep one constructor and life is simple.

GetService vs GetRequiredService

GetService<T>() returns null when nothing is registered; GetRequiredService<T>() throws InvalidOperationException with a clear message. Prefer the required version so mistakes fail loudly. Asking for IEnumerable<T> never fails: you get an empty sequence.

04Ways a dependency can arrive

Constructor injection is the default. The others exist for specific places in the framework, and one is an anti-pattern.

Recommended Dependencies are required, visible, and set once. Store them in readonly fields.

InvoiceService.cs
public sealed class InvoiceService
{
    private readonly IInvoiceRepository _repo;
    private readonly ILogger<InvoiceService> _log;

    public InvoiceService(IInvoiceRepository repo, ILogger<InvoiceService> log)
    {
        _repo = repo;
        _log = log;
    }
}

05Service lifetimes: Transient, Scoped, Singleton

The single most important decision in DI. A lifetime answers two questions: how many instances exist and who shares them.

Transient

A new instance every time it is requested, even twice in the same request.

AddTransient · lightweight, stateless helpers: validators, mappers, calculators, formatters.

Disposed: at the end of the scope that resolved it.

Scoped

One instance per scope. In web apps that means one per HTTP request (in Blazor Server, one per circuit).

AddScoped · DbContext, unit of work, current user / tenant, request-level caches.

Disposed: when the scope ends.

Singleton

One instance for the whole app, created on first use (or supplied by you), shared by every thread.

AddSingleton · caches, config, HttpClient handlers, TimeProvider, expensive clients. Must be thread-safe.

Disposed: when the root container is disposed (app shutdown).

Analogy: a hotel. Transient is a paper cup: every drink gets a new one. Scoped is your room key: one per guest stay, shared by everything you do during that stay, handed back at checkout. Singleton is the lobby: one for the whole hotel, used by everyone at the same time, so it had better handle crowds.

Lifetime simulator

Each request resolves OrdersController and OrderService; both ask for a Transient PriceCalculator, a Scoped AppDbContext and a Singleton IMemoryCache. Watch which instance numbers repeat.

Root container · lives until shutdown

No singletons yet. Send a request.

Request scopes · most recent first

Press “New HTTP request”.

What the simulator registers
builder.Services.AddTransient<IPriceCalculator, PriceCalculator>();   // new every time
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(cs));  // scoped by default
builder.Services.AddMemoryCache();                                    // IMemoryCache: singleton

// Bug mode adds this. ValidateScopes throws at startup in Development:
// "Cannot consume scoped service 'AppDbContext' from singleton 'IReportCache'."
builder.Services.AddSingleton<IReportCache, ReportCache>();           // ReportCache(AppDbContext db) ❌

Which lifetime should I pick?

Answer the questions to get a recommendation.

Common services and their usual lifetimes

ServiceLifetimeWhy
DbContext (EF Core) via AddDbContextScopedUnit of work per request; not thread-safe. In singletons, WPF or Blazor use IDbContextFactory<T> and create short-lived contexts.
Typed HttpClient via AddHttpClient<IApi, Api>()TransientThe client is cheap; IHttpClientFactory pools the handlers. Don't capture a typed client in a singleton.
IHttpClientFactory, ILogger<T>, ILoggerFactorySingletonFramework-registered, thread-safe.
IOptions<T>SingletonRead once; never changes.
IOptionsSnapshot<T>ScopedRe-reads config per request. Can't be injected into singletons.
IOptionsMonitor<T>SingletonLive updates + change notifications, safe anywhere.
IMemoryCache, HybridCache, distributed cacheSingletonShared, thread-safe store.
TimeProvider, ID generators, stateless mappersSingleton / TransientStateless; either works. Microsoft's guidance prefers transient for cheap stateless classes and singleton for expensive or shared ones.
Current user / tenant contextScopedDifferent for each request; must never leak between users.
FluentValidation validators, MediatR handlersTransient / ScopedFollow the library's registration extension (AddValidatorsFromAssembly, AddMediatR).
BackgroundService via AddHostedServiceSingletonLives for the app; create scopes inside for scoped work (chapter 11).
WPF MainWindow / shell ViewModelSingletonOne main window; dialogs and child windows are usually transient.

06Lifetime rules: who may depend on whom

A service should only depend on services that live at least as long as it does. Tap a cell to see why.

consumer ↓ / depends on →
Transient
Scoped
Singleton
Transient
Scoped
Singleton
Tap a cell

Rows are the class being built; columns are what its constructor asks for.

The captive dependency

When a longer-lived service holds a shorter-lived one, the short-lived one is "held captive": it silently lives as long as its owner. Mark Seemann coined the term, and Microsoft lists it as a core anti-pattern.

A singleton price cache that reads from the database
Captive dependency
builder.Services.AddDbContext<ShopDbContext>();              // scoped
builder.Services.AddSingleton<IPriceCache, PriceCache>();    // singleton

public class PriceCache(ShopDbContext db) : IPriceCache       // ❌ first request's DbContext, forever
{
    public decimal Get(int productId) =>
        db.Products.Find(productId)!.Price;                   // not thread-safe, then ObjectDisposedException
}

Two requests hit the same DbContext on two threads (EF Core throws "a second operation was started on this context"), and once the first request's scope is disposed, every call fails. In Development, scope validation turns this into a startup error: Cannot consume scoped service 'ShopDbContext' from singleton 'IPriceCache'.

Turn on the safety nets

WebApplication.CreateBuilder and Host.CreateApplicationBuilder enable both checks automatically when the environment is Development:

  • ValidateScopes: scoped services can't be resolved from the root provider, and can't be injected into singletons.
  • ValidateOnBuild: at Build(), every registration is checked to be constructible, so a missing registration fails at startup instead of on the first request that needs it.
Turn them on everywhere (CI, staging, tests)
// ASP.NET Core
builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;
    o.ValidateOnBuild = true;
});

// Plain ServiceCollection
var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
    ValidateScopes = true,
    ValidateOnBuild = true
});
Limits of validation

ValidateOnBuild can't see inside factory lambdas (sp => new X(sp.GetRequiredService<Y>())) or anything resolved later through IServiceProvider. Prefer type registrations so the container can check the graph for you.

07The registration API

Everything you register is a ServiceDescriptor in a list. Most surprises come from forgetting that it's an ordered list.

Registration playground

Click registrations to add them to builder.Services, then read what the container would give you. Try registering two implementations, then TryAdd, then Replace.

builder.Services (INotifier descriptors)

    Empty. Nothing registered for INotifier.

    All the registration shapes

    ShapeExampleAuto-disposed?Use it when
    Service + implementationAddScoped<IRepo, SqlRepo>()YesThe normal case.
    Implementation onlyAddScoped<OrderService>()YesNo abstraction needed (same as <OrderService, OrderService>).
    FactoryAddSingleton<IBlob>(sp => new Blob(cfg["Conn"]))YesConstructor needs a primitive or custom logic.
    InstanceAddSingleton<IClock>(new FixedClock())NoObject created before the container; you own its disposal.
    Open genericAddScoped(typeof(IRepository<>), typeof(EfRepository<>))YesOne registration serves IRepository<Order>, IRepository<Customer>…
    Descriptorservices.Add(new ServiceDescriptor(typeof(IX), typeof(X), ServiceLifetime.Scoped))YesLifetime decided at runtime / library code.
    Keyed (.NET 8+)AddKeyedSingleton<IPay, StripePay>("stripe")YesSeveral implementations chosen by name (chapter 8).

    Rules worth memorising

    • Last registration wins when you inject a single INotifier.
    • All registrations, in order, when you inject IEnumerable<INotifier>.
    • TryAdd* adds only if the service type has no registration yet: perfect for library defaults the app can override.
    • TryAddEnumerable adds only if that exact service + implementation pair is new: stops duplicate plugins when an AddX() is called twice.
    • Replace removes the first registration of the service type and adds the new one; RemoveAll<T> removes every one. Both live in Microsoft.Extensions.DependencyInjection.Extensions.
    • Registration order doesn't matter, except between registrations of the same service type.

    Group registrations in extension methods

    The framework convention is one Add{Feature} method per feature, in the Microsoft.Extensions.DependencyInjection namespace so IntelliSense finds it. Your Program.cs stays readable and each module owns its wiring.

    BillingServiceCollectionExtensions.cs
    namespace Microsoft.Extensions.DependencyInjection;
    
    public static class BillingServiceCollectionExtensions
    {
        public static IServiceCollection AddBilling(this IServiceCollection services, IConfiguration config)
        {
            services.AddOptions<BillingOptions>()
                    .Bind(config.GetSection("Billing"))
                    .ValidateDataAnnotations()
                    .ValidateOnStart();
    
            services.TryAddSingleton(TimeProvider.System);          // app may override
            services.AddScoped<IInvoiceService, InvoiceService>();
            services.AddHttpClient<ITaxApi, GstTaxApi>(c => c.BaseAddress = new Uri("https://api.example-gst.in/"));
            return services;
        }
    }
    
    // Program.cs
    builder.Services.AddBilling(builder.Configuration);

    One implementation, several interfaces

    CartService implements ICartReader and ICartWriter
    Two separate instances
    builder.Services.AddSingleton<ICartReader, CartService>();
    builder.Services.AddSingleton<ICartWriter, CartService>();   // ❌ a second CartService, different state

    Check what's registered at runtime

    IServiceProviderIsService (and IServiceProviderIsKeyedService) answer "can you resolve this type?" without creating it. Minimal APIs use it to decide whether a handler parameter is a service or comes from the request body.

    08Keyed services (.NET 8 and later)

    Register several implementations of the same interface under keys, and ask for one by key. Keys can be any object with sensible Equals: strings, enums, types.

    Real world
    A checkout supports UPI through Razorpay in India and cards through Stripe elsewhere. A storage layer writes invoices to Azure Blob but thumbnails to local disk. A notification hub sends by email, SMS or push. Each is "one interface, several implementations, chosen by name".
    Program.cs
    public enum Gateway { Stripe, Razorpay }
    
    builder.Services.AddKeyedScoped<IPaymentGateway, StripeGateway>(Gateway.Stripe);
    builder.Services.AddKeyedScoped<IPaymentGateway, RazorpayGateway>(Gateway.Razorpay);
    
    // 1) Constructor: the key is known at design time
    public sealed class RefundService([FromKeyedServices(Gateway.Stripe)] IPaymentGateway stripe) { }
    
    // 2) Minimal API parameter
    app.MapPost("/pay/razorpay", ([FromKeyedServices(Gateway.Razorpay)] IPaymentGateway gw, PayRequest r)
        => gw.ChargeAsync(r));
    
    // 3) Key decided at runtime (from the order's country)
    public sealed class CheckoutService(IServiceProvider sp)
    {
        public Task PayAsync(Order o) =>
            sp.GetRequiredKeyedService<IPaymentGateway>(o.Country == "IN" ? Gateway.Razorpay : Gateway.Stripe)
              .ChargeAsync(o.ToPayRequest());
    }

    Learn the key, fall back for any key

    [ServiceKey] and KeyedService.AnyKey
    // One registration that serves every tenant key; the implementation learns its key
    builder.Services.AddKeyedScoped<ITenantStore, TenantStore>(KeyedService.AnyKey);
    
    public sealed class TenantStore([ServiceKey] string tenantId, ShopDbContext db) : ITenantStore { }
    
    // A specific key always beats the AnyKey fallback
    builder.Services.AddKeyedSingleton<ICache, PremiumCache>("premium");
    builder.Services.AddKeyedSingleton<ICache>(KeyedService.AnyKey, (sp, key) => new DefaultCache($"{key}"));
    Gotchas that changed between versions
    • Keyed and non-keyed registrations are separate tables. GetService<IPaymentGateway>() never sees a keyed registration, and IEnumerable<T> doesn't include them.
    • .NET 9 (and 8.0.9): [FromKeyedServices("x")] now throws when key "x" isn't registered, instead of silently injecting the non-keyed service.
    • .NET 10: GetKeyedService(KeyedService.AnyKey) throws (AnyKey can't pick a single service), and GetKeyedServices(KeyedService.AnyKey) returns only services registered with a specific key.
    Choosing an exporter by format
    Manual switch everywhere
    public byte[] Export(Report r, string format) => format switch
    {
        "pdf"  => new PdfExporter().Render(r),     // ❌ new, no DI, grows forever
        "xlsx" => new ExcelExporter().Render(r),
        _ => throw new NotSupportedException(format)
    };

    Keyed vs IEnumerable<T>: use keys when the caller picks one by name; inject IEnumerable<T> when you want all of them (run every validator, notify every channel) or when each implementation decides whether it applies (CanHandle(...)).

    09Patterns that work well with DI

    Real code needs runtime values, cross-cutting behaviour and configuration. These patterns keep the graph clean.

    Never inject raw strings or IConfiguration deep into services. Bind a section to a class and inject IOptions<T>.

    Options pattern
    public sealed class SmtpOptions
    {
        [Required] public string Host { get; set; } = "";
        [Range(1, 65535)] public int Port { get; set; } = 587;
    }
    
    builder.Services.AddOptions<SmtpOptions>()
        .BindConfiguration("Smtp")
        .ValidateDataAnnotations()
        .ValidateOnStart();                               // fail at startup, not on the first email
    
    public sealed class SmtpEmailSender(IOptions<SmtpOptions> options) : IEmailSender
    {
        private readonly SmtpOptions _o = options.Value;
    }
    InterfaceLifetimeReloads?Use in
    IOptions<T>SingletonNoAnything; settings that don't change.
    IOptionsSnapshot<T>ScopedPer requestScoped/transient services only.
    IOptionsMonitor<T>SingletonLive + OnChangeSingletons that must see updates.

    10Disposal: who cleans up?

    The rule: whoever creates an object disposes it. If the container created it, never call Dispose() on it yourself.

    Transient + Scoped

    Disposed when the scope that resolved them ends (end of the HTTP request).

    Singleton

    Disposed when the root provider is disposed, normally at app shutdown.

    Instances you passed in

    AddSingleton(new X()): the container did not create it and will not dispose it.

    Async disposal in a console tool or worker
    Throws at dispose time
    using (var scope = provider.CreateScope())                 // ❌ synchronous Dispose
    {
        var exporter = scope.ServiceProvider.GetRequiredService<S3Exporter>();  // implements only IAsyncDisposable
        await exporter.UploadAsync();
    }   // InvalidOperationException: type only implements IAsyncDisposable
    The transient-disposable leak

    The container keeps a reference to every IDisposable transient it creates so it can dispose it later. Resolve 1,000 of them from the root provider and all 1,000 stay in memory until shutdown. Resolve them inside a scope, or create them with a factory and dispose them yourself.

    Other disposal rules from Microsoft's guidelines
    • Receiving an IDisposable via DI doesn't mean your class must implement IDisposable, and it must not dispose it.
    • Don't resolve transient or scoped disposables from the root scope.
    • Scopes aren't hierarchical: disposing one scope doesn't touch another.
    • ServiceProvider.DisposeAsync() awaits with ConfigureAwait(false), so don't expect disposal to finish on the UI thread.

    11Real apps: ASP.NET Core, WPF and Worker services

    Same container, three very different hosts. The thing that changes most is who creates scopes.

    Scopes are automatic: the framework creates one scope per HTTP request (exposed as HttpContext.RequestServices) and disposes it when the response finishes. Controllers, minimal API handlers, Razor Pages, SignalR hubs and filters are all built from that scope.

    Scenario
    A food-delivery API: POST /orders validates the cart, saves the order with EF Core, charges the customer, and publishes an "order placed" event.
    Program.cs (.NET 10 Web API)
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddDbContext<FoodDbContext>(o =>
        o.UseNpgsql(builder.Configuration.GetConnectionString("Food")));         // scoped
    builder.Services.AddScoped<IOrderService, OrderService>();
    builder.Services.AddScoped<ICurrentUser, HttpCurrentUser>();                 // per request
    builder.Services.AddHttpContextAccessor();
    builder.Services.AddKeyedScoped<IPaymentGateway, RazorpayGateway>("razorpay");
    builder.Services.AddSingleton<IEventBus, RabbitMqEventBus>();                // shared connection
    builder.Services.AddHttpClient<IMapsApi, MapsApi>();                         // typed client
    builder.Services.AddSingleton(TimeProvider.System);
    builder.Services.AddControllers();
    
    var app = builder.Build();                 // Development: ValidateScopes + ValidateOnBuild run here
    
    // Run a scoped service once at startup (migrations, seeding)
    await using (var scope = app.Services.CreateAsyncScope())
    {
        var db = scope.ServiceProvider.GetRequiredService<FoodDbContext>();
        await db.Database.MigrateAsync();
    }
    
    app.UseMiddleware<RequestTimingMiddleware>();
    app.MapControllers();
    app.MapPost("/orders", async (PlaceOrder cmd, IOrderService orders, CancellationToken ct) =>
        Results.Created($"/orders/{(await orders.PlaceAsync(cmd, ct)).Id}", null));
    app.Run();
    Middleware that needs a scoped service
    Throws or captures
    public sealed class AuditMiddleware(RequestDelegate next, FoodDbContext db)   // ❌ middleware is built once
    {
        public async Task InvokeAsync(HttpContext ctx)
        {
            db.Audits.Add(new Audit(ctx.Request.Path));
            await db.SaveChangesAsync();
            await next(ctx);
        }
    }

    Conventional middleware is constructed once for the app's lifetime, so a scoped constructor parameter behaves like a singleton. ASP.NET Core throws at runtime for this.

    Where things come from in a web request

    • Controllers: constructor injection, or [FromServices] on one action.
    • Minimal APIs: parameters are bound from DI when the type is registered (checked with IServiceProviderIsService); [FromServices] / [FromKeyedServices] make it explicit.
    • Razor / Blazor: @inject. In Blazor Server a scope lasts for the whole circuit, so prefer IDbContextFactory over a scoped DbContext.
    • Filters: use [ServiceFilter(typeof(AuditFilter))] or [TypeFilter] so the filter gets DI.
    • Avoid HttpContext.RequestServices.GetService and static access to IHttpContextAccessor in business code.

    12Anti-patterns and how to fix them

    Most DI bugs fall into a small set of patterns. Toggle each one to see the fix.

    1 · Calling BuildServiceProvider during registration
    Builds a second, temporary container
    builder.Services.AddSingleton<IFeatureFlags, FeatureFlags>();
    var flags = builder.Services.BuildServiceProvider()                  // ❌ a second set of singletons
                                .GetRequiredService<IFeatureFlags>();
    if (flags.IsOn("NewTax")) builder.Services.AddScoped<ITax, NewTax>();
    2 · Async work in a DI factory
    Deadlock risk
    builder.Services.AddSingleton<ISecretStore>(sp =>
        SecretStore.LoadAsync(sp.GetRequiredService<IConfiguration>()).Result);   // ❌ sync-over-async
    3 · Static access to services
    Ambient container
    public static class ServiceLocator { public static IServiceProvider Services = null!; }
    ServiceLocator.Services = app.Services;                        // ❌ global state
    
    public class Invoice { public decimal Tax() =>
        ServiceLocator.Services.GetRequiredService<ITaxCalculator>().For(this); }   // ❌ hidden, root scope
    4 · Constructor over-injection
    A class doing too much
    public class OrderManager(IOrderRepository o, ICustomerRepository c, IPaymentGateway p,
        IEmailSender e, ISmsSender s, IPdfRenderer pdf, IAuditLog a, IInventory i, IShipping sh) { }  // ❌
    5 · Disposing what you didn't create
    Breaks everyone else in the scope
    public class ReportService(ReportsDbContext db)
    {
        public async Task RunAsync()
        {
            using (db) { /* ... */ }     // ❌ other services in this request now get a disposed context
        }
    }
    6 · Mutable state in a singleton
    Race conditions under load
    public sealed class VisitorCounter                      // registered as singleton
    {
        private readonly Dictionary<string, int> _hits = new();   // ❌ concurrent writes corrupt it
        public void Hit(string page) => _hits[page] = _hits.GetValueOrDefault(page) + 1;
    }
    7 · Storing user data in the container
    Every user shares one cart
    builder.Services.AddSingleton<ShoppingCart>();   // ❌ data, not a service; leaks between users
    SmellSymptomFix
    Scoped resolved from root“Cannot resolve scoped service from root provider”, or a DbContext that never diesCreate a scope (CreateAsyncScope) and resolve from it.
    Missing registration“Unable to resolve service for type X while attempting to activate Y”Register X (check the namespace and the exact interface), or enable ValidateOnBuild to catch it at startup.
    Ambiguous constructors“Unable to activate type … The following constructors are ambiguous”Keep one public constructor, or mark one with [ActivatorUtilitiesConstructor] for ActivatorUtilities.
    Circular dependency“A circular dependency was detected for the service of type…”Extract the shared logic into a third service, or use events. Don't break it with a service locator.
    new HttpClient() per callSocket exhaustion under loadAddHttpClient typed / named clients.
    Interface for everythingHundreds of one-implementation interfaces, no testsAbstract at boundaries (I/O, time, external systems); register simple classes directly.

    13When to use DI, and when not to

    DI is a tool for wiring services. It is not a rule that every object must come from a container.

    Use DI for

    • Anything that touches the outside world: databases, files, HTTP, queues, email, the clock.
    • Services with a lifetime to manage or resources to dispose.
    • Behaviour you want to swap per environment, tenant or feature flag.
    • Code you want to unit test in isolation.
    • Cross-cutting concerns added by decorators: caching, logging, retries.
    • Framework integration: controllers, hubs, hosted services, ViewModels.

    Don't use DI for

    • Entities, DTOs, records, value objects: new Order(...) is correct. They carry data, not services.
    • Pure functions with no I/O (Math.Round, a slug helper): a static method is fine.
    • Objects that need runtime data to be created: use a factory, not a registration.
    • Primitive settings like strings and ints: use the options pattern.
    • Tiny scripts and throwaway tools where a container adds ceremony without benefit.
    • Abstractions with no second implementation and no test: register the class itself; add an interface when a real need appears.

    Quick decision guide

    SituationChoose
    Needs a DB connection for one requestScoped + constructor injection
    Same DB access from a singleton or desktop appIDbContextFactory<T> or a new scope
    Expensive, thread-safe, shared by everyoneSingleton
    Cheap and statelessTransient
    Several implementations, caller picks by nameKeyed services
    Several implementations, all should runIEnumerable<T>
    Needs a runtime argument + servicesFactory + ActivatorUtilities
    Configuration valuesIOptions<T> / IOptionsMonitor<T>
    Add behaviour around an existing serviceDecorator
    Property injection, child containers, conventions, interceptionConsider a third-party container

    14Testing with DI

    DI pays for itself in tests. Unit tests don't even need the container; integration tests swap a few registrations.

    Unit test (xUnit): just call the constructor
    public class OrderServiceTests
    {
        [Fact]
        public async Task Stamps_order_with_current_time()
        {
            var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 31, 10, 0, 0, TimeSpan.Zero));
            var repo = new InMemoryOrderRepository();
            var email = Substitute.For<IEmailSender>();                 // NSubstitute / Moq / a hand-written fake
    
            var sut = new OrderService(repo, email, clock);
            await sut.PlaceAsync(new Order { CustomerEmail = "a@b.in" });
    
            Assert.Equal(clock.GetUtcNow(), repo.Saved.Single().CreatedAt);
            await email.Received(1).SendAsync("a@b.in", Arg.Any<string>());
        }
    }
    Integration test: replace services in WebApplicationFactory
    public class CheckoutApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
    {
        [Fact]
        public async Task Checkout_uses_fake_gateway()
        {
            var client = factory.WithWebHostBuilder(b => b.ConfigureTestServices(services =>
            {
                services.RemoveAll<IPaymentGateway>();
                services.AddScoped<IPaymentGateway, AlwaysApprovesGateway>();     // ✅ no real money moves
            })).CreateClient();
    
            var res = await client.PostAsJsonAsync("/checkout", new { cartId = 7 });
            res.EnsureSuccessStatusCode();
        }
    }
    A one-line safety test

    Build your real container with ValidateOnBuild = true in a test. If anyone forgets a registration, CI fails before a user ever sees "Unable to resolve service".

    15Built-in container vs third-party

    Microsoft recommends the built-in container unless you need a feature it doesn't have. Third-party containers plug in behind the same IServiceProvider, so your classes don't change.

    FeatureMicrosoft.Extensions.DIScrutor 7.xAutofac 9.x
    Constructor injection, 3 lifetimes, open generics✓(uses built-in)✓
    Keyed services✓ (.NET 8+)(uses built-in)✓ (keyed, plus AnyKey in 9.x)
    Assembly scanning / conventions✗✓ Scan✓
    DecoratorsManual factory✓ Decorate✓
    Property injection✗✗✓
    Child / nested containers, tagged lifetimes✗✗✓
    Func<T>, Lazy<T>, Owned<T>✗✗✓
    Interception (AOP)✗✗✓ (DynamicProxy)
    ModulesAdd{Feature} extensions—✓ Module
    Is it a container?YesNo, extensions for the built-in oneYes
    Scrutor: register by convention
    builder.Services.Scan(scan => scan
        .FromAssemblyOf<IOrderService>()
        .AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))
        .AsImplementedInterfaces()
        .WithScopedLifetime());
    Autofac: plug it in behind IServiceProvider
    // dotnet add package Autofac.Extensions.DependencyInjection
    builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
    builder.Host.ConfigureContainer<ContainerBuilder>(cb =>
    {
        cb.RegisterModule<BillingModule>();
        cb.RegisterType<ReportService>().PropertiesAutowired();        // property injection
    });

    Other containers Microsoft lists as compatible with ASP.NET Core: DryIoc, Grace, LightInject, Lamar, Stashbox and Simple Injector. Coming from classic .NET Framework (Unity, Ninject)? Microsoft.Extensions.DependencyInjection also targets .NET Standard 2.0, so you can adopt it before you migrate.

    16What changed, version by version

    Handy when you read older tutorials or upgrade an app.

    1. .NET Core 1.0 – 3.xBuilt-in container ships with ASP.NET Core; Startup.ConfigureServices; Generic Host (3.0).
    2. .NET 6Minimal hosting (WebApplication.CreateBuilder, builder.Services), CreateAsyncScope / AsyncServiceScope, IServiceProviderIsService.
    3. .NET 7Host.CreateApplicationBuilder brings the same style to workers, console and desktop apps.
    4. .NET 8 (LTS)Keyed services (AddKeyed*, [FromKeyedServices], [ServiceKey], KeyedService.AnyKey); C# 12 primary constructors; TimeProvider.
    5. .NET 9[FromKeyedServices] throws when the key is missing instead of injecting the non-keyed service (backported to 8.0.9).
    6. .NET 10 (LTS, current)GetKeyedService(AnyKey) throws and GetKeyedServices(AnyKey) returns only specifically-keyed services; BackgroundService runs all of ExecuteAsync as a task. Latest servicing: Microsoft.Extensions.DependencyInjection 10.0.13 (October 2026).
    7. .NET 11 (release candidate, GA expected November 2026)DI abstractions, Options, Logging and Hosting abstractions move into the shared framework (remove the package references; NU1510 warns); IHost.RunAsync/StopAsync throw when a background service fails.

    17Cheat sheet

    Everything on one screen. Bookmark this section.

    Transient

    New every resolve. Cheap, stateless helpers. Don't make disposable transients resolved from root.

    Scoped

    One per request/scope. DbContext, current user, unit of work. Never inside a singleton.

    Singleton

    One per app. Thread-safe, shared, expensive. Depends only on singletons.

    DI in 30 lines
    // Register
    services.AddTransient<IFoo, Foo>();             services.AddScoped<IBar, Bar>();
    services.AddSingleton<IBaz, Baz>();             services.AddSingleton<IClock>(new FixedClock()); // not disposed
    services.AddScoped<IBlob>(sp => new Blob(sp.GetRequiredService<IOptions<BlobOptions>>().Value));
    services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));          // open generic
    services.TryAddSingleton<ICache, MemCache>();                               // only if none yet
    services.TryAddEnumerable(ServiceDescriptor.Scoped<IRule, TaxRule>());      // no duplicate pairs
    services.Replace(ServiceDescriptor.Singleton<ICache, RedisCache>());        // swap the first
    services.RemoveAll<ICache>();                                               // remove all
    services.AddKeyedScoped<IPay, StripePay>("stripe");                         // keyed (.NET 8+)
    services.AddOptions<SmtpOptions>().BindConfiguration("Smtp").ValidateOnStart();
    services.AddHttpClient<IApi, Api>();                                        // typed client
    services.AddHostedService<Worker>();                                        // singleton
    
    // Consume
    public sealed class Checkout(IBar bar, [FromKeyedServices("stripe")] IPay pay, IEnumerable<IRule> rules);
    
    // Resolve manually (composition root, factories, workers only)
    sp.GetRequiredService<IFoo>();  sp.GetService<IFoo>();  sp.GetServices<IRule>();
    sp.GetRequiredKeyedService<IPay>("stripe");
    ActivatorUtilities.CreateInstance<ImportJob>(sp, "file.csv");
    
    // Scopes
    await using var scope = scopeFactory.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    
    // Safety nets
    builder.Host.UseDefaultServiceProvider(o => { o.ValidateScopes = true; o.ValidateOnBuild = true; });

    Golden rules

    1. Constructor injection by default.
    2. Depend on abstractions at the boundaries.
    3. Dependencies live at least as long as their consumer.
    4. Create a scope for each unit of work outside web requests.
    5. The container disposes what it creates; you dispose what you create.
    6. Resolve from IServiceProvider only in the composition root and infrastructure.

    Error message decoder

    • Unable to resolve service for type… → not registered.
    • Cannot consume scoped service … from singleton… → captive dependency.
    • Cannot resolve scoped service … from root provider → create a scope.
    • A circular dependency was detected… → split responsibilities.
    • Cannot access a disposed object → you kept or disposed something past its scope.
    • No service for type … has been registered → GetRequiredService on a missing type.

    You've reached the end. Scroll back to the simulator and try bug mode once more: if you can explain what happens, you understand DI.