Open generic registration in Microsoft DI: how resolution closes types, precedence over closed registrations, IEnumerable resolution, decorators with Scrutor, validation and AOT limits.
You register the open types, services.AddScoped(typeof(IRepository<>), typeof(Repository<>)), and when someone asks for IRepository<Order> the container closes the implementation with MakeGenericType and builds it, caching that plan. An exact closed registration for IRepository<Order> wins over the open one, while IEnumerable<IRepository<Order>> returns both. Lifetimes are per closed type. Microsoft DI has no decorator support, so I use Scrutor's Decorate for open generics or write it by hand. ValidateOnBuild cannot check open generics, so I add tests that resolve real closed types.
An open generic registration tells the container: "for any closed version of this interface, create the matching closed version of this implementation". services.AddScoped(typeof(IRepository<>), typeof(Repository<>)) means IRepository<Order> resolves to Repository<Order>, IRepository<Customer> to Repository<Customer>, and so on — one registration for every entity. I'd explain how closing works, overrides with closed registrations, lifetimes, decorators, and what Microsoft.Extensions.DependencyInjection (MS DI) can't do.
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
builder.Services.AddSingleton(typeof(ILogger<>), typeof(Logger<>)); // how ILogger<T> itself is registered
builder.Services.AddScoped(typeof(IHandler<,>), typeof(DefaultHandler<,>)); // multiple type parameters
public sealed class OrderService(IRepository<Order> orders) { ... } // resolves EfRepository<Order>
The implementation must be an open generic type definition with the same number of type parameters in a compatible arrangement, and must implement the service type.
When IRepository<Order> is requested and there's no closed registration:
IRepository<>) and looks for open generic registrations of it.Order) and calls typeof(EfRepository<>).MakeGenericType(typeof(Order)).EfRepository<T> where T : AggregateRoot and Order doesn't satisfy them, MakeGenericType would throw — MS DI (since .NET 5) skips such registrations when resolving IEnumerable<T> and throws a clear error for single resolution.Implementation type parameters are mapped positionally to the service's type arguments; MS DI doesn't support implementations whose type parameters are arranged differently (e.g. class Impl<T> : IService<List<T>>).
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
services.AddScoped<IRepository<Order>, OrderRepository>(); // specialised for Order
Resolving IRepository<Order> returns OrderRepository — closed registrations win over open generic ones regardless of order. For IEnumerable<IRepository<Order>>, you get both (open-generic-derived and closed) — relevant for handler/plug-in patterns.
typeof(ICache<>) gives one Cache<Order> and one Cache<Customer> for the app.DbContext is a bug; ValidateScopes catches it when the closed type is resolved (and ValidateOnBuild can't validate open generics without knowing type arguments — they're validated on first resolution).DbContext should be scoped.MS DI has no built-in decorator support, and decorating open generics is awkward by hand. Options:
services.Decorate(typeof(IRepository<>), typeof(CachingRepository<>)); — supports open generic decoration.RegisterGenericDecorator, Lamar, Simple Injector).Typical generic decorators: caching, logging, retry, validation (IHandler<TCommand> wrapped by ValidatingHandler<TCommand>).
IHandler<IDomainEvent> won't satisfy IHandler<OrderPlaced>.IHandler<Order, TResult>).MakeGenericType; with NativeAOT, instantiations involving value types must be statically discoverable, and the container emits trimming warnings in some cases.These limits are deliberate — MS DI is a minimal "conforming container". For complex needs, plug in a third-party container via UseServiceProviderFactory.
A CQRS-style app registered 120 command handlers one by one. We introduced ICommandHandler<TCommand> with Scrutor scanning to register all closed implementations, and an open generic decorator chain: ValidationDecorator<> → TransactionDecorator<> → LoggingDecorator<>. New commands needed no registration changes, cross-cutting behaviour was consistent, and ValidateScopes in development caught a decorator that had been registered as a singleton while depending on a scoped DbContext.
Checks practical DI knowledge beyond AddScoped<IFoo, Foo>(): how the container works with generics, precedence, decorators and validation gaps.
Green flags: explains MakeGenericType closing and caching; closed-over-open precedence; per-closed-type lifetimes; ValidateOnBuild gap; decorator options.
Red flags: thinks you must register each closed type; unaware that Microsoft DI has no decorator support; no testing strategy.
ServiceCollection stores ServiceDescriptors. For open generics, ServiceType and ImplementationType are generic type definitions. CallSiteFactory handles resolution:
serviceType.IsConstructedGenericType and look up descriptors by GetGenericTypeDefinition().MakeGenericType, catching ArgumentException for constraint violations (so incompatible open generic registrations are skipped for IEnumerable<T>).The resulting call sites are cached per service type.
IEnumerable<T> resolution orderFor IEnumerable<IHandler<OrderPlaced>>, MS DI returns all matching registrations in registration order, including open generic closures and closed registrations. Ordering matters for pipelines — register in the order you want them executed, or have explicit ordering in your dispatcher.
public sealed class EfRepository<T> : IRepository<T> where T : class, IAggregateRoot { ... }
Requesting IRepository<OrderLine> (not an aggregate root) fails at resolution with an error explaining constraint violation — useful as an architectural guardrail ("repositories only for aggregates").
Decorate replaces the existing descriptor with one that resolves the inner implementation and wraps it. For open generics, Scrutor creates a new open generic decorator registration that closes both decorator and inner type. Order matters: decorators applied later wrap earlier ones.
.NET 8 keyed services (AddKeyedScoped(typeof(IExporter<>), "csv", typeof(CsvExporter<>))) support open generics, resolved with [FromKeyedServices("csv")] IExporter<Order> or GetRequiredKeyedService. Useful for strategy selection by configuration.
For NativeAOT or faster start-up, some teams use compile-time containers (e.g. Jab, Pure.DI) or source-generated registration code. These require closed types known at compile time, which conflicts with open generic flexibility — another trade-off.
IRepository<>/EfRepository<> and a closed override; resolve both a generic and the overridden type.ValidateScopes and register a generic singleton depending on DbContext; resolve it.using Microsoft.Extensions.DependencyInjection;
namespace Generics.OpenRegistration;
public abstract class EntityBase { public Guid Id { get; init; } = Guid.NewGuid(); }
public sealed class Order : EntityBase { }
public sealed class Customer : EntityBase { }
public interface IRepository<T> where T : EntityBase
{
Task<T?> FindAsync(Guid id, CancellationToken ct);
}
public class Repository<T> : IRepository<T> where T : EntityBase
{
public virtual Task<T?> FindAsync(Guid id, CancellationToken ct) => Task.FromResult<T?>(null);
}
public sealed class OrderRepository : Repository<Order>
{
public override Task<Order?> FindAsync(Guid id, CancellationToken ct) => Task.FromResult<Order?>(new Order { Id = id });
}
// Manual decorator without Scrutor, for a closed type
public sealed class LoggingRepository<T>(IRepository<T> inner) : IRepository<T> where T : EntityBase
{
public async Task<T?> FindAsync(Guid id, CancellationToken ct)
{
Console.WriteLine($"Finding {typeof(T).Name} {id}");
return await inner.FindAsync(id, ct);
}
}
public static class Program
{
public static async Task Main()
{
var services = new ServiceCollection();
services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); // open generic
services.AddScoped<IRepository<Order>, OrderRepository>(); // closed override
// With Scrutor you could write: services.Decorate(typeof(IRepository<>), typeof(LoggingRepository<>));
await using var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateOnBuild = true, // does not cover the open generic
ValidateScopes = true,
});
await using var scope = provider.CreateAsyncScope();
var orders = scope.ServiceProvider.GetRequiredService<IRepository<Order>>();
var customers = scope.ServiceProvider.GetRequiredService<IRepository<Customer>>();
Console.WriteLine(orders.GetType().Name); // OrderRepository
Console.WriteLine(customers.GetType().Name); // Repository`1
foreach (var repo in scope.ServiceProvider.GetServices<IRepository<Order>>())
Console.WriteLine($"enumerable: {repo.GetType().Name}");
var logged = new LoggingRepository<Order>(orders);
Console.WriteLine((await logged.FindAsync(Guid.NewGuid(), CancellationToken.None))?.Id);
}
}
What happens: dozens of AddScoped<IRepository<X>, Repository<X>>() lines.
Why it is wrong: repetitive and easy to forget for new types.
Do instead: use an open generic registration (and closed overrides where needed).
What happens: open generic singletons that depend on scoped services.
Why it is wrong: captive dependencies — shared DbContext across requests.
Do instead: match lifetimes and enable scope validation.
What happens: registering a decorator as another implementation of the same interface. Why it is wrong: the last registration wins; it isn't wrapped automatically (and may recurse). Do instead: use Scrutor or a container with decorator support.
What happens: typeof(IService<>) registered with typeof(Impl<,>) or an implementation over List<T>.
Why it is wrong: MS DI can't close it; errors at registration or resolution.
Do instead: keep implementation type parameters aligned with the service's.
What happens: open generic IRepository<T> with IQueryable<T> used everywhere.
Why it is wrong: leaky abstraction and unpredictable queries.
Do instead: small generic core plus specific repositories or query handlers.
What happens: expecting IHandler<IDomainEvent> to handle OrderPlaced.
Why it is wrong: MS DI doesn't resolve via variance.
Do instead: explicit registrations or variance-aware dispatch.
What happens: assuming ValidateOnBuild checks everything.
Why it is wrong: open generics are only validated when closed and resolved.
Do instead: add tests that resolve representative closed types.
What happens: open generic DI with value-type arguments in NativeAOT apps.
Why it is wrong: MakeGenericType may fail at run time.
Do instead: test AOT builds and register closed types where needed.
Open generics are one of the most effective ways to apply cross-cutting behaviour (validation, transactions, logging, caching) consistently, but they move errors from start-up to first use. My standard is: open generic registrations plus a reflection-driven test that resolves every closed type, plus explicit closed overrides where an entity is special. I also keep the decoration order documented, because the order of Decorate calls decides which decorator runs first. In AOT-published services I test the published binary, not just the JIT build.
A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.
A generic interface is a contract with a type parameter, like IComparer<T> or IEquatable<T>. They exist because the old object-based interfaces forced casts and boxed structs: Dictionary<TKey,TValue> uses EqualityComparer<T>.Default, which calls IEquatable<T>.Equals with no boxing. A type can implement several closed versions of the same interface, usually explicitly, and generic interfaces, unlike classes, can be covariant or contravariant. When I design one, like IHandler<TCommand>, I keep it small and make T carry real meaning.
Generics are usually zero-cost, but how depends on T. For value types the JIT generates separate, fully specialised code, so there is no boxing and typeof(T) == typeof(int) folds to a constant. For reference types all instantiations share one canonical body, which looks up type-specific data such as statics and typeof(T) through a runtime dictionary. new T() and generic virtual methods take slower paths. The cost of specialisation is code size and JIT time, which matters for Native AOT, where every instantiation must be known at build time.
How to tell it (STAR)
**S:** A deployment broke one command endpoint even though start-up validation passed. **T:** Fix it and close the gap. **A:** I found ValidateOnBuild does not check open generic registrations, so a missing dependency in one host slipped through. I fixed the registration and wrote a reflection-based test that resolves every closed handler. **R:** We caught [number] similar problems in CI over the next [period] instead of in production.
Last updated 30 Oct 2026.