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.
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?
The class builds everything with new. It is welded to SQL Server, to SMTP and to the real clock.
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.
The class only declares what it needs, as abstractions, in its constructor. Someone else builds and hands them in.
public class OrderService(
IOrderRepository repo, // ✅ any implementation: SQL, Cosmos, in-memory fake
IEmailSender email, // ✅ SMTP today, SendGrid tomorrow
TimeProvider clock) // ✅ FakeTimeProvider in tests (.NET 8+)
{
public async Task PlaceAsync(Order order)
{
order.CreatedAt = clock.GetUtcNow();
await repo.SaveAsync(order);
await email.SendAsync(order.CustomerEmail, "Thanks for your order!");
}
}
// Program.cs: the one place that decides "which implementation"
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IEmailSender, SendGridEmailSender>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<OrderService>();- Loose coupling: swap an implementation in one line of
Program.cs. - Testability: pass fakes straight into the constructor.
- Lifetime management: the container creates, shares and disposes objects for you.
- Explicit dependencies: the constructor is an honest list of what the class needs.
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.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().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".
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
2 · Build
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
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:
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}!");
}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 GetRequiredServiceGetService<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.
public sealed class InvoiceService
{
private readonly IInvoiceRepository _repo;
private readonly ILogger<InvoiceService> _log;
public InvoiceService(IInvoiceRepository repo, ILogger<InvoiceService> log)
{
_repo = repo;
_log = log;
}
}C# 12+ Primary constructors remove the boilerplate. The parameters are captured and usable in every member. Note they are not readonly, so don't reassign them.
public sealed class InvoiceService(IInvoiceRepository repo, ILogger<InvoiceService> log)
{
public async Task<Invoice?> GetAsync(int id)
{
log.LogInformation("Loading invoice {Id}", id);
return await repo.FindAsync(id);
}
}Framework entry points The framework resolves parameters for one call only. Ideal when only one action needs a heavy service.
// Minimal API: services are inferred from registered types (or mark with [FromServices])
app.MapPost("/orders", async (CreateOrder dto, IOrderService orders) =>
Results.Ok(await orders.CreateAsync(dto)));
// MVC controller action injection
public IActionResult Export([FromServices] IPdfExporter exporter) => File(exporter.Render(), "application/pdf");
// Middleware: scoped services go in InvokeAsync, not the constructor
public sealed class TenantMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx, ITenantContext tenant) // per request
{
tenant.Id = ctx.Request.Headers["X-Tenant"];
await next(ctx);
}
}Not in the built-in container Microsoft.Extensions.DependencyInjection does not do property injection. Blazor components are the exception: @inject / [Inject] set properties on components. If you need it for normal classes, that's a reason to plug in Autofac (chapter 15).
@inject NavigationManager Nav
@code {
[Inject] public IWeatherService Weather { get; set; } = default!;
[Inject(Key = "eu")] public IPricing Pricing { get; set; } = default!; // keyed (.NET 8+)
}Anti-pattern Injecting IServiceProvider and pulling things out of it hides dependencies, defeats compile-time checks and makes tests harder. Use it only in infrastructure code (factories, scopes in background services).
public class ReportService(IServiceProvider sp)
{
public void Run()
{
var repo = sp.GetRequiredService<IReportRepository>(); // ❌ hidden dependency
var mail = sp.GetService<IEmailSender>(); // ❌ might be null at runtime
}
}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).
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”.
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
| Service | Lifetime | Why |
|---|---|---|
DbContext (EF Core) via AddDbContext | Scoped | Unit 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>() | Transient | The client is cheap; IHttpClientFactory pools the handlers. Don't capture a typed client in a singleton. |
IHttpClientFactory, ILogger<T>, ILoggerFactory | Singleton | Framework-registered, thread-safe. |
IOptions<T> | Singleton | Read once; never changes. |
IOptionsSnapshot<T> | Scoped | Re-reads config per request. Can't be injected into singletons. |
IOptionsMonitor<T> | Singleton | Live updates + change notifications, safe anywhere. |
IMemoryCache, HybridCache, distributed cache | Singleton | Shared, thread-safe store. |
TimeProvider, ID generators, stateless mappers | Singleton / Transient | Stateless; either works. Microsoft's guidance prefers transient for cheap stateless classes and singleton for expensive or shared ones. |
| Current user / tenant context | Scoped | Different for each request; must never leak between users. |
| FluentValidation validators, MediatR handlers | Transient / Scoped | Follow the library's registration extension (AddValidatorsFromAssembly, AddMediatR). |
BackgroundService via AddHostedService | Singleton | Lives for the app; create scopes inside for scoped work (chapter 11). |
WPF MainWindow / shell ViewModel | Singleton | One 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.
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.
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'.
builder.Services.AddDbContextFactory<ShopDbContext>(o => o.UseSqlServer(cs));
builder.Services.AddSingleton<IPriceCache, PriceCache>();
public class PriceCache(IDbContextFactory<ShopDbContext> factory, IMemoryCache cache) : IPriceCache
{
public async Task<decimal> GetAsync(int productId) =>
await cache.GetOrCreateAsync($"price:{productId}", async _ =>
{
await using var db = await factory.CreateDbContextAsync(); // ✅ new, owned, disposed
return (await db.Products.FindAsync(productId))!.Price;
});
}public class PriceCache(IServiceScopeFactory scopes) : IPriceCache
{
public async Task<decimal> GetAsync(int productId)
{
await using var scope = scopes.CreateAsyncScope(); // ✅ its own scope
var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
return (await db.Products.FindAsync(productId))!.Price;
}
}Or ask: does this really need to be a singleton? Often making PriceCache scoped and keeping the cached data in IMemoryCache (a singleton) is the cleanest answer.
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.
// 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
});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
| Shape | Example | Auto-disposed? | Use it when |
|---|---|---|---|
| Service + implementation | AddScoped<IRepo, SqlRepo>() | Yes | The normal case. |
| Implementation only | AddScoped<OrderService>() | Yes | No abstraction needed (same as <OrderService, OrderService>). |
| Factory | AddSingleton<IBlob>(sp => new Blob(cfg["Conn"])) | Yes | Constructor needs a primitive or custom logic. |
| Instance | AddSingleton<IClock>(new FixedClock()) | No | Object created before the container; you own its disposal. |
| Open generic | AddScoped(typeof(IRepository<>), typeof(EfRepository<>)) | Yes | One registration serves IRepository<Order>, IRepository<Customer>… |
| Descriptor | services.Add(new ServiceDescriptor(typeof(IX), typeof(X), ServiceLifetime.Scoped)) | Yes | Lifetime decided at runtime / library code. |
| Keyed (.NET 8+) | AddKeyedSingleton<IPay, StripePay>("stripe") | Yes | Several 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.TryAddEnumerableadds only if that exact service + implementation pair is new: stops duplicate plugins when anAddX()is called twice.Replaceremoves the first registration of the service type and adds the new one;RemoveAll<T>removes every one. Both live inMicrosoft.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.
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
builder.Services.AddSingleton<ICartReader, CartService>();
builder.Services.AddSingleton<ICartWriter, CartService>(); // ❌ a second CartService, different statebuilder.Services.AddSingleton<CartService>();
builder.Services.AddSingleton<ICartReader>(sp => sp.GetRequiredService<CartService>());
builder.Services.AddSingleton<ICartWriter>(sp => sp.GetRequiredService<CartService>()); // ✅ same objectCheck 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.
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".
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
// 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}"));- Keyed and non-keyed registrations are separate tables.
GetService<IPaymentGateway>()never sees a keyed registration, andIEnumerable<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), andGetKeyedServices(KeyedService.AnyKey)returns only services registered with a specific key.
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)
};builder.Services.AddKeyedTransient<IExporter, PdfExporter>("pdf");
builder.Services.AddKeyedTransient<IExporter, ExcelExporter>("xlsx");
app.MapGet("/reports/{id}.{format}", (int id, string format, IServiceProvider sp, IReportStore store) =>
{
var exporter = sp.GetKeyedService<IExporter>(format); // null if unknown key
return exporter is null ? Results.NotFound() : Results.File(exporter.Render(store.Get(id)));
});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>.
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;
}| Interface | Lifetime | Reloads? | Use in |
|---|---|---|---|
IOptions<T> | Singleton | No | Anything; settings that don't change. |
IOptionsSnapshot<T> | Scoped | Per request | Scoped/transient services only. |
IOptionsMonitor<T> | Singleton | Live + OnChange | Singletons that must see updates. |
When an object needs a runtime value (a file path, a customer id) plus services, don't register it. Register a factory that combines both. ActivatorUtilities builds a type that isn't registered, filling the rest from DI.
public interface IImportJobFactory { ImportJob Create(string csvPath); }
public sealed class ImportJobFactory(IServiceProvider sp) : IImportJobFactory
{
public ImportJob Create(string csvPath) =>
ActivatorUtilities.CreateInstance<ImportJob>(sp, csvPath); // csvPath + injected services
}
public sealed class ImportJob(string csvPath, ICustomerRepository repo, ILogger<ImportJob> log) { }
builder.Services.AddSingleton<IImportJobFactory, ImportJobFactory>();Microsoft's guidelines call injecting a factory that resolves things at runtime a service-locator variation, so keep factories small, typed and in infrastructure code.
Add caching, logging, retries or metrics around a service without touching it. The built-in container has no Decorate method; write a factory, or use Scrutor.
public sealed class CachedProductRepository(IProductRepository inner, IMemoryCache cache) : IProductRepository
{
public Task<Product?> GetAsync(int id) =>
cache.GetOrCreateAsync($"p:{id}", _ => inner.GetAsync(id));
}
// Manual (no packages)
builder.Services.AddScoped<SqlProductRepository>();
builder.Services.AddScoped<IProductRepository>(sp =>
new CachedProductRepository(sp.GetRequiredService<SqlProductRepository>(),
sp.GetRequiredService<IMemoryCache>()));
// Scrutor 7.x
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();Inject IEnumerable<T> to get every implementation. Each one decides whether it applies (strategy), or you run all of them (composite / pipeline).
public interface IDiscountRule { bool Applies(Cart c); decimal Discount(Cart c); }
builder.Services.AddScoped<IDiscountRule, DiwaliSaleRule>();
builder.Services.AddScoped<IDiscountRule, FirstOrderRule>();
builder.Services.AddScoped<IDiscountRule, LoyaltyTierRule>();
public sealed class PricingService(IEnumerable<IDiscountRule> rules)
{
public decimal Total(Cart c) =>
c.Subtotal - rules.Where(r => r.Applies(c)).Sum(r => r.Discount(c));
}
// Add a new rule = add a class + one line. PricingService never changes (open/closed principle).Creating new HttpClient() per call can exhaust sockets; one static client can miss DNS changes. IHttpClientFactory solves both.
builder.Services.AddHttpClient<IWeatherApi, WeatherApi>(c =>
{
c.BaseAddress = new Uri("https://api.weather.example/");
c.Timeout = TimeSpan.FromSeconds(10);
})
.AddStandardResilienceHandler(); // Microsoft.Extensions.Http.Resilience: retries, timeouts, circuit breaker
public sealed class WeatherApi(HttpClient http) : IWeatherApi
{
public Task<Forecast?> TodayAsync(string city) =>
http.GetFromJsonAsync<Forecast>($"forecast/{Uri.EscapeDataString(city)}");
}Typed clients are registered as transient. If a singleton needs HTTP, inject IHttpClientFactory and call CreateClient when needed.
The built-in container doesn't support Lazy<T> or Func<T> out of the box. If an expensive dependency is used only on rare paths, you can register an open-generic helper:
public sealed class LazyService<T>(IServiceProvider sp) : Lazy<T>(sp.GetRequiredService<T>) where T : notnull;
builder.Services.AddTransient(typeof(Lazy<>), typeof(LazyService<>));
public sealed class ReportController(Lazy<IPdfRenderer> pdf) // created only if used
{
public IActionResult Pdf() => new FileContentResult(pdf.Value.Render(), "application/pdf");
}Often a better fix is method injection ([FromServices]) or splitting the class.
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.
using (var scope = provider.CreateScope()) // ❌ synchronous Dispose
{
var exporter = scope.ServiceProvider.GetRequiredService<S3Exporter>(); // implements only IAsyncDisposable
await exporter.UploadAsync();
} // InvalidOperationException: type only implements IAsyncDisposableawait using (var scope = provider.CreateAsyncScope()) // ✅ AsyncServiceScope (.NET 6+)
{
var exporter = scope.ServiceProvider.GetRequiredService<S3Exporter>();
await exporter.UploadAsync();
}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.
- Receiving an
IDisposablevia DI doesn't mean your class must implementIDisposable, 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 withConfigureAwait(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.
A food-delivery API:
POST /orders validates the cart, saves the order with EF Core, charges the customer, and publishes an "order placed" event.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();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.
public sealed class AuditMiddleware(RequestDelegate next, ILogger<AuditMiddleware> log) // singletons only
{
public async Task InvokeAsync(HttpContext ctx, FoodDbContext db) // ✅ from the request scope
{
db.Audits.Add(new Audit(ctx.Request.Path));
await db.SaveChangesAsync(ctx.RequestAborted);
await next(ctx);
}
}
// Alternative: implement IMiddleware, register it (AddScoped<AuditMiddleware>()), and it is
// activated per request through the container, so constructor injection works.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 preferIDbContextFactoryover a scopedDbContext. - Filters: use
[ServiceFilter(typeof(AuditFilter))]or[TypeFilter]so the filter gets DI. - Avoid
HttpContext.RequestServices.GetServiceand static access toIHttpContextAccessorin business code.
Nothing is automatic: WPF has no host or scopes by default. You add the .NET Generic Host (Microsoft.Extensions.Hosting), build windows and ViewModels from the container, and decide what a "scope" means. A common choice: one scope per window or per screen.
A pharmacy point-of-sale app: the main window hosts a ViewModel, the "New sale" window has its own scope (and its own unit of work), and the stock database is SQLite via EF Core.
<Application x:Class="PharmacyPos.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Startup="OnStartup" Exit="OnExit">
</Application>public partial class App : Application
{
private IHost _host = null!;
private async void OnStartup(object sender, StartupEventArgs e)
{
var builder = Host.CreateApplicationBuilder(); // config, logging, DI
builder.Services.AddDbContextFactory<PosDbContext>(o => o.UseSqlite("Data Source=pos.db"));
builder.Services.AddSingleton<IDialogService, DialogService>();
builder.Services.AddSingleton<IWindowFactory, WindowFactory>();
builder.Services.AddSingleton<MainViewModel>();
builder.Services.AddSingleton<MainWindow>();
builder.Services.AddTransient<SaleViewModel>(); // one per sale window
builder.Services.AddTransient<SaleWindow>();
builder.Services.AddScoped<ISaleSession, SaleSession>(); // shared inside one window's scope
_host = builder.Build();
await _host.StartAsync();
_host.Services.GetRequiredService<MainWindow>().Show(); // composition root: OK here
}
private async void OnExit(object sender, ExitEventArgs e)
{
using (_host) { await _host.StopAsync(); }
}
}public partial class MainWindow : Window
{
public MainWindow(MainViewModel vm) // ✅ ViewModel injected, set as DataContext
{
InitializeComponent();
DataContext = vm;
}
}
public sealed class MainViewModel(IWindowFactory windows, IDbContextFactory<PosDbContext> dbs)
{
public async Task LoadLowStockAsync()
{
await using var db = await dbs.CreateDbContextAsync(); // short-lived context per operation
LowStock = await db.Medicines.Where(m => m.Qty < 10).ToListAsync();
}
public void NewSale() => windows.ShowSaleWindow();
public List<Medicine> LowStock { get; private set; } = [];
}
public sealed class WindowFactory(IServiceScopeFactory scopes) : IWindowFactory
{
public void ShowSaleWindow()
{
var scope = scopes.CreateScope(); // ✅ a scope per window
var window = scope.ServiceProvider.GetRequiredService<SaleWindow>();
window.Closed += (_, _) => scope.Dispose(); // dispose with the window
window.Show();
}
}private void NewSale_Click(object s, RoutedEventArgs e)
{
var w = new SaleWindow(new SaleViewModel(new SaleSession(new PosDbContext()))); // ❌ manual graph
w.Show(); // ❌ PosDbContext never disposed, untestable ViewModel
}private void NewSale_Click(object s, RoutedEventArgs e) => _windows.ShowSaleWindow();
// SaleWindow(SaleViewModel vm) → SaleViewModel(ISaleSession s, IDialogService d) → resolved in the window's scope- Remove
StartupUri, or WPF will create a second, DI-lessMainWindow. - Keep
IServiceProviderout of ViewModels; inject a small factory or navigation service instead. - Use
IDbContextFactory: a desktop app can live for days, so a long-livedDbContextgrows its change tracker and goes stale. CreateApplicationBuilderalso loadsappsettings.json(set it to copy to the output folder) and gives youILogger<T>in every window.
You own the scopes: a BackgroundService is registered as a singleton and runs for the life of the app. Each unit of work (one message, one batch, one timer tick) should get its own scope.
A logistics company polls a queue of delivery events every 5 seconds, updates shipment status in SQL and sends SMS to customers.
public sealed class ShipmentWorker(ShipmentsDbContext db, ISmsSender sms) : BackgroundService // ❌
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// one DbContext for days: change tracker grows, stale data, memory leak
await db.SaveChangesAsync(ct);
await Task.Delay(5000, ct);
}
}
}public sealed class ShipmentWorker(IServiceScopeFactory scopes, ILogger<ShipmentWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(ct))
{
await using var scope = scopes.CreateAsyncScope(); // ✅ new scope per tick
var processor = scope.ServiceProvider.GetRequiredService<IShipmentProcessor>();
try { await processor.ProcessPendingAsync(ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{ log.LogError(ex, "Batch failed; will retry next tick"); }
} // DbContext disposed here
}
}
// Program.cs (Worker SDK)
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<ShipmentsDbContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("Db")));
builder.Services.AddScoped<IShipmentProcessor, ShipmentProcessor>();
builder.Services.AddSingleton<ISmsSender, TwilioSmsSender>();
builder.Services.AddHostedService<ShipmentWorker>();
await builder.Build().RunAsync();A queue between web and worker
builder.Services.AddSingleton(Channel.CreateBounded<EmailJob>(500)); // the queue is a singleton
builder.Services.AddHostedService<EmailQueueWorker>();
app.MapPost("/newsletter", (Channel<EmailJob> q, EmailJob job) => q.Writer.TryWrite(job)
? Results.Accepted() : Results.StatusCode(503));
public sealed class EmailQueueWorker(Channel<EmailJob> q, IServiceScopeFactory scopes) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var job in q.Reader.ReadAllAsync(ct))
{
await using var scope = scopes.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<IEmailSender>().SendAsync(job, ct);
}
}
}- .NET 10:
BackgroundServiceruns all ofExecuteAsyncas a task, so synchronous code before the firstawaitno longer blocks other services from starting. - .NET 11:
IHost.RunAsyncandStopAsyncthrow when aBackgroundServicefails, so a crashing worker surfaces instead of disappearing. - Console tools without a host: build a
ServiceCollectionyourself (chapter 3) and wrap each operation inCreateAsyncScope().
12Anti-patterns and how to fix them
Most DI bugs fall into a small set of patterns. Toggle each one to see the fix.
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>();builder.Services.AddScoped<ITax>(sp =>
sp.GetRequiredService<IFeatureFlags>().IsOn("NewTax")
? ActivatorUtilities.CreateInstance<NewTax>(sp)
: ActivatorUtilities.CreateInstance<LegacyTax>(sp));
// Or read plain configuration directly: builder.Configuration.GetValue<bool>("Features:NewTax")builder.Services.AddSingleton<ISecretStore>(sp =>
SecretStore.LoadAsync(sp.GetRequiredService<IConfiguration>()).Result); // ❌ sync-over-asyncbuilder.Services.AddSingleton<ISecretStore, SecretStore>(); // constructor stays cheap and sync
var app = builder.Build();
await app.Services.GetRequiredService<ISecretStore>().InitializeAsync(); // or an IHostedServiceC# has no async constructors and the container doesn't support Task-based resolution. Keep factories fast and synchronous.
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 scopepublic sealed class InvoiceTotals(ITaxCalculator tax)
{
public decimal Total(Invoice i) => i.Subtotal + tax.For(i);
}public class OrderManager(IOrderRepository o, ICustomerRepository c, IPaymentGateway p,
IEmailSender e, ISmsSender s, IPdfRenderer pdf, IAuditLog a, IInventory i, IShipping sh) { } // ❌public sealed class PlaceOrderHandler(IOrderRepository orders, IPaymentGateway pay, IInventory stock, IPublisher events) { }
public sealed class OrderNotifications(IEmailSender email, ISmsSender sms) : INotificationHandler<OrderPlaced> { }
public sealed class InvoiceGenerator(IPdfRenderer pdf, IAuditLog audit) : INotificationHandler<OrderPlaced> { }Many constructor parameters are a hint that the class violates the Single Responsibility Principle. DI makes the smell visible, which is a good thing.
public class ReportService(ReportsDbContext db)
{
public async Task RunAsync()
{
using (db) { /* ... */ } // ❌ other services in this request now get a disposed context
}
}public class ReportService(ReportsDbContext db)
{
public Task<int> RunAsync() => db.Reports.CountAsync(); // ✅ scope ends → container disposes
}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;
}public sealed class VisitorCounter
{
private readonly ConcurrentDictionary<string, int> _hits = new(); // ✅
public void Hit(string page) => _hits.AddOrUpdate(page, 1, (_, n) => n + 1);
}The container makes resolving thread-safe; it does not make your singleton's state thread-safe.
builder.Services.AddSingleton<ShoppingCart>(); // ❌ data, not a service; leaks between usersbuilder.Services.AddScoped<ICartService, CartService>(); // CartService(ICartStore store, ICurrentUser user)
// var cart = await cartService.GetForCurrentUserAsync();| Smell | Symptom | Fix |
|---|---|---|
| Scoped resolved from root | “Cannot resolve scoped service from root provider”, or a DbContext that never dies | Create 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 call | Socket exhaustion under load | AddHttpClient typed / named clients. |
| Interface for everything | Hundreds of one-implementation interfaces, no tests | Abstract 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
| Situation | Choose |
|---|---|
| Needs a DB connection for one request | Scoped + constructor injection |
| Same DB access from a singleton or desktop app | IDbContextFactory<T> or a new scope |
| Expensive, thread-safe, shared by everyone | Singleton |
| Cheap and stateless | Transient |
| Several implementations, caller picks by name | Keyed services |
| Several implementations, all should run | IEnumerable<T> |
| Needs a runtime argument + services | Factory + ActivatorUtilities |
| Configuration values | IOptions<T> / IOptionsMonitor<T> |
| Add behaviour around an existing service | Decorator |
| Property injection, child containers, conventions, interception | Consider 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.
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>());
}
}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();
}
}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.
| Feature | Microsoft.Extensions.DI | Scrutor 7.x | Autofac 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 | ✓ |
| Decorators | Manual factory | ✓ Decorate | ✓ |
| Property injection | ✗ | ✗ | ✓ |
| Child / nested containers, tagged lifetimes | ✗ | ✗ | ✓ |
Func<T>, Lazy<T>, Owned<T> | ✗ | ✗ | ✓ |
| Interception (AOP) | ✗ | ✗ | ✓ (DynamicProxy) |
| Modules | Add{Feature} extensions | — | ✓ Module |
| Is it a container? | Yes | No, extensions for the built-in one | Yes |
builder.Services.Scan(scan => scan
.FromAssemblyOf<IOrderService>()
.AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))
.AsImplementedInterfaces()
.WithScopedLifetime());// 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.
- .NET Core 1.0 – 3.xBuilt-in container ships with ASP.NET Core;
Startup.ConfigureServices; Generic Host (3.0). - .NET 6Minimal hosting (
WebApplication.CreateBuilder,builder.Services),CreateAsyncScope/AsyncServiceScope,IServiceProviderIsService. - .NET 7
Host.CreateApplicationBuilderbrings the same style to workers, console and desktop apps. - .NET 8 (LTS)Keyed services (
AddKeyed*,[FromKeyedServices],[ServiceKey],KeyedService.AnyKey); C# 12 primary constructors;TimeProvider. - .NET 9
[FromKeyedServices]throws when the key is missing instead of injecting the non-keyed service (backported to 8.0.9). - .NET 10 (LTS, current)
GetKeyedService(AnyKey)throws andGetKeyedServices(AnyKey)returns only specifically-keyed services;BackgroundServiceruns all ofExecuteAsyncas a task. Latest servicing: Microsoft.Extensions.DependencyInjection 10.0.13 (October 2026). - .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/StopAsyncthrow 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.
// 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
- Constructor injection by default.
- Depend on abstractions at the boundaries.
- Dependencies live at least as long as their consumer.
- Create a scope for each unit of work outside web requests.
- The container disposes what it creates; you dispose what you create.
- Resolve from
IServiceProvideronly 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 →
GetRequiredServiceon 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.