Dependency Injection Basics in .NET 8: Lifetimes, Service Registration, and the Options Pattern
Published:
This post covers the built-in Dependency Injection container in .NET and the concepts you need to use it safely. If you remember only one idea, make it this: services should declare what they need through constructor parameters, and the container should create those dependencies with the correct lifetime.
What Dependency Injection means in practice
Dependency Injection, usually shortened to DI, is a pattern where objects receive the services they need from the outside instead of creating them internally.
Without DI:
public sealed class OrderService
{
private readonly EmailSender _emailSender = new();
private readonly PaymentGateway _paymentGateway = new();
}
This code is tightly coupled:
- the implementation types are fixed
- testing becomes harder
- configuration is hidden
- object creation logic is scattered through the app
With DI:
public sealed class OrderService(
IEmailSender emailSender,
IPaymentGateway paymentGateway,
ILogger<OrderService> logger)
{
public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken)
{
logger.LogInformation("Placing order {OrderId}", order.Id);
await paymentGateway.CaptureAsync(order, cancellationToken);
await emailSender.SendConfirmationAsync(order, cancellationToken);
}
}
Now the class clearly declares its dependencies and does not decide how they are created.
The built-in container in ASP.NET Core
When you create a .NET app with:
var builder = WebApplication.CreateBuilder(args);
you also get a service container at:
builder.Services
This is where you register services before the app is built:
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<ISystemClock, UtcSystemClock>();
After builder.Build(), the container becomes read-only and the application resolves services from it as requests arrive.
Service lifetimes
The most important DI concept is lifetime. The built-in container supports three main lifetimes.
Singleton
- one instance for the entire app lifetime
- good for stateless utilities, caches, and shared configuration helpers
- must be thread-safe because the instance is reused everywhere
Scoped
- one instance per request in web apps
- the most common choice for business services and database contexts
- all services resolved within the same request share the same scoped instance
Transient
- a new instance each time the service is requested
- good for lightweight, stateless helpers
- can create too many objects if overused carelessly
Example registration:
builder.Services.AddSingleton<ISystemClock, UtcSystemClock>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();
Choosing the wrong lifetime is a common cause of subtle bugs.
Lifetime rules you should remember
These rules prevent many problems:
- a singleton must not depend on a scoped service
- scoped services can depend on singletons and transients
- transient services can depend on any lifetime, but repeated construction may have a cost
The most famous mistake is capturing a scoped dependency inside a singleton. For example, a singleton service must not hold onto a DbContext because DbContext is normally scoped per request.
Basic service registration patterns
The simplest registration maps an interface to an implementation:
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
You can also register a concrete type directly:
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
Or create an object with a factory:
builder.Services.AddSingleton(sp =>
{
var logger = sp.GetRequiredService<ILogger<ApiClient>>();
return new ApiClient("https://api.example.com", logger);
});
Factories are useful, but use them deliberately. If every registration becomes a large lambda, your startup code becomes hard to reason about.
Resolving services in endpoints and controllers
Once a service is registered, ASP.NET Core can inject it where needed.
Minimal API example:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
app.MapGet("/products", async (
IProductService service,
CancellationToken cancellationToken) =>
{
var products = await service.GetAllAsync(cancellationToken);
return Results.Ok(products);
});
app.Run();
Controller example:
[ApiController]
[Route("orders")]
public sealed class OrdersController(IOrderService orderService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(Order request, CancellationToken cancellationToken)
{
await orderService.PlaceOrderAsync(request, cancellationToken);
return Accepted();
}
}
This keeps object creation out of endpoint logic and makes the app easier to test.
The options pattern
The options pattern is a clean way to inject configuration into services.
Suppose you have this configuration:
{
"Mail": {
"Host": "smtp.example.com",
"Port": 587,
"Sender": "noreply@example.com"
}
}
Create a class for the section:
public sealed class MailOptions
{
public const string SectionName = "Mail";
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string Sender { get; set; } = string.Empty;
}
Register it:
builder.Services
.AddOptions<MailOptions>()
.Bind(builder.Configuration.GetSection(MailOptions.SectionName))
.Validate(o => !string.IsNullOrWhiteSpace(o.Host), "Mail host is required.")
.Validate(o => o.Port > 0, "Mail port must be greater than zero.")
.ValidateOnStart();
Use it in a service:
using Microsoft.Extensions.Options;
public sealed class SmtpEmailSender(IOptions<MailOptions> options)
: IEmailSender
{
private readonly MailOptions _mail = options.Value;
public Task SendConfirmationAsync(Order order, CancellationToken cancellationToken)
{
Console.WriteLine(
$"Sending order {order.Id} email using {_mail.Host}:{_mail.Port} as {_mail.Sender}");
return Task.CompletedTask;
}
}
This pattern is better than scattering raw configuration reads throughout the codebase.
IOptions, IOptionsSnapshot, and IOptionsMonitor
These three types solve slightly different problems:
IOptions<T>gives a single bound value and is common for singleton-friendly scenariosIOptionsSnapshot<T>recomputes per request and is useful in scoped servicesIOptionsMonitor<T>supports change notifications and updated values over time
For most beginner applications:
- start with
IOptions<T> - use
IOptionsSnapshot<T>in web requests when you need per-request freshness - use
IOptionsMonitor<T>for long-lived services that react to config changes
Common DI mistakes
Watch for these issues:
- injecting too many services into one class, which usually signals that the class has too many responsibilities
- resolving services manually from
IServiceProvidereverywhere, which turns DI into a service locator anti-pattern - registering everything as singleton to “improve performance” without considering state and thread safety
- letting framework types such as
HttpClientbe created manually instead of usingAddHttpClient
For example, this is better than constructing HttpClient yourself:
builder.Services.AddHttpClient<WeatherClient>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.Timeout = TimeSpan.FromSeconds(10);
});
That registration integrates with DI and uses the recommended HTTP client factory pattern.
A small end-to-end example
The following setup combines service registration, lifetimes, and options:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<MailOptions>()
.Bind(builder.Configuration.GetSection(MailOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();
app.MapPost("/orders", async (
Order order,
IOrderService orderService,
CancellationToken cancellationToken) =>
{
await orderService.PlaceOrderAsync(order, cancellationToken);
return Results.Accepted($"/orders/{order.Id}");
});
app.Run();
That is the usual mental model for ASP.NET Core:
- configuration is bound to options
- services are registered with lifetimes
- endpoints ask for the services they need
- the container creates the object graph for each request
If that model is clear, much of the framework stops feeling magical.
Next Article: Building Your First Web API in .NET 8: Controllers, Minimal APIs, and Routing
