Production killer: uncalled GetMethod() loops cause GC pressure; always cache MemberInfo
AOT trimming silently removes metadata — annotate with [DynamicallyAccessedMembers] to survive trim
Plain-English First
Imagine your app is a locked filing cabinet. Normally you open specific drawers you already know about. Reflection is like getting an X-ray machine — you can see every drawer, every folder, and every sheet of paper inside, even the ones you didn't know existed, and you can read or change them at runtime without a key. It's the CLR letting your code inspect and manipulate itself.
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.
ReflectionMetadataExplorer.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
usingSystem;
usingSystem.Reflection;
usingSystem.Linq;
// A realistic domain class we'll inspect at runtimepublicclassOrderProcessor
{
privatereadonlystring _regionCode;
publicintProcessedCount { get; private set; }
publicOrderProcessor(string regionCode)
{
_regionCode = regionCode;
}
publicdecimalCalculateTotal(decimal subtotal, decimal taxRate)
{
ProcessedCount++;
return subtotal + (subtotal * taxRate);
}
privatevoidResetCounters() => ProcessedCount = 0;
}
classReflectionMetadataExplorer
{
staticvoidMain()
{
// GetType() on a live instance is the fastest path — no string lookupType 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 trapBindingFlags 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 / EventConsole.WriteLine($" [{member.MemberType,-12}] {member.Name}");
}
Console.WriteLine();
Console.WriteLine("=== METHOD SIGNATURES ===");
// GetMethods returns MethodInfo[], each carrying full parameter metadataforeach (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})");
}
}
}
Output
Full name : OrderProcessor
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()
Watch Out: The Compiler-Generated Backing Field Name
When you reflect over an auto-property like public int ProcessedCount { get; private set; }, the CLR generates a backing field named <ProcessedCount>k__BackingField. If you're trying to set it directly via FieldInfo.SetValue() (a common serialiser trick), search for it by that exact pattern — don't assume the field name matches the property name.
Production Insight
Type lookup is cheap only after the first load; repeated calls to GetType(string) parse metadata each time.
The real cost is in GetMembers() which allocates a new array every call — always cache MemberInfo arrays.
Rule: Profile with dotnet-trace to see if reflection allocations dominate your GC heap.
Key Takeaway
System.Type objects are cached by the CLR.
GetMembers() and GetCustomAttributes() allocate every call.
Cache MemberInfo at startup to avoid repeated array allocations.
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.
CommandDispatcher.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
usingSystem;
usingSystem.Collections.Generic;
usingSystem.Linq;
usingSystem.Linq.Expressions;
usingSystem.Reflection;
// ── Custom attribute that marks a class as a command handler ──────────────────
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
publicsealedclassCommandHandlerAttribute : Attribute
{
publicstringCommandName { get; }
publicCommandHandlerAttribute(string commandName) => CommandName = commandName;
}
// ── Handler contract ──────────────────────────────────────────────────────────publicinterfaceICommandHandler
{
stringExecute(string payload);
}
// ── Two handlers discovered purely via reflection — no registration code ──────
[CommandHandler("greet")]
publicclassGreetHandler : ICommandHandler
{
publicstringExecute(string payload) => $"Hello, {payload}!";
}
[CommandHandler("shout")]
publicclassShoutHandler : ICommandHandler
{
publicstringExecute(string payload) => payload.ToUpperInvariant() + "!!";
}
// ── The dispatcher — built once, invoked thousands of times ───────────────────publicclassCommandDispatcher
{
// 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)privatereadonlyDictionary<string, Func<ICommandHandler>> _handlerFactories
= new(StringComparer.OrdinalIgnoreCase);
publicCommandDispatcher(Assembly searchAssembly)
{
// ── STARTUP COST: do all reflection here, once ────────────────────────foreach (Type candidateType in searchAssembly.GetTypes())
{
// IsDefined is cheaper than GetCustomAttribute when you only need presenceif (!candidateType.IsClass || candidateType.IsAbstract)
continue;
CommandHandlerAttribute? attr =
candidateType.GetCustomAttribute<CommandHandlerAttribute>();
if (attr isnull) continue;
// Verify the type actually implements ICommandHandler at startup// Fail fast here rather than at dispatch timeif (!typeof(ICommandHandler).IsAssignableFrom(candidateType))
thrownewInvalidOperationException(
$"{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)
?? thrownewInvalidOperationException(
$"{candidateType.Name} needs a parameterless constructor");
// NewExpression represents `new T()` in the expression treeNewExpression newExpression = Expression.New(ctor);
// Cast to ICommandHandler so the delegate type is uniformUnaryExpression castExpression =
Expression.Convert(newExpression, typeof(ICommandHandler));
// Compile into an actual delegate — this is the one-time JIT costFunc<ICommandHandler> factory =
Expression.Lambda<Func<ICommandHandler>>(castExpression).Compile();
_handlerFactories[attr.CommandName] = factory;
Console.WriteLine($"[Startup] Registered '{attr.CommandName}' → {candidateType.Name}");
}
}
publicstringDispatch(string commandName, string payload)
{
if (!_handlerFactories.TryGetValue(commandName, outFunc<ICommandHandler>? factory))
return $"Unknown command: {commandName}";
// This is now essentially a direct constructor call + interface dispatch// Zero reflection overhead here — just a compiled delegate invocationICommandHandler handler = factory();
return handler.Execute(payload);
}
}
classProgram
{
staticvoidMain()
{
// All reflection happens here — one time, at startupvar dispatcher = newCommandDispatcher(Assembly.GetExecutingAssembly());
Console.WriteLine();
Console.WriteLine("=== DISPATCHING COMMANDS ===");
// These calls have no reflection overhead — just compiled delegate + vtable dispatchConsole.WriteLine(dispatcher.Dispatch("greet", "World"));
Console.WriteLine(dispatcher.Dispatch("shout", "this works"));
Console.WriteLine(dispatcher.Dispatch("unknown", "anything"));
}
}
Output
[Startup] Registered 'greet' → GreetHandler
[Startup] Registered 'shout' → ShoutHandler
=== DISPATCHING COMMANDS ===
Hello, World!
THIS WORKS!!
Unknown command: unknown
Pro Tip: Expression.Compile() vs Delegate.CreateDelegate()
For simple instance methods that match a known delegate signature, Delegate.CreateDelegate() is faster to set up than building an Expression tree and compiling it — use it when the method signature is fixed. Use Expression trees when you need to adapt signatures, coerce types, or construct objects. Both produce near-native invocation speed.
Production Insight
Startup cost scales linearly with number of assemblies scanned — GetTypes() on a 100-assembly app can take 500ms.
Cache your discovery results in a ConcurrentDictionary to avoid re-scanning on AppDomain restarts.
Rule: Measure startup time with Application Insights or a simple Stopwatch; if >1s, consider a source generator.
Key Takeaway
Do all reflection work once at startup.
Compile delegates for hot-path invocation.
Failure to cache leads to the 50x throughput drop shown in the production incident.
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<T>() — slower than a direct constructor but faster than the non-generic overload due to avoided boxing.
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.
ReflectionPerformanceBenchmark.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// Requires: dotnet add package BenchmarkDotNet// Run with: dotnet run -c ReleaseusingSystem;
usingSystem.Linq.Expressions;
usingSystem.Reflection;
usingBenchmarkDotNet.Attributes;
usingBenchmarkDotNet.Running;
publicclassPriceCalculator
{
publicdecimalApplyDiscount(decimal price, decimal discountRate)
=> price * (1m - discountRate);
}
[MemoryDiagnoser] // shows allocations per operation
[RankColumn] // adds a rank column so differences are obviouspublicclassInvocationBenchmarks
{
privatereadonlyPriceCalculator _calculator = new();
// ── Pre-fetched and cached — this is what good code does ─────────────────privatereadonlyMethodInfo _cachedMethodInfo;
privatereadonlyFunc<PriceCalculator, decimal, decimal, decimal> _compiledDelegate;
publicInvocationBenchmarks()
{
// 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) => decimalParameterExpression 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)]
publicdecimalDirectCall()
// This is the gold standard — what we compare everything else to
=> _calculator.ApplyDiscount(100m, 0.1m);
[Benchmark]
publicdecimalCachedMethodInfoInvoke()
// 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, newobject[] { 100m, 0.1m })!;
[Benchmark]
publicdecimalUncachedMethodInfoInvoke()
// Worst case: reflects AND invokes on every call — never do this in a loop
=> (decimal)typeof(PriceCalculator)
.GetMethod(nameof(PriceCalculator.ApplyDiscount))!
.Invoke(_calculator, newobject[] { 100m, 0.1m })!;
[Benchmark]
publicdecimalCompiledExpressionDelegate()
// 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);
}
classProgram
{
staticvoidMain() => BenchmarkRunner.Run<InvocationBenchmarks>();
}
Interview Gold: Why Does CachedMethodInfoInvoke Still Allocate?
Even with a pre-fetched MethodInfo, Invoke() takes an object[] parameter array. Passing value types like decimal causes boxing — wrapping the value in a heap-allocated object. That's the 96 bytes you see in the benchmark. The compiled delegate avoids this entirely because it's strongly typed — the JIT passes decimals as value types on the stack, just like a direct call.
Production Insight
The cost of boxing adds up fast in high-throughput services. A single Invoke() with two decimals allocates 96B. At 10K RPS, that's 960KB/s of GC pressure.
Source Generators eliminate all runtime reflection cost but require build-time code generation.
Rule: Use compiled delegates for runtime-dynamic invocation; invest in Source Generators for library code.
Key Takeaway
Compiled delegates are 150x faster than cached Invoke() for value types.
Source Generators are the ultimate fix for reflection-heavy libraries.
Benchmark your actual usage before dismissing reflection as 'too slow'.
Reflection.Emit: Generating IL at Runtime for Maximum Flexibility
When compiled delegates aren't enough — think dynamic proxies, mock frameworks, or serialisers that need to create entire methods on the fly — Reflection.Emit gives you direct access to the IL emitter. This is the low-level API that tools like Castle.Core (DynamicProxy) and Moq use under the hood. It's complex, but it's the only way to generate new types and methods at runtime without resorting to file-based code generation.
The core classes are AssemblyBuilder, ModuleBuilder, TypeBuilder, and ILGenerator. You define a dynamic assembly, create a type, add methods, and emit IL opcodes directly. The resulting code is fully JIT-compiled and executes at native speed. The cost is development time and complexity — one misplaced opcode can corrupt the stack or cause runtime execution errors.
In modern .NET, the more practical approach for most scenarios is to use the source generators or the expression trees we've already covered. But if you ever need to implement AOP interceptors, ORM lazy loading proxies, or compile-time serialisers for types discovered at runtime, Reflection.Emit is the tool. Just remember: dynamic assemblies cannot be unloaded unless you use an AssemblyLoadContext.
DynamicProxyWithEmit.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
usingSystem;
usingSystem.Reflection;
usingSystem.Reflection.Emit;
// A simple interceptor that logs every method callpublicclassLoggingProxyBuilder
{
publicstatic T CreateProxy<T>(T target, Action<string> logAction)
where T : class
{
Type targetType = typeof(T);
AssemblyName assemblyName = newAssemblyName("DynamicProxyAssembly");
AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(
assemblyName, AssemblyBuilderAccess.Run);
ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule");
TypeBuilder typeBuilder = moduleBuilder.DefineType(
$"{targetType.Name}_Proxy",
TypeAttributes.Public | TypeAttributes.Class,
targetType);
// Store target and logAction in fieldsFieldBuilder targetField = typeBuilder.DefineField(
"_target", targetType, FieldAttributes.Private);
FieldBuilder logField = typeBuilder.DefineField(
"_log", typeof(Action<string>), FieldAttributes.Private);
// Constructor that takes target and logActionConstructorBuilder ctorBuilder = typeBuilder.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
new[] { targetType, typeof(Action<string>) });
ILGenerator ctorIl = ctorBuilder.GetILGenerator();
ctorIl.Emit(OpCodes.Ldarg_0);
ctorIl.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes));
ctorIl.Emit(OpCodes.Ldarg_0);
ctorIl.Emit(OpCodes.Ldarg_1);
ctorIl.Emit(OpCodes.Stfld, targetField);
ctorIl.Emit(OpCodes.Ldarg_0);
ctorIl.Emit(OpCodes.Ldarg_2);
ctorIl.Emit(OpCodes.Stfld, logField);
ctorIl.Emit(OpCodes.Ret);
// Override every public virtual methodforeach (MethodInfo method in targetType.GetMethods(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (!method.IsVirtual || method.IsFinal) continue;
ParameterInfo[] parameters = method.GetParameters();
Type[] paramTypes = Array.ConvertAll(parameters, p => p.ParameterType);
MethodBuilder methodBuilder = typeBuilder.DefineMethod(
method.Name,
MethodAttributes.Public | MethodAttributes.Virtual,
method.ReturnType,
paramTypes);
ILGenerator il = methodBuilder.GetILGenerator();
// Log method call
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldfld, logField);
il.Emit(OpCodes.Ldstr, $"{method.Name} called");
il.Emit(OpCodes.Callvirt, typeof(Action<string>).GetMethod("Invoke"));
// Call target method
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldfld, targetField);
for (int i = 1; i <= parameters.Length; i++)
il.Emit(OpCodes.Ldarg, i);
il.Emit(OpCodes.Callvirt, method);
il.Emit(OpCodes.Ret);
}
Type proxyType = typeBuilder.CreateType();
return (T)Activator.CreateInstance(proxyType, target, logAction);
}
}
Output
// Usage:
var calculator = new PriceCalculator();
var proxy = LoggingProxyBuilder.CreateProxy(calculator, msg => Console.WriteLine(msg));
proxy.ApplyDiscount(100m, 0.1m);
// Output: "ApplyDiscount called"
// The proxy logs the call then delegates to the real method.
Reflection.Emit: One Wrong Opcode = Hard Crash
There's no compile-time checking for emitted IL. A missing operand, wrong stack type, or unbalanced stack causes an InvalidProgramException at runtime. Always unit test dynamic assemblies thoroughly and consider using expression trees for simpler cases.
Production Insight
Dynamic assemblies generated via Reflection.Emit cannot be unloaded (before .NET Core 3.0) unless you use a custom AssemblyLoadContext. Memory leaks from proxy classes that are no longer needed are a real production issue.
Use AssemblyLoadContext to isolate and unload dynamic assemblies in long-running services like plugin hosts.
Rule: Prefer expression trees or source generators; use Emit only when you must generate new types (e.g., dynamic proxy, serialiser).
Key Takeaway
Reflection.Emit lets you generate IL at runtime — powerful but dangerous.
One IL opcode mistake = runtime crash. Always test generated code.
Use AssemblyLoadContext to unload dynamic assemblies to avoid memory leaks.
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<string, MethodInfo> discovery cache, make sure you use ConcurrentDictionary or initialise it once before any concurrent access. The bug pattern is a lazy-initialised static dictionary populated in a static constructor that gets hit from multiple threads — the static constructor is thread-safe, but populating a regular Dictionary after construction is not.
Generic type reflection adds a wrinkle: typeof(List<>) gives you the open generic type definition. You need Type.MakeGenericType() to get List<string> at runtime. And GetMethod() on a generic type requires you to filter by parameter count and then check IsGenericMethodDefinition — GetMethod by name alone will throw AmbiguousMatchException if there are overloads.
ProductionReflectionPatterns.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
usingSystem;
usingSystem.Collections.Concurrent;
usingSystem.Collections.Generic;
usingSystem.Reflection;
usingSystem.Diagnostics.CodeAnalysis;
// ── Pattern 1: Thread-safe lazy discovery cache ───────────────────────────────publicstaticclassTypeMetadataCache
{
// ConcurrentDictionary is safe for concurrent reads AND writes// Regular Dictionary with concurrent writes causes data corruption — not just exceptionsprivatestaticreadonlyConcurrentDictionary<Type, PropertyInfo[]> _propertyCache = new();
// GetOrAdd is atomic: if two threads race, only one factory runs, one result is storedpublicstaticPropertyInfo[] GetPublicProperties(Type targetType)
=> _propertyCache.GetOrAdd(targetType,
t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
}
// ── Pattern 2: AOT-safe reflection with [DynamicallyAccessedMembers] ──────────publicstaticclassSafeActivator
{
// 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 awaypublicstatic 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 ───────────────────────────publicstaticclassGenericTypeBuilder
{
publicstaticobjectCreateGenericList(Type elementType)
{
// typeof(List<>) is the open generic definition — List with no type argsType 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 setupreturnActivator.CreateInstance(closedListType)
?? thrownewInvalidOperationException("Could not create list instance");
}
publicstaticvoidAddItemToList(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 countType listType = list.GetType();
MethodInfo addMethod = Array.Find(
listType.GetMethods(),
m => m.Name == "Add" && m.GetParameters().Length == 1)
?? thrownewInvalidOperationException("No Add(T) method found");
addMethod.Invoke(list, new[] { item });
}
}
// ── Pattern 4: Reflecting over generic method definitions ─────────────────────publicclassDataConverter
{
public T Convert<T>(string input) where T : IParsable<T>
=> T.Parse(input, null);
publicstaticvoidDemonstrateGenericMethodReflection()
{
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")
?? thrownewMissingMethodException("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 = newDataConverter();
object result = closedMethod.Invoke(instance, newobject[] { "42" })!;
Console.WriteLine($"Converted result: {result} (type: {result.GetType().Name})");
}
}
classProgram
{
staticvoidMain()
{
// Thread-safe cache demoPropertyInfo[] props1 = TypeMetadataCache.GetPublicProperties(typeof(DateTime));
PropertyInfo[] props2 = TypeMetadataCache.GetPublicProperties(typeof(DateTime)); // cache hitConsole.WriteLine($"DateTime properties found: {props1.Length} (both calls same array: {ReferenceEquals(props1, props2)})");
// Generic type constructionobject 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 reflectionDataConverter.DemonstrateGenericMethodReflection();
}
}
Output
DateTime properties found: 18 (both calls same array: True)
List type: List`1, contents: hello, world
Is generic method def: True
Converted result: 42 (type: Int32)
Watch Out: Reflection + .NET Native AOT = Silent Data Loss
In NativeAOT and Blazor WASM AOT builds, the IL trimmer removes metadata it can't statically prove is needed. GetProperties() or GetFields() on a type that isn't annotated will return an empty array — no exception, just empty results. Annotate all reflection entry points with [DynamicallyAccessedMembers] and run dotnet publish -r <rid> --self-contained locally to catch trim warnings before they reach production.
Production Insight
AOT-compatible reflection requires explicit annotations — [DynamicallyAccessedMembers] tells the trimmer what to keep.
Missing this attribute in a NativeAOT app leads to silent data loss (empty arrays, null methods).
Rule: Always publish with trimming enabled during CI to catch trim warnings before deployment.
ConcurrentDictionary prevents data races in discovery caches.
Generic reflection needs MakeGenericType and careful method filtering.
● Production incidentPOST-MORTEMseverity: high
The 50x Throughput Drop: Uncached Invoke() in a High-Traffic API
Symptom
P99 latency jumped from 2ms to 120ms. CPU usage went from 30% to 85%. GC collections spiked to Gen0 collections every 2 seconds.
Assumption
The team assumed that because they cached the MethodInfo object, the invocation would be nearly as fast as a direct call. They didn't account for boxing of decimal parameters.
Root cause
The router used MethodInfo.Invoke(instance, new object[]{ amount, rate }) where amount and rate are decimal value types. Every call boxed both arguments into object[] — allocating ~96B per invocation and triggering GC pressure. Invoke() also performs a security stack walk on first call per method.
Fix
Replaced MethodInfo.Invoke with compiled Expression delegates. The factory built a Func<Handler, decimal, decimal, decimal> once at startup. Per-call allocation dropped to 0B, latency returned to 2ms, and CPU fell to 32%.
Key lesson
Never use MethodInfo.Invoke() in a hot path with value type arguments — the boxing cost kills throughput.
Compile delegates at startup using Expression trees or Delegate.CreateDelegate() to get near-native speed.
Always profile before assuming reflection is 'fast enough'; one Invoke() in a loop can tank an entire service.
Production debug guideSymptom → Action for the most common reflection-related production problems4 entries
Symptom · 01
High CPU and frequent Gen0 GC collections in a reflection-heavy workload
→
Fix
Profile with dotnet-trace: dotnet-trace collect --providers Microsoft-DotNETRuntimeSampledObjectAllocation. Look for allocations in System.Reflection.MethodBaseInvoker. Replace Invoke() with compiled delegates.
Symptom · 02
GetProperties() returns empty array in a NativeAOT published binary
→
Fix
Run dotnet publish -r win-x64 --self-contained and check trim warnings. Add [DynamicallyAccessedMembers] on the type parameter or use [RequiresUnreferencedCode] to signal the trimmer.
Symptom · 03
AmbiguousMatchException when calling GetMethod() for an overloaded method
→
Fix
GetMethod('Add') fails when there are overloads. Use GetMethods().Where(m => m.Name == 'Add' && m.GetParameters().Length == 1) and manually select the correct one.
Symptom · 04
Custom attributes appear null after trimming
→
Fix
Mark the attribute class with [AttributeUsage(AllowMultiple = false)] and ensure the assembly declares [assembly: MetadataUpdateHandler] if using hot reload. In AOT, add a trim root for the attribute.
★ Reflection Cheat Sheet: Diagnose & Fix FastCommands and patterns to debug reflection issues without digging through MSDN for an hour.
Use GetMethods() + LINQ instead of GetMethod(name) for generic methods.
Invocation Strategy
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<T>()
~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
1
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.
2
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.
3
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.
4
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.
5
Reflection.Emit can generate types at runtime but requires an AssemblyLoadContext for unloading
avoid for simple invocation; stick to expression trees or source generators.
Common mistakes to avoid
4 patterns
×
Calling GetType().GetMethod() inside a loop
Symptom
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.
×
Forgetting BindingFlags when accessing non-public members
Symptom
GetMethod("ResetCounters") returns null if the method is private, with zero indication of why. Developers waste hours thinking the method doesn't exist.
Fix
Always explicit BindingFlags: 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).
×
Using Reflection in a NativeAOT or trimmed publish without trim annotations
Symptom
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 <TrimmerRootDescriptor> or <EnableTrimAnalyzer>true</EnableTrimAnalyzer>, and validate by doing a test publish with --self-contained before shipping.
×
Using GetMethod("Add") on a generic List<T> without handling overloads
Symptom
AmbiguousMatchException thrown because List<T> has an overloaded Add method (e.g., Add(T) and Add(IEnumerable<T>) in some interfaces).
Fix
Use GetMethods() and filter by name and parameter count: m.Name == "Add" && m.GetParameters().Length == 1. For generic types, also check IsGenericMethodDefinition.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What's the performance difference between a cached MethodInfo.Invoke() c...
Q02SENIOR
How does [DynamicallyAccessedMembers] interact with the IL trimmer, and ...
Q03SENIOR
If you're building a plugin system that loads external assemblies at run...
Q04JUNIOR
What is the difference between typeof(T) and Type.GetType("TypeName") fr...
Q01 of 04SENIOR
What'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?
ANSWER
Cached MethodInfo.Invoke() boxes value-type arguments into object[], allocates ~96B per call, and incurs a security stack walk on first invocation. The JIT cannot inline or devirtualise the call. A compiled Expression delegate, on the other hand, produces a strongly-typed delegate that the JIT can inline and devirtualise — per-call cost drops to ~1.1ns with zero allocations. The difference is roughly 68x for a cached invoke, and over 400x for an uncached one.
Q02 of 04SENIOR
How does [DynamicallyAccessedMembers] interact with the IL trimmer, and what happens if you omit it in a NativeAOT-published application that uses Reflection?
ANSWER
The IL trimmer uses static analysis to remove metadata that isn't called directly. [DynamicallyAccessedMembers] acts as a hint: it marks the type's metadata (e.g., constructors, properties, methods) to be preserved even if not directly referenced. If omitted, the trimmer may remove metadata for private members, resulting in GetProperties() returning an empty array or GetMethod() returning null. The app may fail silently or throw MissingMethodException at runtime. The fix is to annotate all parameters or fields that accept types for reflection with the appropriate DynamicallyAccessedMemberTypes.
Q03 of 04SENIOR
If 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.
ANSWER
1. Use Assembly.LoadFrom or AssemblyLoadContext to load plugin assemblies. 2. Scan assemblies with GetTypes() once at startup, filtering by interface or attribute. 3. Cache discovered types and compiled delegates in a ConcurrentDictionary<Type, Delegate>. 4. For versioning: check assembly version, major version should match; use binding redirects if needed. 5. For invocation: use expression trees to compile delegates per method, so per-call cost is near-native. 6. Fail fast: validate that the plugin implements the expected interface, throw at load time not dispatch time. 7. Isolation: use separate AssemblyLoadContext per plugin to allow unloading and avoid type collisions.
Q04 of 04JUNIOR
What is the difference between typeof(T) and Type.GetType("TypeName") from a performance and reliability perspective?
ANSWER
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. Use Type.GetType() only when the type name is dynamic.
01
What'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?
SENIOR
02
How does [DynamicallyAccessedMembers] interact with the IL trimmer, and what happens if you omit it in a NativeAOT-published application that uses Reflection?
SENIOR
03
If 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.
SENIOR
04
What is the difference between typeof(T) and Type.GetType("TypeName") from a performance and reliability perspective?
JUNIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
What's the difference between Reflection.Emit and Expression trees?
Expression trees (System.Linq.Expressions) are a higher-level abstraction that the .NET runtime can compile into IL; they're easier to use but limited to creating delegates for known signatures. Reflection.Emit gives you direct IL opcode emission, allowing you to create new types and methods at runtime — useful for dynamic proxies, advanced serialisers, and AOP frameworks. Use expression trees unless you need to generate new types.