Junior 6 min · March 06, 2026

C# Reflection — 50x Throughput Drop from Invoke() Boxing

P99 latency jumped 2ms→120ms, CPU 30%→85% from MethodInfo.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Reflection is a runtime API over CLR metadata tables embedded in the PE file
  • System.Type is cached after first load; GetMembers() allocates arrays each call
  • MethodInfo.Invoke() with value types boxes arguments — adds ~67ns and 96B per call
  • Compiled Expression delegates eliminate all reflection overhead ( ~1.1ns, 0 alloc)
  • 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
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})");
        }
    }
}
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
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"));
    }
}
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 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>();
}
Output
| Method | Mean | Ratio | Allocated | Rank |
|---------------------------|------------|--------|-----------|------|
| 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 |
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
using System;
using System.Reflection;
using System.Reflection.Emit;

// A simple interceptor that logs every method call
public class LoggingProxyBuilder
{
    public static T CreateProxy<T>(T target, Action<string> logAction)
        where T : class
    {
        Type targetType = typeof(T);
        
        AssemblyName assemblyName = new AssemblyName("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 fields
        FieldBuilder targetField = typeBuilder.DefineField(
            "_target", targetType, FieldAttributes.Private);
        FieldBuilder logField = typeBuilder.DefineField(
            "_log", typeof(Action<string>), FieldAttributes.Private);
        
        // Constructor that takes target and logAction
        ConstructorBuilder 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 method
        foreach (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
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();
    }
}
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.
Key Takeaway
AOT trimming removes metadata silently — annotate reflection entry points.
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.
MethodInfo.Invoke() is slow in production
Immediate action
Profile allocations: `dotnet-trace collect --providers Microsoft-DotNETRuntimeSampledObjectAllocation`
Commands
dotnet-trace collect -p <PID> --providers Microsoft-DotNETRuntimeSampledObjectAllocation
dotnet-trace report <trace.nettrace> topN --alloc
Fix now
Replace Invoke() with Expression.Compile() or Delegate.CreateDelegate().
GetMethod() returns null for a private method+
Immediate action
Check BindingFlags: must include NonPublic and Instance (or Static).
Commands
typeof(MyClass).GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Select(m => m.Name)
typeof(MyClass).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static).Length
Fix now
Always specify explicit BindingFlags — never rely on defaults.
AOT build: GetProperties() returns empty+
Immediate action
Run trimmed publish locally and inspect warnings.
Commands
dotnet publish -c Release -r win-x64 --self-contained > build.log 2>&1
Select-String -Path build.log -Pattern 'IL2026|IL3050|IL2075'
Fix now
Add [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] to the method parameter that accepts the type.
Generic method reflection: AmbiguousMatchException+
Immediate action
Filter by parameter count and check IsGenericMethodDefinition.
Commands
typeof(MyClass).GetMethods().Where(m => m.Name == 'Convert' && m.IsGenericMethodDefinition).ToArray()
method.MakeGenericMethod(typeof(int))
Fix now
Use GetMethods() + LINQ instead of GetMethod(name) for generic methods.
Invocation Strategy
Invocation StrategyPer-Call SpeedAllocationsSetup CostAOT SafeBest Used When
Direct method call~0.5 ns (baseline)0 BNoneYesYou know the type at compile time
Compiled Expression delegate~1-2 ns0 BOne-time compile (~1ms)Partial*Hot path, type unknown at compile time
Delegate.CreateDelegate()~1-2 ns0 BNegligibleYes (public)Simple method with known delegate signature
Cached MethodInfo.Invoke()~68 ns~96 BOne-time GetMethod()NoModerate frequency, value type args acceptable
Uncached MethodInfo.Invoke()~412 ns~312 BNone (paid per call)NoNever in hot paths — acceptable for tools/utils
Activator.CreateInstance<T>()~15 nsObject size onlyNoneYes (with annotation)Creating instances of known interface
Source Generator~0.5 ns0 BBuild-time codegenYesLibrary 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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Is C# Reflection slow and should I avoid it in production?
02
What's the difference between Type.GetType(string) and typeof(T)?
03
Can I use Reflection to access private methods and fields in C#?
04
What's the difference between Reflection.Emit and Expression trees?
🔥

That's C# Advanced. Mark it forged?

6 min read · try the examples if you haven't

Previous
Extension Methods in C#
6 / 15 · C# Advanced
Next
Attributes in C#