The Built-In Container
Register services on IServiceCollection, inject them through constructors and endpoint parameters, and know how the provider builds them.
builder.Services.AddScoped<OrderPricing>();
app.MapGet("/orders/{id:int}/quote", (int id, OrderPricing pricing) => pricing.Price(id));Every .NET host ships with one container: Microsoft.Extensions.DependencyInjection. builder.Services is an IServiceCollection, which is nothing more than a list of ServiceDescriptor entries (service type, lifetime, and either an implementation type, a factory or an instance). builder.Build() turns that list into an IServiceProvider and the list is frozen: adding to builder.Services after Build() throws, and you cannot register anything later.
How a service is constructed
For a type registration the provider picks a public constructor, resolves each parameter recursively and calls it. The rule: it takes the constructor with the most parameters that it can satisfy entirely from the container. If two satisfiable constructors exist and neither's parameters are a superset of the other's, resolution throws an ambiguity InvalidOperationException. Optional parameters with defaults are used when the type is not registered. A cycle (A needs B, B needs A) throws A circular dependency was detected. The container builds a call site per service the first time it is asked for, caches it, and later compiles hot paths, so resolution in steady state is cheap; the expensive part is a deep graph of objects being allocated per request, not the lookup.
Primary constructors in services
C# primary constructors fit services well: public sealed class OrderPricing(ITaxRates taxRates, TimeProvider clock). Know the trade-off: captured parameters are mutable (there is no readonly), so code in the class can reassign taxRates by accident. Teams either accept that or assign to an explicit private readonly field when the class is large. Injecting TimeProvider rather than calling DateTime.UtcNow keeps the class testable.
Resolving in endpoints
Minimal API handlers take services as parameters. At startup the framework asks IServiceProviderIsService whether each complex parameter type is registered; if it is, the value comes from HttpContext.RequestServices (the request scope). If it is not, the parameter is inferred as the JSON body, and on a GET that is an error when the endpoint is built, with a message asking whether you meant to register it as a service. [FromServices] makes the source explicit. In [ApiController] actions a registered service type is inferred from DI without it (since .NET 7), but the attribute is still clearer and is needed in controllers without [ApiController]. Most controller code takes dependencies through the constructor.
| Way to get a service | Use it for |
|---|---|
| Constructor parameter | The default, in every service, controller, middleware and hosted service |
| Endpoint parameter | Minimal API handlers; resolved from the request scope |
app.Services | Start-up work only, inside a scope you create |
GetRequiredService inside business code | Avoid: a service locator hides dependencies |
What goes wrong in production
A missing registration is found at the first request that needs it unless validation runs at startup; in Development the host turns on ValidateOnBuild, which instantiates nothing but checks that every registered type can be constructed. Prefer GetRequiredService over GetService: a null from GetService turns into a NullReferenceException far from the cause.
Example
When to use it
- Inject a tax-rate lookup and TimeProvider into an order pricing service so a GET /orders/{id}/quote handler stays one line
- Find a missing IInvoiceNumbering registration at startup with ValidateOnBuild instead of on the first invoice of the day
- Replace a static PaymentClient.Instance with a registered interface so checkout tests can fake the payment provider
More examples
Ambiguous constructors and the fix
The container never guesses between two constructors of equal standing: it throws <code>Unable to activate type 'CustomerReport'. The following constructors are ambiguous</code>. Overloaded constructors in services usually exist for tests or for an old caller; give a DI-built class exactly one public constructor and build test variants through it. <code>[ActivatorUtilitiesConstructor]</code> only affects <code>ActivatorUtilities</code>, not the container.
var services = new ServiceCollection();
services.AddSingleton<ICustomerStore, InMemoryCustomerStore>();
services.AddSingleton<IOrderStore, InMemoryOrderStore>();
services.AddTransient<CustomerReport>();
services.AddTransient<FixedCustomerReport>();
using var provider = services.BuildServiceProvider();
try
{
provider.GetRequiredService<CustomerReport>();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
Console.WriteLine(provider.GetRequiredService<FixedCustomerReport>().Describe());
public interface ICustomerStore;
public interface IOrderStore;
public sealed class InMemoryCustomerStore : ICustomerStore;
public sealed class InMemoryOrderStore : IOrderStore;
// Both constructors are satisfiable and neither is a superset of the other: ambiguous.
public sealed class CustomerReport
{
public CustomerReport(ICustomerStore customers) { }
public CustomerReport(IOrderStore orders) { }
}
// One constructor that states every dependency removes the guesswork.
public sealed class FixedCustomerReport(ICustomerStore customers, IOrderStore orders)
{
public string Describe() => $"{customers.GetType().Name} + {orders.GetType().Name}";
}Primary constructor with an explicit readonly field
Primary constructor parameters are captured as mutable state, and the compiler warns (CS9124) if you both capture a parameter and use it to initialise a field. Pick one per parameter: here <code>orders</code> becomes a <code>readonly</code> field with a null guard, while <code>logger</code> and <code>clock</code> stay captured. The container never passes null for a registered type, but tests and manual construction can.
using Microsoft.Extensions.Logging;
public interface IOrderRepository
{
Task<Order?> FindAsync(Guid id, CancellationToken ct);
}
public sealed record Order(Guid Id, string CustomerEmail, decimal Total, string Status);
public sealed class OrderCancellation(IOrderRepository orders, ILogger<OrderCancellation> logger, TimeProvider clock)
{
// Assigning to a readonly field stops a later edit from reassigning the dependency.
private readonly IOrderRepository _orders = orders ?? throw new ArgumentNullException(nameof(orders));
public async Task<bool> CanCancelAsync(Guid orderId, CancellationToken ct)
{
var order = await _orders.FindAsync(orderId, ct);
if (order is null || order.Status != "Pending")
{
logger.LogInformation("Order {OrderId} cannot be cancelled at {Now}", orderId, clock.GetUtcNow());
return false;
}
return true;
}
}Testing a service without the container
Constructor injection pays off in tests: a unit test calls <code>new</code> with fakes and never builds a <code>ServiceCollection</code>. If a test needs the container to construct the class under test, the class probably resolves things itself. Keep container-level checks for integration tests that boot the real <code>Program</code>.
using NSubstitute;
using Xunit;
public interface ITaxRates
{
decimal RateFor(string country);
}
public sealed record Quote(int OrderId, decimal Subtotal, decimal Tax, decimal Total);
public sealed class OrderPricing(ITaxRates taxRates)
{
public Quote Price(int orderId, decimal subtotal, string country)
{
var tax = Math.Round(subtotal * taxRates.RateFor(country), 2);
return new Quote(orderId, subtotal, tax, subtotal + tax);
}
}
public class OrderPricingTests
{
[Fact]
public void Adds_country_tax_to_subtotal()
{
var rates = Substitute.For<ITaxRates>();
rates.RateFor("DE").Returns(0.19m);
var quote = new OrderPricing(rates).Price(1042, 120.00m, "DE");
Assert.Equal(22.80m, quote.Tax);
Assert.Equal(142.80m, quote.Total);
}
}
Discussion