📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you register and resolve open generic types in .NET dependency injection, for example services.AddScoped(typeof(IRepository<>), typeof(Repository<>))? Explain how the container closes the type, how closed registrations override it, lifetimes, decorators and the limits of Microsoft.Extensions.DependencyInjection.

Open generic registration in Microsoft DI: how resolution closes types, precedence over closed registrations, IEnumerable resolution, decorators with Scrutor, validation and AOT limits.

Senior Architecture Advanced aspnet-core csharp dependency-injection generics open-generics

🎯 Key points

  • Register with `typeof(IService<>)` and `typeof(Impl<>)`; arities must match and the implementation must be an open generic type definition
  • On resolve, the container closes the implementation with `MakeGenericType` using the requested type arguments and caches the call site
  • An exact closed registration (`IRepository<Order>`) takes precedence over the open generic for a single-service resolve
  • `IEnumerable<IService<T>>` returns both closed and open-generic-based registrations
  • Lifetimes apply per closed type: one singleton `Repository<Order>` and a separate one for `Repository<Customer>`
  • Microsoft DI has no built-in decorator; Scrutor's `Decorate` supports open generics, or you write the decoration by hand
  • ValidateOnBuild cannot validate open generics because the closed types are unknown until resolution
  • `ILogger<T>` and `IOptions<T>` are the framework's own open generic registrations

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Registering and resolving

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.

2. How the container closes the type

When IRepository<Order> is requested and there's no closed registration:

  1. The container takes the requested type's generic type definition (IRepository<>) and looks for open generic registrations of it.
  2. It takes the type arguments (Order) and calls typeof(EfRepository<>).MakeGenericType(typeof(Order)).
  3. It checks constraints: if 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.
  4. It builds a resolver for the closed type (constructor selection, dependencies) and caches it, so later resolutions are as fast as closed registrations.

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>>).

3. Closed registrations override

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.

4. Lifetimes

  • The lifetime applies per closed type: a singleton typeof(ICache<>) gives one Cache<Order> and one Cache<Customer> for the app.
  • Captive dependency rules still apply: an open generic singleton depending on a scoped 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).
  • Repositories over DbContext should be scoped.

5. Decorators

MS DI has no built-in decorator support, and decorating open generics is awkward by hand. Options:

  • Scrutor: services.Decorate(typeof(IRepository<>), typeof(CachingRepository<>)); — supports open generic decoration.
  • Manual closed decoration per type with factories (doesn't scale).
  • Switch to a container with richer features (Autofac RegisterGenericDecorator, Lamar, Simple Injector).

Typical generic decorators: caching, logging, retry, validation (IHandler<TCommand> wrapped by ValidatingHandler<TCommand>).

6. Limits of MS DI

  • No decorators, interception or named registrations (keyed services exist since .NET 8 but don't combine elegantly with open generics).
  • No variance-aware resolution: IHandler<IDomainEvent> won't satisfy IHandler<OrderPlaced>.
  • No conditional registrations ("use X when injected into Y").
  • Only positional mapping of generic arguments; no partially closed generics (IHandler<Order, TResult>).
  • No assembly scanning built in (use Scrutor or source generators).
  • AOT: open generic resolution uses 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.

7. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Under the hood of MS DI

ServiceCollection stores ServiceDescriptors. For open generics, ServiceType and ImplementationType are generic type definitions. CallSiteFactory handles resolution:

  • Exact match lookup for closed descriptors first.
  • If none, check serviceType.IsConstructedGenericType and look up descriptors by GetGenericTypeDefinition().
  • Create the closed implementation type via MakeGenericType, catching ArgumentException for constraint violations (so incompatible open generic registrations are skipped for IEnumerable<T>).
  • Build a call site (constructor call site with dependency call sites), then compile it to a resolver (expression trees / IL emit, or interpretation under AOT).

The resulting call sites are cached per service type.

2. IEnumerable<T> resolution order

For 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.

3. Generic constraints and registration

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").

4. Decorators with Scrutor

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.

5. Keyed services and generics

.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.

6. Source-generated DI

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.

Try it yourself

  1. Register IRepository<>/EfRepository<> and a closed override; resolve both a generic and the overridden type.
  2. Add a constraint to the implementation and resolve a type that violates it; read the error.
  3. Add Scrutor and decorate the open generic with a caching decorator.
  4. Enable ValidateScopes and register a generic singleton depending on DbContext; resolve it.

💻 Code

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);
    }
}

⚠ Common mistakes

1. Registering every closed type manually

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).

2. Wrong lifetimes for generic services

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.

3. Expecting decorators in MS DI

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.

4. Mismatched generic arity or arrangement

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.

5. Generic repositories hiding EF Core

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.

6. Relying on variance in resolution

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.

7. Not validating open generics

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.

8. Ignoring AOT warnings

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate talks about cross-cutting decorators and dependencies. Why: Checks the candidate understands why DI exists, not just how to register.
    How to answer

    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.

  2. When: Candidate registers IRepository<> or ICommandHandler<>. Why: Open generic registration depends on well-designed generic interfaces.
    How to answer

    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.

  3. When: Candidate mentions AOT or MakeGenericType. Why: Native AOT and MakeGenericType limits relate to how generics are compiled.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Open generic handler failed on first use in production
Situation
A CQRS API registered `ICommandHandler<>` handlers and an open generic `TransactionDecorator<>` that depended on a new scoped `IUnitOfWork`. CI passed and start-up validation passed.
Signals
After deployment, the first request for one rarely used command returned 500 with 'Unable to resolve service for type IUnitOfWork'.
Investigation
Reproduced locally by resolving that closed handler type; confirmed ValidateOnBuild skips open generic descriptors, so the missing IUnitOfWork registration in one host was never checked.
Resolution
Added the missing registration and hot-fixed the release.
Prevention
Added an integration test that discovers every ICommand type by reflection and resolves `ICommandHandler<T>` in a scope, run against each host project.

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.

↩ Usually asked after

More in Generics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me