Registration Patterns
Use TryAdd, multiple implementations, factories, open generics and per-feature AddX() extension methods to keep registrations correct as the app grows.
services.TryAddSingleton<IClock, SystemClock>();
services.TryAddEnumerable(ServiceDescriptor.Scoped<IOrderValidator, StockValidator>());
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));Registration code grows with the app. These patterns keep it predictable: what wins when two registrations collide, how to resolve many implementations, and where the code lives.
Add, TryAdd and last-wins
Add* always appends a descriptor. When several exist for one service type, resolving a single T returns the last one registered, while IEnumerable<T> returns all of them in registration order. TryAdd* (namespace Microsoft.Extensions.DependencyInjection.Extensions) adds only when no descriptor for that service type exists yet. Libraries and shared feature modules use TryAdd for their defaults so the application can register its own implementation first and keep it. TryAddEnumerable adds to a collection only when that exact implementation type is not already there, which makes calling AddOrders() twice harmless. Replace and RemoveAll swap registrations, mostly in tests.
Multiple implementations
Inject IEnumerable<IOrderValidator> to run every rule: a pipeline of checks, a set of event handlers, notification channels. The order is registration order, so if order matters, make it explicit (an Order property and a sort) rather than relying on which extension method ran first.
Factories
AddSingleton<IClock>(sp => ...) runs your lambda instead of a constructor. Use it when construction needs configuration values, a choice between implementations, or forwarding one registration to another (register a concrete class once and expose it under two interfaces, so both resolve the same singleton). Factories hide dependencies from ValidateOnBuild, so keep them short.
Open generics
AddScoped(typeof(IRepository<>), typeof(EfRepository<>)) serves every IRepository<Order>, IRepository<Invoice> and so on from one line. Generic constraints on the implementation are honoured: a closed type that does not satisfy them is skipped. A specific closed registration such as IRepository<Order> takes precedence over the open generic.
One extension method per feature
A 300-line Program.cs is the usual end state of "just add it here". Put each feature's registrations in public static IServiceCollection AddOrders(this IServiceCollection services, IConfiguration config) next to the feature code, return the collection for chaining, and keep Program.cs as a list of features.
| Call | When the type is already registered |
|---|---|
AddScoped | Appends; single resolve gets the newest |
TryAddScoped | Does nothing |
TryAddEnumerable | Appends unless the same implementation is present |
Replace | Removes the first match, adds the new one |
The classic production bug: a library calls AddSingleton<IClock, SystemClock>() after your registration, silently overriding your implementation because last wins. Libraries should use TryAdd; apps should register their overrides last.
Example
When to use it
- Run every IOrderValidator (stock, credit limit, max lines) over a new order by injecting IEnumerable<IOrderValidator>
- Serve IRepository<Order>, IRepository<Invoice> and IRepository<Product> from one open generic registration
- Ship an internal AddPayments() package that uses TryAdd so each service can override the default fraud checker
More examples
Program.cs as a list of features
Each feature owns its registrations, options binding and validation in one method that lives beside the feature's code. <code>Program.cs</code> reads as the list of what the service does, and removing a feature means deleting one line plus a folder. Returning <code>IServiceCollection</code> keeps the calls chainable, like the framework's own <code>AddX</code> methods.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOrders(builder.Configuration)
.AddInvoicing();
var app = builder.Build();
app.MapGet("/orders/limits", (IOptions<OrdersOptions> options) => options.Value);
app.Run();
public sealed class OrdersOptions
{
[Range(1, 1000)]
public int MaxLinesPerOrder { get; set; } = 100;
[Range(typeof(decimal), "1", "1000000")]
public decimal MaxOrderTotal { get; set; } = 25_000m;
}
public interface IOrderService { }
public sealed class OrderService(IOptions<OrdersOptions> options) : IOrderService { }
public interface IInvoiceService { }
public sealed class InvoiceService : IInvoiceService { }
public static class OrdersModule
{
public static IServiceCollection AddOrders(this IServiceCollection services, IConfiguration config)
{
services.AddOptions<OrdersOptions>()
.Bind(config.GetSection("Orders"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.TryAddScoped<IOrderService, OrderService>();
return services;
}
}
public static class InvoicingModule
{
public static IServiceCollection AddInvoicing(this IServiceCollection services)
{
services.TryAddScoped<IInvoiceService, InvoiceService>();
return services;
}
}Open generic with a constraint
The container closes the open generic on demand. When the requested type argument violates the implementation's constraint, the registration is skipped rather than throwing, so <code>GetService</code> returns null and <code>GetRequiredService</code> throws a plain "no service registered" error. That message can be confusing; check constraints when an open generic seems to be missing.
var services = new ServiceCollection();
services.AddScoped(typeof(IAuditLog<>), typeof(EntityAuditLog<>));
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
Console.WriteLine(scope.ServiceProvider.GetService<IAuditLog<Product>>()?.GetType().Name ?? "none");
// PriceQuote is not an IEntity, so the constrained open generic is skipped and the result is null.
Console.WriteLine(scope.ServiceProvider.GetService<IAuditLog<PriceQuote>>()?.GetType().Name ?? "none");
public interface IEntity
{
Guid Id { get; }
}
public sealed record Product(Guid Id, string Sku, decimal Price) : IEntity;
public sealed record PriceQuote(string Sku, decimal Price);
public interface IAuditLog<T>
{
void Record(T item, string action);
}
public sealed class EntityAuditLog<T> : IAuditLog<T> where T : IEntity
{
public void Record(T item, string action) => Console.WriteLine($"{typeof(T).Name} {item.Id}: {action}");
}Swapping a registration in an integration test
<code>ConfigureTestServices</code> runs after the app's own registrations, so <code>RemoveAll</code> followed by <code>AddSingleton</code> leaves exactly one <code>IClock</code>. Relying on last-wins alone also works for single resolution, but any code that injects <code>IEnumerable<IClock></code> would still see the real clock, so removing first is the safer habit.
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Xunit;
public partial class Program;
public interface IClock
{
DateTimeOffset Now { get; }
}
public sealed class FrozenClock(DateTimeOffset now) : IClock
{
public DateTimeOffset Now => now;
}
public class InvoiceDueDateTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Due_date_is_thirty_days_after_issue()
{
var client = factory.WithWebHostBuilder(b => b.ConfigureTestServices(services =>
{
services.RemoveAll<IClock>();
services.AddSingleton<IClock>(new FrozenClock(new DateTimeOffset(2026, 10, 1, 9, 0, 0, TimeSpan.Zero)));
})).CreateClient();
var dueDate = await client.GetStringAsync("/invoices/INV-2026-1001/due-date", TestContext.Current.CancellationToken);
Assert.Contains("2026-10-31", dueDate);
}
}
Discussion