C# Reflection Explained — Internals, Performance & Real-World Patterns
Most C# code is written with full knowledge of the types it works with — you reference a class, call its methods, and the compiler keeps everything honest. But entire categories of powerful software — dependency injection containers, ORMs, serialisers, test frameworks, and plugin systems — work without knowing the types in advance. They discover, inspect, and invoke code at runtime. That capability is Reflection, and it's one of the most consequential APIs in the entire .NET ecosystem.
The problem Reflection solves is compile-time ignorance. When you're building a framework that loads user-supplied assemblies, or a serialiser that must handle any POCO ever written, you can't hard-code type knowledge. Reflection gives you a runtime mirror of the CLR's metadata — every assembly, every type, every method, field, property, and attribute — as a navigable object graph you can query and act on.
By the end of this article you'll understand exactly how the CLR stores and exposes metadata, how to walk the full reflection object model, how to invoke code dynamically with and without caching, how to write and read custom attributes, how to emit IL at runtime with lightweight techniques, where Reflection genuinely kills performance and how to fix it, and the production patterns that make Reflection safe to ship. This is the article you wish existed the first time Reflection bit you in production.
How the CLR Metadata System Actually Works Under the Hood
Every .NET assembly is a PE (Portable Executable) file. Alongside the IL bytecode, the compiler embeds a rich metadata section — tables of type definitions, method signatures, field layouts, custom attributes, and inter-assembly references. This is the same data Visual Studio uses for IntelliSense. Reflection is simply a managed API that reads those tables at runtime.
The entry point is always System.Type. It's an abstract class whose concrete implementation, System.RuntimeType, is created and cached by the CLR the first time a type is loaded into an AppDomain. That means Type.GetType() calls after the first one are cheap — you're reading a cached CLR object, not re-parsing the binary.
The hierarchy goes: AppDomain → Assembly → Module → Type → MemberInfo (MethodInfo, FieldInfo, PropertyInfo, ConstructorInfo, EventInfo). Every one of these is an object you can hold a reference to, compare, and pass around. MethodInfo.Invoke() ultimately calls into native CLR code that performs a late-bound dispatch — it finds the compiled JIT stub for the method and calls it, bypassing compile-time binding but still executing fully-compiled IL.
Understanding this model matters because it tells you exactly where costs live: type lookup is cheap after the first load, MemberInfo retrieval has moderate overhead (array allocation), and Invoke itself is expensive — roughly 50-100x slower than a direct call — because of argument boxing, security checks, and the late-bind dispatch mechanism.
using System; using System.Reflection; using System.Linq; // A realistic domain class we'll inspect at runtime public class OrderProcessor { private readonly string _regionCode; public int ProcessedCount { get; private set; } public OrderProcessor(string regionCode) { _regionCode = regionCode; } public decimal CalculateTotal(decimal subtotal, decimal taxRate) { ProcessedCount++; return subtotal + (subtotal * taxRate); } private void ResetCounters() => ProcessedCount = 0; } class ReflectionMetadataExplorer { static void Main() { // GetType() on a live instance is the fastest path — no string lookup Type orderProcessorType = typeof(OrderProcessor); Console.WriteLine($"Full name : {orderProcessorType.FullName}"); Console.WriteLine($"Assembly : {orderProcessorType.Assembly.GetName().Name}"); Console.WriteLine($"Is abstract : {orderProcessorType.IsAbstract}"); Console.WriteLine($"Base type : {orderProcessorType.BaseType?.Name}"); Console.WriteLine(); // GetMembers with BindingFlags to include private and instance members // Without these flags you only get public instance members — a common trap BindingFlags allMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; Console.WriteLine("=== ALL MEMBERS ==="); foreach (MemberInfo member in orderProcessorType.GetMembers(allMembers) .OrderBy(m => m.MemberType.ToString())) { // MemberType tells you Constructor / Field / Method / Property / Event Console.WriteLine($" [{member.MemberType,-12}] {member.Name}"); } Console.WriteLine(); Console.WriteLine("=== METHOD SIGNATURES ==="); // GetMethods returns MethodInfo[], each carrying full parameter metadata foreach (MethodInfo method in orderProcessorType.GetMethods(allMembers) .Where(m => !m.IsSpecialName)) // skip get_/set_ accessors { string parameters = string.Join(", ", method.GetParameters() .Select(p => $"{p.ParameterType.Name} {p.Name}")); string visibility = method.IsPublic ? "public" : "private"; Console.WriteLine($" {visibility} {method.ReturnType.Name} {method.Name}({parameters})"); } } }
Assembly : ReflectionDemo
Is abstract : False
Base type : Object
=== ALL MEMBERS ===
[Constructor ] .ctor
[Field ] _regionCode
[Field ] <ProcessedCount>k__BackingField
[Method ] CalculateTotal
[Method ] Equals
[Method ] Finalize
[Method ] GetHashCode
[Method ] GetType
[Method ] MemberwiseClone
[Method ] ResetCounters
[Method ] ToString
[Method ] get_ProcessedCount
[Method ] set_ProcessedCount
[Property ] ProcessedCount
=== METHOD SIGNATURES ===
public Decimal CalculateTotal(Decimal subtotal, Decimal taxRate)
public Boolean Equals(Object obj)
public Int32 GetHashCode()
public Type GetType()
public String ToString()
private Void ResetCounters()
Dynamic Invocation, Custom Attributes, and Caching for Production Use
Raw Reflection.Invoke() is the sledgehammer — powerful but slow. In production you have two main upgrade paths: cache MemberInfo objects so you avoid repeated metadata lookups, and use compiled delegates or expression trees to turn a one-time reflection cost into a near-zero per-call cost.
Custom attributes are where Reflection really earns its keep in frameworks. By decorating types and members with attributes you create a declarative metadata layer — think [Required], [HttpGet], or your own [AuditLog]. Reflection reads those attributes at startup to build routing tables, validation rules, or processing pipelines without any hard-coded type knowledge.
The pattern that scales: do your reflection work once at application startup, compile it into Func<> delegates via Expression.Compile() or Delegate.CreateDelegate(), then call those delegates at request time. The delegate call is indistinguishable from a direct call in the JIT — no boxing overhead, no late-bind dispatch.
Below is a realistic mini-framework that discovers all types decorated with a custom [CommandHandler] attribute, builds a dispatch table from command name to handler delegate, and invokes handlers at near-native speed — the same pattern used by MediatR, minimal API routing, and plugin systems.
using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; // ── Custom attribute that marks a class as a command handler ────────────────── [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public sealed class CommandHandlerAttribute : Attribute { public string CommandName { get; } public CommandHandlerAttribute(string commandName) => CommandName = commandName; } // ── Handler contract ────────────────────────────────────────────────────────── public interface ICommandHandler { string Execute(string payload); } // ── Two handlers discovered purely via reflection — no registration code ────── [CommandHandler("greet")] public class GreetHandler : ICommandHandler { public string Execute(string payload) => $"Hello, {payload}!"; } [CommandHandler("shout")] public class ShoutHandler : ICommandHandler { public string Execute(string payload) => payload.ToUpperInvariant() + "!!"; } // ── The dispatcher — built once, invoked thousands of times ─────────────────── public class CommandDispatcher { // Stores pre-compiled factory delegates: () => ICommandHandler // Using Func<ICommandHandler> means we get a fresh handler instance per call // (swap for a singleton if your handlers are stateless and thread-safe) private readonly Dictionary<string, Func<ICommandHandler>> _handlerFactories = new(StringComparer.OrdinalIgnoreCase); public CommandDispatcher(Assembly searchAssembly) { // ── STARTUP COST: do all reflection here, once ──────────────────────── foreach (Type candidateType in searchAssembly.GetTypes()) { // IsDefined is cheaper than GetCustomAttribute when you only need presence if (!candidateType.IsClass || candidateType.IsAbstract) continue; CommandHandlerAttribute? attr = candidateType.GetCustomAttribute<CommandHandlerAttribute>(); if (attr is null) continue; // Verify the type actually implements ICommandHandler at startup // Fail fast here rather than at dispatch time if (!typeof(ICommandHandler).IsAssignableFrom(candidateType)) throw new InvalidOperationException( $"{candidateType.Name} has [CommandHandler] but doesn't implement ICommandHandler"); // ── Build a compiled factory: Expression<Func<ICommandHandler>> ── // This is the key performance trick. We use Expression trees to emit // a delegate that calls `new GreetHandler()` directly — no Activator, // no boxing, no reflection at invocation time. ConstructorInfo ctor = candidateType.GetConstructor(Type.EmptyTypes) ?? throw new InvalidOperationException( $"{candidateType.Name} needs a parameterless constructor"); // NewExpression represents `new T()` in the expression tree NewExpression newExpression = Expression.New(ctor); // Cast to ICommandHandler so the delegate type is uniform UnaryExpression castExpression = Expression.Convert(newExpression, typeof(ICommandHandler)); // Compile into an actual delegate — this is the one-time JIT cost Func<ICommandHandler> factory = Expression.Lambda<Func<ICommandHandler>>(castExpression).Compile(); _handlerFactories[attr.CommandName] = factory; Console.WriteLine($"[Startup] Registered '{attr.CommandName}' → {candidateType.Name}"); } } public string Dispatch(string commandName, string payload) { if (!_handlerFactories.TryGetValue(commandName, out Func<ICommandHandler>? factory)) return $"Unknown command: {commandName}"; // This is now essentially a direct constructor call + interface dispatch // Zero reflection overhead here — just a compiled delegate invocation ICommandHandler handler = factory(); return handler.Execute(payload); } } class Program { static void Main() { // All reflection happens here — one time, at startup var dispatcher = new CommandDispatcher(Assembly.GetExecutingAssembly()); Console.WriteLine(); Console.WriteLine("=== DISPATCHING COMMANDS ==="); // These calls have no reflection overhead — just compiled delegate + vtable dispatch Console.WriteLine(dispatcher.Dispatch("greet", "World")); Console.WriteLine(dispatcher.Dispatch("shout", "this works")); Console.WriteLine(dispatcher.Dispatch("unknown", "anything")); } }
[Startup] Registered 'shout' → ShoutHandler
=== DISPATCHING COMMANDS ===
Hello, World!
THIS WORKS!!
Unknown command: unknown
Reflection Performance — Benchmarks, Bottlenecks, and the Source Generator Alternative
The performance story of Reflection has two distinct chapters: .NET Framework (slow, always) and modern .NET (much better, but still has sharp edges). On .NET 7+ the JIT can devirtualise some reflection calls and the metadata reader is significantly faster, but MethodInfo.Invoke() still carries boxing overhead for value types and a security stack-walk on first invocation.
Here's what actually costs time, ranked: (1) Assembly scanning with GetTypes() — O(n) where n is total types, can be tens of milliseconds for large assemblies; (2) GetCustomAttributes() — allocates an array on every call if not cached; (3) MethodInfo.Invoke() — approximately 50-100ns per call vs ~1ns for a direct call; (4) Activator.CreateInstance
The modern answer for hot-path scenarios is Source Generators. Introduced in .NET 5, they run at compile time and generate the type-inspection code that Reflection would have run at runtime. System.Text.Json switched from Reflection to Source Generators in .NET 6 and saw 2-3x serialisation throughput improvements. If you're writing a library that does Reflection-heavy work, offering a Source Generator path is now table-stakes for performance-sensitive users.
For scenarios where you can't use Source Generators — runtime plugin loading, for example — the cached-delegate pattern shown earlier is your best tool. The benchmark below makes the cost differences concrete.
// Requires: dotnet add package BenchmarkDotNet // Run with: dotnet run -c Release using System; using System.Linq.Expressions; using System.Reflection; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; public class PriceCalculator { public decimal ApplyDiscount(decimal price, decimal discountRate) => price * (1m - discountRate); } [MemoryDiagnoser] // shows allocations per operation [RankColumn] // adds a rank column so differences are obvious public class InvocationBenchmarks { private readonly PriceCalculator _calculator = new(); // ── Pre-fetched and cached — this is what good code does ───────────────── private readonly MethodInfo _cachedMethodInfo; private readonly Func<PriceCalculator, decimal, decimal, decimal> _compiledDelegate; public InvocationBenchmarks() { // Cache MethodInfo once — reuse thousands of times _cachedMethodInfo = typeof(PriceCalculator) .GetMethod(nameof(PriceCalculator.ApplyDiscount))!; // Build and compile an Expression tree delegate once // Signature: (PriceCalculator instance, decimal price, decimal discountRate) => decimal ParameterExpression instanceParam = Expression.Parameter(typeof(PriceCalculator), "instance"); ParameterExpression priceParam = Expression.Parameter(typeof(decimal), "price"); ParameterExpression discountParam = Expression.Parameter(typeof(decimal), "discountRate"); MethodCallExpression callExpression = Expression.Call( instanceParam, _cachedMethodInfo, priceParam, discountParam); _compiledDelegate = Expression .Lambda<Func<PriceCalculator, decimal, decimal, decimal>>( callExpression, instanceParam, priceParam, discountParam) .Compile(); } [Benchmark(Baseline = true)] public decimal DirectCall() // This is the gold standard — what we compare everything else to => _calculator.ApplyDiscount(100m, 0.1m); [Benchmark] public decimal CachedMethodInfoInvoke() // Uses pre-fetched MethodInfo — avoids lookup overhead, still pays boxing cost // decimal args get boxed into object[] here — that's the main remaining cost => (decimal)_cachedMethodInfo.Invoke(_calculator, new object[] { 100m, 0.1m })!; [Benchmark] public decimal UncachedMethodInfoInvoke() // Worst case: reflects AND invokes on every call — never do this in a loop => (decimal)typeof(PriceCalculator) .GetMethod(nameof(PriceCalculator.ApplyDiscount))! .Invoke(_calculator, new object[] { 100m, 0.1m })!; [Benchmark] public decimal CompiledExpressionDelegate() // Best of both worlds: discovered via reflection once, executes like a direct call // No boxing, no late-bind overhead — the JIT sees this as a regular delegate call => _compiledDelegate(_calculator, 100m, 0.1m); } class Program { static void Main() => BenchmarkRunner.Run<InvocationBenchmarks>(); }
|---------------------------|------------|--------|-----------|------|
| DirectCall | 0.45 ns | 1.00 | 0 B | 1 |
| CompiledExpressionDelegate| 1.10 ns | 2.44 | 0 B | 2 |
| CachedMethodInfoInvoke | 68.20 ns | 151.6 | 96 B | 3 |
| UncachedMethodInfoInvoke | 412.50 ns | 916.7 | 312 B | 4 |
Production Gotchas — Private Members, Security, AOT Compatibility, and Thread Safety
Reflection in production has a few traps that only reveal themselves at scale or in unusual deployment environments. Let's cover the ones that actually hurt teams.
Private member access works fine in standard .NET but is restricted in Ahead-of-Time (AOT) compiled apps (.NET NativeAOT, Blazor WASM in full AOT mode, and iOS/Android with Xamarin/MAUI). AOT strips metadata for private members by default to reduce binary size. If your serialiser or DI container tries to set a private field, it silently fails or throws. The fix is rd.xml trim directives (for older platforms) or the newer [DynamicallyAccessedMembers] attribute, which tells the trimmer exactly what metadata to preserve.
Thread safety: Type and MemberInfo objects themselves are thread-safe to read — the CLR guarantees that. But if you're building a Dictionary
Generic type reflection adds a wrinkle: typeof(List<>) gives you the open generic type definition. You need Type.MakeGenericType() to get List
using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Reflection; using System.Diagnostics.CodeAnalysis; // ── Pattern 1: Thread-safe lazy discovery cache ─────────────────────────────── public static class TypeMetadataCache { // ConcurrentDictionary is safe for concurrent reads AND writes // Regular Dictionary with concurrent writes causes data corruption — not just exceptions private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propertyCache = new(); // GetOrAdd is atomic: if two threads race, only one factory runs, one result is stored public static PropertyInfo[] GetPublicProperties(Type targetType) => _propertyCache.GetOrAdd(targetType, t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)); } // ── Pattern 2: AOT-safe reflection with [DynamicallyAccessedMembers] ────────── public static class SafeActivator { // The [DynamicallyAccessedMembers] attribute tells the IL trimmer: // "keep the parameterless constructor for whatever type T resolves to" // Without this, NativeAOT / Blazor WASM AOT may trim the constructor away public static T CreateInstance< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>() where T : new() { return new T(); // Activator.CreateInstance<T>() would also work here } } // ── Pattern 3: Generic type construction at runtime ─────────────────────────── public static class GenericTypeBuilder { public static object CreateGenericList(Type elementType) { // typeof(List<>) is the open generic definition — List with no type args Type openListType = typeof(List<>); // MakeGenericType closes the generic: List<> + string → List<string> Type closedListType = openListType.MakeGenericType(elementType); // Now we can construct it — Activator is fine here since it's a one-time setup return Activator.CreateInstance(closedListType) ?? throw new InvalidOperationException("Could not create list instance"); } public static void AddItemToList(object list, object item) { // list is a List<T> for some T — we need to find Add(T item) // GetMethod("Add") would throw AmbiguousMatchException for types with overloads // Safe approach: filter by name AND parameter count Type listType = list.GetType(); MethodInfo addMethod = Array.Find( listType.GetMethods(), m => m.Name == "Add" && m.GetParameters().Length == 1) ?? throw new InvalidOperationException("No Add(T) method found"); addMethod.Invoke(list, new[] { item }); } } // ── Pattern 4: Reflecting over generic method definitions ───────────────────── public class DataConverter { public T Convert<T>(string input) where T : IParsable<T> => T.Parse(input, null); public static void DemonstrateGenericMethodReflection() { Type converterType = typeof(DataConverter); // GetMethod with just the name works here because there's only one overload // But if overloads existed, we'd use GetMethods().Where(m => m.IsGenericMethodDefinition) MethodInfo openGenericMethod = converterType.GetMethod("Convert") ?? throw new MissingMethodException("Convert method not found"); Console.WriteLine($"Is generic method def: {openGenericMethod.IsGenericMethodDefinition}"); // Close the generic by specifying the type argument: Convert<int> MethodInfo closedMethod = openGenericMethod.MakeGenericMethod(typeof(int)); DataConverter instance = new DataConverter(); object result = closedMethod.Invoke(instance, new object[] { "42" })!; Console.WriteLine($"Converted result: {result} (type: {result.GetType().Name})"); } } class Program { static void Main() { // Thread-safe cache demo PropertyInfo[] props1 = TypeMetadataCache.GetPublicProperties(typeof(DateTime)); PropertyInfo[] props2 = TypeMetadataCache.GetPublicProperties(typeof(DateTime)); // cache hit Console.WriteLine($"DateTime properties found: {props1.Length} (both calls same array: {ReferenceEquals(props1, props2)})"); // Generic type construction object stringList = GenericTypeBuilder.CreateGenericList(typeof(string)); GenericTypeBuilder.AddItemToList(stringList, "hello"); GenericTypeBuilder.AddItemToList(stringList, "world"); Console.WriteLine($"List type: {stringList.GetType().Name}, contents: {string.Join(", ", (List<string>)stringList)}"); // Generic method reflection DataConverter.DemonstrateGenericMethodReflection(); } }
List type: List`1, contents: hello, world
Is generic method def: True
Converted result: 42 (type: Int32)
| Invocation Strategy | Per-Call Speed | Allocations | Setup Cost | AOT Safe | Best Used When |
|---|---|---|---|---|---|
| Direct method call | ~0.5 ns (baseline) | 0 B | None | Yes | You know the type at compile time |
| Compiled Expression delegate | ~1-2 ns | 0 B | One-time compile (~1ms) | Partial* | Hot path, type unknown at compile time |
| Delegate.CreateDelegate() | ~1-2 ns | 0 B | Negligible | Yes (public) | Simple method with known delegate signature |
| Cached MethodInfo.Invoke() | ~68 ns | ~96 B | One-time GetMethod() | No | Moderate frequency, value type args acceptable |
| Uncached MethodInfo.Invoke() | ~412 ns | ~312 B | None (paid per call) | No | Never in hot paths — acceptable for tools/utils |
| Activator.CreateInstance | ~15 ns | Object size only | None | Yes (with annotation) | Creating instances of known interface |
| Source Generator | ~0.5 ns | 0 B | Build-time codegen | Yes | Library authors, serialisation, DI containers |
🎯 Key Takeaways
- Reflection is a runtime API over CLR metadata — Type objects are cached by the runtime after first load, so typeof() and GetType() are cheap, but GetCustomAttributes() and Invoke() allocate on every call if not handled carefully.
- The performance cliff is MethodInfo.Invoke() for value-type arguments — boxing turns a stack operation into a heap allocation. Compiled Expression delegates eliminate this entirely and benchmark at near-direct-call speed.
- Do all reflection work at startup (assembly scanning, attribute reading, MethodInfo resolution) and compile the results into delegates — then your hot path has zero reflection overhead, just delegate invocations.
- NativeAOT and IL trimming are the new production minefield for Reflection — always annotate with [DynamicallyAccessedMembers], validate with a trimmed publish, and consider Source Generators for library code that needs both reflection-style flexibility and AOT compatibility.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Calling GetType().GetMethod() inside a loop — Every GetMethod() call traverses the metadata tables and allocates a new MethodInfo object — in a tight loop processing thousands of objects this burns CPU and creates GC pressure. The exact symptom is high Gen0 GC collections and unexplained CPU spikes in profiling. Fix: call GetMethod() once before the loop, store the MethodInfo in a local variable, and invoke the cached reference inside the loop.
- ✕Mistake 2: Forgetting BindingFlags when accessing non-public members — GetMethod("ResetCounters") returns null if the method is private, with zero indication of why. Developers waste hours thinking the method doesn't exist. The fix is always explicit: BindingFlags.Instance | BindingFlags.NonPublic for private instance members. Make it a habit to always pass explicit BindingFlags — never rely on the default (public instance only).
- ✕Mistake 3: Using Reflection in a NativeAOT or trimmed publish without trim annotations — The trimmer silently removes metadata, so GetProperties() returns an empty array and GetMethod() returns null even for existing methods. There's no runtime exception to guide you. Fix: add [DynamicallyAccessedMembers] attributes to all reflection entry points, enable trim warnings in your csproj with
or true , and validate by doing a test publish with --self-contained before shipping.
Interview Questions on This Topic
- QWhat's the performance difference between a cached MethodInfo.Invoke() call and a compiled Expression tree delegate, and why does the difference exist at the IL/JIT level?
- QHow does [DynamicallyAccessedMembers] interact with the IL trimmer, and what happens if you omit it in a NativeAOT-published application that uses Reflection?
- QIf you're building a plugin system that loads external assemblies at runtime and calls methods on discovered types, walk me through the full approach: how you'd discover types, handle versioning mismatches, ensure thread safety on the discovery cache, and keep invocation overhead low.
Frequently Asked Questions
Is C# Reflection slow and should I avoid it in production?
It depends entirely on how you use it. MethodInfo.Invoke() in a hot loop is slow — roughly 100x a direct call with value-type boxing overhead. But if you cache MemberInfo objects and compile Expression tree delegates at startup, per-call overhead drops to near zero. Modern production frameworks like ASP.NET Core and Entity Framework use Reflection extensively — they just front-load the cost at startup.
What's the difference between Type.GetType(string) and typeof(T)?
typeof(T) is resolved at compile time by the C# compiler — it emits a ldtoken IL instruction and costs essentially nothing at runtime. Type.GetType(string) performs a runtime string lookup across loaded assemblies, is significantly slower, and returns null (not an exception) if the type isn't found. Always prefer typeof() when you know the type at compile time.
Can I use Reflection to access private methods and fields in C#?
Yes, using BindingFlags.NonPublic in your GetMethod() or GetField() call will return private members. However, this is fragile — private members are implementation details with no stability guarantee between versions — and it's blocked by default in partial-trust environments and some AOT deployment targets. It's legitimate for unit testing internals or writing serialisers, but never rely on it in library code that targets unknown environments.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.