How Dependency Injection Containers Use Reflection Internally in C#
How dependency injection uses reflection is a question that demystifies a lot of the "magic" in modern .NET applications. You register a service with three lines of code, and somehow the container produces a fully wired-up object graph with all the right constructor arguments populated. No new keyword. No manual wiring. It just works.
It just works because of reflection. The container inspects your types at runtime, reads their constructor signatures, resolves each parameter's type recursively, and creates instances in the right order. Once you understand this pipeline, DI stops feeling magical and starts feeling like a well-structured algorithm you could build yourself -- which is exactly what we're going to do in this article.
We'll start by tracing what a DI container does, build a working minimal container under 60 lines, walk through how IServiceCollection approaches the same problem, and finish with how modern tools like Needlr use source generators to move the reflection work to compile time.
What a DI Container Actually Does
At its core, a dependency injection container solves one problem: "Given a type T, produce a valid instance of it with all its dependencies satisfied." The steps are:
- Registration -- the user tells the container which interface maps to which implementation.
- Resolution -- when something requests a service, the container looks up the implementation type.
- Construction analysis -- the container reflects on the implementation's constructors to discover what it needs.
- Recursive resolution -- each constructor parameter is itself a type that the container resolves the same way.
- Instantiation -- the container creates the object using the resolved parameter values.
- Lifetime management -- the container decides whether to create a new instance or return a cached one.
Reflection is the engine for steps 3 and 4. Everything else is bookkeeping.
One important nuance: reflection is used once at startup (or on first resolve per type) to analyze constructor signatures and build factories. During steady-state request handling, the container invokes compiled delegates or cached constructors -- not raw reflection on every call. The per-call cost of DI resolution is negligible precisely because the reflection work is front-loaded.
For background on what's inside IServiceCollection before we look under the hood, the IServiceCollection in C# -- Complete Guide walks through the registration API in detail.
Building a Minimal DI Container
Let's build one. This container supports singleton and transient lifetimes, handles constructor injection, and resolves dependencies recursively. It's intentionally minimal -- around 55 lines -- to keep the algorithm visible.
Note: The implementation below is intentionally simplified for educational purposes -- it is not production-safe. Use
Microsoft.Extensions.DependencyInjectionor a proven DI container in real applications.
using System.Collections.Concurrent;
public enum Lifetime { Singleton, Transient }
// Intentionally simplified for educational purposes -- not production-safe.
// Use Microsoft.Extensions.DependencyInjection or a proven DI container in real applications.
public sealed class MinimalContainer
{
private readonly Dictionary<Type, (Type ImplementationType, Lifetime Lifetime)> _registrations = new();
private readonly ConcurrentDictionary<Type, object> _singletons = new();
public MinimalContainer Register<TService, TImplementation>(
Lifetime lifetime = Lifetime.Transient)
where TImplementation : TService
{
_registrations[typeof(TService)] = (typeof(TImplementation), lifetime);
return this;
}
public MinimalContainer RegisterSelf<TImplementation>(
Lifetime lifetime = Lifetime.Transient)
{
_registrations[typeof(TImplementation)] = (typeof(TImplementation), lifetime);
return this;
}
public TService Resolve<TService>() => (TService)Resolve(typeof(TService));
private object Resolve(Type serviceType)
{
if (!_registrations.TryGetValue(serviceType, out var registration))
{
throw new InvalidOperationException(
$"No registration found for {serviceType.Name}. Did you forget to register it?");
}
if (registration.Lifetime == Lifetime.Singleton)
{
return _singletons.GetOrAdd(serviceType, _ => CreateInstance(registration.ImplementationType));
}
return CreateInstance(registration.ImplementationType);
}
private object CreateInstance(Type implementationType)
{
// This is the reflection heart of the container
var constructor = implementationType.GetConstructors()
.OrderByDescending(c => c.GetParameters().Length)
.FirstOrDefault()
?? throw new InvalidOperationException(
$"No public constructor found on {implementationType.Name}.");
var parameters = constructor.GetParameters();
// Recursively resolve each parameter
var resolvedArgs = parameters
.Select(p => Resolve(p.ParameterType))
.ToArray();
return constructor.Invoke(resolvedArgs);
}
}
That's it. Let's see it work:
public interface IMessageService
{
void Send(string message);
}
public sealed class EmailService : IMessageService
{
public void Send(string message) => Console.WriteLine($"Email: {message}");
}
public sealed class NotificationHandler
{
private readonly IMessageService _messageService;
public NotificationHandler(IMessageService messageService)
{
_messageService = messageService;
}
public void Notify(string text) => _messageService.Send(text);
}
// Registration
var container = new MinimalContainer()
.Register<IMessageService, EmailService>(Lifetime.Singleton)
.RegisterSelf<NotificationHandler>(Lifetime.Transient);
// Resolution -- reflection happens here
var handler = container.Resolve<NotificationHandler>();
handler.Notify("Hello, DI!"); // Email: Hello, DI!
When Resolve<NotificationHandler>() runs:
- The container looks up
NotificationHandler→ implementation isNotificationHandler. GetConstructors()returns the one public constructor.GetParameters()returns one parameter:IMessageService.- The container recursively calls
Resolve(typeof(IMessageService)). IMessageServicemaps toEmailServicewith Singleton lifetime.EmailServicehas a parameterless constructor, so it's created immediately.- The cached
EmailServiceis passed toNotificationHandler's constructor viaconstructor.Invoke(resolvedArgs).
Every GetConstructors(), GetParameters(), and constructor.Invoke() call is reflection. For more on how ConstructorInfo.Invoke compares to alternatives, see ConstructorInfo - How To Make Reflection in DotNet Faster for Instantiation.
How IServiceCollection Does It
Microsoft's built-in DI container (Microsoft.Extensions.DependencyInjection) follows the same conceptual algorithm but adds significant production-grade machinery on top.
When you call services.AddTransient<IMyService, MyService>(), you're adding a ServiceDescriptor -- a plain data object -- to the IServiceCollection. The collection itself is just a List<ServiceDescriptor>.
The magic happens when you call services.BuildServiceProvider(). At that point:
- The
ServiceProvideris constructed from the collection. - Internally it builds a
CallSiteFactory-- a cache of "how to create each registered type." - Each call site is built by reflecting on the implementation type's constructors, finding the best match (the constructor with the most parameters that can all be satisfied), and recording the dependency graph.
- On first resolution, the call site is used to create the object. Subsequent resolutions (for singletons and scoped services) return cached instances.
DI containers like Microsoft.Extensions.DependencyInjection use reflection for service registration analysis, then build optimized factory delegates for the steady-state resolution path. After the first resolution per type, subsequent calls go through these cached factories with no further reflection. This is an implementation detail, not a guaranteed public API contract, and may change across versions -- but the pattern of "reflect once at startup, invoke delegates at runtime" is consistent with how the container has worked across .NET releases.
The IServiceCollection API is the registration surface. The ServiceProvider is the engine. For usage patterns in different application types, see How To Use IServiceCollection in Console Applications.
How Scrutor Extends This with Reflection
Scrutor adds assembly scanning on top of IServiceCollection. Instead of registering each service manually, you tell Scrutor: "scan this assembly, find all types that implement IMyInterface, and register them."
Under the hood, Scrutor uses:
Assembly.GetTypes()-- to enumerate all types in the target assembly.type.GetInterfaces()-- to check which interfaces each type implements.type.IsAbstract,type.IsGenericTypeDefinition-- to filter out non-instantiable types.- The
IServiceCollection.Add()methods -- to register what it found.
All of this is reflection. It runs at startup, which is why the reflection cost is acceptable -- startup is a one-time event, not a hot path.
For how to use Scrutor effectively, see Scrutor in C# - 3 Simple Tips to Level Up Dependency Injection. For a comparison between Scrutor and Autofac's scanning capabilities, see Scrutor vs Autofac in C#: What You Need To Know.
The Performance Problem with Reflection in DI
Let's be precise about where reflection hurts DI:
Startup time is where most DI reflection happens. Scanning assemblies, reading constructor metadata, building service descriptors -- these all run once when BuildServiceProvider() is called. On a large application with hundreds of registered services, this can add noticeable startup latency.
First resolution is where constructor-reflection-based instantiation happens if the container hasn't compiled its call sites yet. The second and subsequent resolutions hit cached delegates instead.
Scoped services in ASP.NET Core are a trickier case. Scoped services are created once per HTTP request. The compiled call sites mean most of the cost is already amortized, but the allocation still happens per-request.
The pattern that production DI containers use -- and that you can use in your own containers -- is: reflect at startup, compile to delegates, invoke delegates at runtime. Here's what that looks like added to our minimal container:
// Add to MinimalContainer: compiled factory cache
private readonly ConcurrentDictionary<Type, Func<object>> _compiledFactories = new();
private object CreateInstanceFast(Type implementationType)
{
var factory = _compiledFactories.GetOrAdd(implementationType, BuildCompiledFactory);
return factory();
}
private Func<object> BuildCompiledFactory(Type type)
{
// This runs once per type -- expression tree compilation
var ctor = type.GetConstructors()
.OrderByDescending(c => c.GetParameters().Length)
.First();
var parameterTypes = ctor.GetParameters()
.Select(p => p.ParameterType)
.ToArray();
// Build expression that calls resolve for each parameter
// (simplified -- a real implementation would capture the container reference)
var newExpr = Expression.New(ctor,
parameterTypes.Select(t =>
Expression.Convert(
Expression.Call(
Expression.Constant(this),
typeof(MinimalContainer).GetMethod("Resolve", new[] { typeof(Type) })!,
Expression.Constant(t)),
t)));
var lambda = Expression.Lambda<Func<object>>(Expression.Convert(newExpr, typeof(object)));
return lambda.Compile();
}
After the first resolution per type, every subsequent call goes through Func<object> -- no reflection in the hot path.
How Needlr Uses Source Generators Instead
Needlr takes a fundamentally different approach to the reflection-at-startup problem: it moves the type discovery work to compile time using Roslyn source generators.
Instead of scanning assemblies at runtime with Assembly.GetTypes(), Needlr's source generator runs during the build. It emits C# registration code directly into your project -- no runtime reflection required.
The result is that your DI registration at startup is just calling generated methods that contain explicit services.AddTransient<IMyService, MyService>() calls. There's no assembly scanning, no constructor reflection, and no expression tree compilation. Everything that would have been discovered dynamically is instead hard-coded into generated source.
This approach has two major advantages over reflection-based scanning:
- AOT compatibility -- NativeAOT can see all the type references at trim time because they're in real C# code, not hidden behind
Type.GetType()strings. - Startup performance -- zero reflection means faster cold starts, which matters in serverless and container-per-request deployment models.
For the full picture of how Needlr works, see Automatic Dependency Injection in C#: The Complete Guide to Needlr and Automatic Service Discovery in C# with Needlr. For the specific trade-off between reflection and source generation for this use case, Source Generation vs Reflection in Needlr digs into the decision in detail.
The "Reflection at Startup, Delegates at Runtime" Pattern
The practical synthesis of everything above is a three-phase DI lifecycle:
Phase 1 -- Registration (build time in Needlr, startup otherwise). Types are registered. No reflection yet for Needlr; full reflection scan for Scrutor/runtime scanners.
Phase 2 -- Build / warmup (startup). The container analyzes registrations. Constructor metadata is read via reflection. Call sites are built. In containers that use expression trees, delegates are compiled and cached.
Phase 3 -- Runtime resolution (hot path). Every request goes through a compiled delegate or a direct constructor call. No reflection. The cost is a dictionary lookup and a delegate invocation.
This pattern is why DI containers that look expensive (all that magic!) actually perform well in production. The reflection cost is front-loaded into startup, which happens once. Runtime is fast.
If you're building your own DI-like infrastructure (plugin loaders, factory registries, etc.), adopting this same three-phase structure will give you both flexibility and performance. The Assembly Scanning in Needlr: Filtering and Organizing Type Discovery article shows how Needlr approaches Phase 1 with source generators in production code.
Reflection and DI in .NET 10
.NET 10 continues the trend of making reflection-heavy startup patterns optional. Key developments:
- NativeAOT improvements -- the trimmer in .NET 10 is better at analyzing DI registrations when you use source generators, making AOT-compatible DI registration straightforward with the right tooling.
- Keyed services (available since .NET 8, matured in .NET 9/10) -- the
IKeyedServiceCollectionAPI lets you register multiple implementations of the same interface under different keys, which source generators can handle cleanly. See Keyed Services in Needlr: Managing Multiple Implementations for how Needlr exposes this. FrozenDictionaryfor service caches -- immutable, read-optimized dictionaries are a good fit for the "build once at startup, read constantly at runtime" pattern that DI containers use.
FAQ
Does Microsoft's DI container use reflection internally?
Yes -- Microsoft.Extensions.DependencyInjection uses reflection to analyze constructor parameters when building service call sites. After the first resolution, it compiles the call sites to delegates so that subsequent resolutions skip the reflection overhead. The reflection cost is paid at startup and on first resolution per type, not on every call.
Why are DI containers fast if they use reflection?
Because they use the "reflect once, cache forever" pattern. Constructor metadata is inspected once per type, then compiled to a delegate. After that, resolution goes through the delegate with no reflection. The reflection cost is a startup cost, not a per-request cost, which is why production DI containers have excellent throughput.
What is ConstructorInfo.Invoke and why do containers use it?
ConstructorInfo.Invoke() is a reflection API that calls a constructor with a supplied array of arguments and returns the newly created object. DI containers use it to create instances when no compiled delegate is available yet. It's slower than a direct new expression but more flexible -- the container doesn't need to know the concrete type at compile time.
Can I use DI without any reflection in .NET 10?
Yes -- with source generators. Tools like Needlr emit explicit services.Add*() calls at compile time, eliminating runtime assembly scanning entirely. For instantiation, the generated code uses direct new expressions rather than Activator.CreateInstance or ConstructorInfo.Invoke, making it AOT-safe and startup-fast.
How does Scrutor use reflection differently from the built-in container?
IServiceCollection uses reflection for instantiation (constructor analysis). Scrutor uses reflection for registration (type discovery). Scrutor scans assemblies with Assembly.GetTypes() and type.GetInterfaces() to build the registration list automatically. After scanning, registration flows into the standard IServiceCollection, and the built-in container handles instantiation as normal.
What happens if my class has multiple constructors? Which one does the DI container use?
Microsoft.Extensions.DependencyInjection selects the constructor with the most parameters that can all be satisfied from the container. If two constructors have the same number of satisfiable parameters, it throws an InvalidOperationException. You can mark a specific constructor with [ActivatorUtilitiesConstructor] to override this selection logic.
Is reflection in DI a problem for performance in .NET 10?
For most applications: no. The startup reflection cost is a one-time payment, and runtime resolution uses compiled delegates. Where it becomes a concern is in serverless or container-per-request architectures with very tight cold-start requirements. In those scenarios, source-generator-based DI (like Needlr) eliminates the startup reflection cost entirely, making cold starts as fast as a handwritten new chain.
Conclusion
Dependency injection containers are reflection engines wrapped in a friendly API. The AddSingleton<IMyService, MyService>() call you've written hundreds of times ultimately results in GetConstructors(), GetParameters(), and Invoke() calls at runtime -- the same APIs you'd use if you built the container yourself.
Understanding this internals story matters for three reasons. First, it removes the mystery -- when something goes wrong in DI resolution, you can reason about what the container is actually doing. Second, it shows you where performance is spent -- startup, not hot-path runtime. Third, it points you toward the right optimization when startup cost does matter -- source generators that move the type discovery work to compile time.
The minimal container we built demonstrates that the core algorithm is simple. The production containers add lifetime management, scope tracking, compiled delegates, and diagnostics -- but the heart is the same recursive reflection loop. Knowing that heart makes you a more effective user of whatever container you choose.

