Dependency injection and configuration
Register services with the right lifetime, inject through constructors, and wire options, logging and the generic host correctly.
The generic host and service registration
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
var builder = Host.CreateApplicationBuilder(args);
// configuration is already layered: appsettings, environment, command line
builder.Configuration.AddEnvironmentVariables(prefix: "SHOP_");
// register by interface, not by concrete type
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddTransient<IOrderValidator, OrderValidator>();
// options: validate at startup so a bad configuration fails fast
builder.Services
.AddOptions<StorageOptions>()
.Bind(builder.Configuration.GetSection("Storage"))
.Validate(o => !string.IsNullOrWhiteSpace(o.RootPath), "Storage:RootPath is required")
.ValidateOnStart();
// a typed HttpClient instead of new HttpClient()
builder.Services.AddHttpClient<IPricingClient, PricingClient>(http =>
{
http.BaseAddress = new Uri(builder.Configuration["Pricing:BaseUrl"]!);
http.Timeout = TimeSpan.FromSeconds(5);
});
builder.Services.AddHostedService<Worker>();
using IHost host = builder.Build();
await host.RunAsync();| Lifetime | Created | Use for | Do not |
|---|---|---|---|
| Singleton | Once per container | Stateless services, caches, configuration | Depend on a scoped service |
| Scoped | Once per scope (request) | DbContext, per-request state | Be captured by a singleton |
| Transient | Every resolution | Lightweight stateless helpers | Anything costly to build |
A singleton that captures a scoped service is the classic captive dependency: the scoped instance never gets released and is shared by every request. Enable scope validation in development and the container refuses to start instead of failing at run time under load.
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseDefaultServiceProvider((context, options) =>
{
options.ValidateScopes = context.HostingEnvironment.IsDevelopment();
options.ValidateOnBuild = true; // fail at startup, not on first request
});Constructor injection and service boundaries
public sealed class OrderService
{
private readonly IOrderRepository _repository;
private readonly IPricingClient _pricing;
private readonly ILogger<OrderService> _logger;
private readonly TimeProvider _clock;
private readonly StorageOptions _options;
// constructor injection: every dependency is visible in the signature
public OrderService(
IOrderRepository repository,
IPricingClient pricing,
ILogger<OrderService> logger,
TimeProvider clock,
IOptions<StorageOptions> options)
{
_repository = repository;
_pricing = pricing;
_logger = logger;
_clock = clock;
_options = options.Value;
}
public async Task<Order> PlaceAsync(string sku, int quantity, CancellationToken ct)
{
_logger.LogInformation("placing order for {Sku} x{Quantity}", sku, quantity);
decimal price = await _pricing.GetPriceAsync(sku, ct);
var order = new Order(Guid.NewGuid(), sku, quantity, price, _clock.GetUtcNow());
await _repository.AddAsync(order, ct);
return order;
}
}- Inject
TimeProviderrather than callingDateTimeOffset.UtcNow. Tests can then useFakeTimeProviderand control time deterministically. - Use structured logging placeholders (
{Sku}), not string interpolation. The values stay as separate fields for the log sink to filter on. - Resolve the container only at the very top of the application. Calling
GetServicedeep inside business code is the service locator anti-pattern and hides dependencies. IServiceScopeFactorylets a singleton create a scope per unit of work, which is the correct fix for the captive dependency.
A constructor with eight dependencies is a design signal, not a container problem. Group cohesive collaborators into a service with a narrower job, or extract a use case that needs fewer of them.
Options, logging and health
using Microsoft.Extensions.Options;
public sealed class StorageOptions
{
public const string SectionName = "Storage";
public string RootPath { get; set; } = "";
public int MaxFileSizeMb { get; set; } = 50;
}
// IOptionsMonitor re-reads when the file changes (reloadOnChange must be on)
public sealed class StorageProbe
{
public StorageProbe(IOptionsMonitor<StorageOptions> monitor) => _monitor = monitor;
private readonly IOptionsMonitor<StorageOptions> _monitor;
public string CurrentPath => _monitor.CurrentValue.RootPath;
}
// high-performance logging with a source-generated method
public static partial class Log
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Warning,
Message = "stock low for {Sku}: {Available} left")]
public static partial void StockLow(ILogger logger, string sku, int available);
}⚠️
Register the container's own types correctly:
IOptions<T> is a singleton snapshot, IOptionsSnapshot<T> is scoped and re-bound per request, and IOptionsMonitor<T> is a singleton that observes changes. Injecting the snapshot into a singleton is a captive dependency.FAQ
Should I write my own container?
Only if you have a specific unmet requirement. The built-in container covers lifetimes, scopes, open generics, keyed services and factory registration, and it is fast. Adding a third-party container means maintaining a second registration model.
Why does the same service appear twice in a constructor?
Most often a transitive dependency registered twice, or a decorator that depends on the type it decorates. Check the registration order: the last registration wins for a single resolve, but every implementation is injected into an
IEnumerable<T>.Related
Setting up .NET: SDK, CLI and project layout Building a web API with ASP.NET Core
Last refreshed 2026-09-18.