Reflection & Runtime Code Generation
Key Points
- Reflection reads metadata at runtime:
Type,MethodInfo,PropertyInfo, attributes. Cheap to inspect once, expensive to invoke repeatedly. - Runtime codegen options ranked by cost: cached delegates (
MethodInfo.CreateDelegate) →Expression.Compile()→DynamicMethod+ IL →Reflection.Emit→AssemblyBuilder. - Source generators beat reflection for AOT/trimming compatibility, startup time, and zero allocations on the hot path. Reflection is hostile to NativeAOT and trimmer-unfriendly.
- Caching is mandatory — never call
Type.GetMethodon the hot path; resolve once, store the delegate. - Senior choice in 2026: source generators first;
Expression.Compilefor dynamic-but-rare scenarios (mappers, validators, EF expression rewriters); rawDynamicMethod/IL only for narrow perf-critical libraries (Dapper, AutoMapper, MessagePack).
Concepts (deep dive)
The cost ladder
startup cost invocation cost AOT-friendly?
direct call 0 ~0 yes
cached delegate 1× resolve ~5 ns yes (if no reflection)
Expression.Compile ~ms ~10-20 ns no (uses Reflection.Emit)
DynamicMethod IL ~ms ~10 ns no
MethodInfo.Invoke cached ~200-500 ns no
Activator.CreateInstance ~ ~500 ns-1µs partial
MethodInfo.Invoke boxes value-type args, allocates object[] for params, performs security checks, and crosses an unmanaged transition for argument marshaling. Avoid on hot paths.
Common reflection APIs
Type t = typeof(Order); // compile-time type
Type t2 = order.GetType(); // runtime type
PropertyInfo p = t.GetProperty("Total")!;
object? v = p.GetValue(order); // boxes value types
p.SetValue(order, 99.99m);
MethodInfo m = t.GetMethod("Submit", BindingFlags.Public | BindingFlags.Instance)!;
m.Invoke(order, null);
var attrs = t.GetCustomAttributes<DataContractAttribute>();
Cached-delegate pattern (the senior default)
// Resolve once, store, reuse.
private static readonly Func<Order, decimal> _getTotal =
typeof(Order).GetProperty(nameof(Order.Total))!
.GetMethod!.CreateDelegate<Func<Order, decimal>>();
public decimal FastTotal(Order o) => _getTotal(o); // ~5 ns, no boxing
Expression trees → compiled lambda
When the property/type isn't known at compile time but is stable per app run:
public static Func<T, object?> BuildGetter<T>(string propertyName)
{
var p = Expression.Parameter(typeof(T), "x");
var prop = Expression.Property(p, propertyName);
var box = Expression.Convert(prop, typeof(object));
return Expression.Lambda<Func<T, object?>>(box, p).Compile();
}
Expression.Compile() JIT-emits a method comparable to a hand-written one. Compile cost ~1 ms; cache the delegate.
Raw DynamicMethod + IL
For libraries that need maximum throughput (serializers, ORMs):
var dm = new DynamicMethod("get_Total", typeof(decimal), [typeof(Order)], typeof(Order), skipVisibility: true);
var il = dm.GetILGenerator();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, typeof(Order).GetProperty("Total")!.GetMethod!);
il.Emit(OpCodes.Ret);
var fn = dm.CreateDelegate<Func<Order, decimal>>();
Faster startup than Expression.Compile but you're writing IL by hand — error-prone.
AOT/trimming reality
MethodInfo.Invoke,Activator.CreateInstance(Type)— survive trimming only if types are rooted via[DynamicallyAccessedMembers]orDynamicDependency.Expression.Compile()— usesReflection.Emitwhich is unsupported on NativeAOT.DynamicMethod— unsupported on NativeAOT.Reflection.Emit AssemblyBuilder— unsupported on NativeAOT.
If you target AOT, replace runtime codegen with source generators.
Code: correct vs wrong
❌ Wrong: reflection on the hot path
public decimal Sum(IEnumerable<Order> orders)
{
decimal total = 0;
foreach (var o in orders)
{
var prop = o.GetType().GetProperty("Total"); // resolves every call
total += (decimal)prop!.GetValue(o)!; // boxes
}
return total;
}
✅ Correct: cached compiled delegate
private static readonly Func<Order, decimal> _getTotal =
typeof(Order).GetProperty(nameof(Order.Total))!
.GetMethod!.CreateDelegate<Func<Order, decimal>>();
public decimal Sum(IEnumerable<Order> orders)
{
decimal total = 0;
foreach (var o in orders) total += _getTotal(o);
return total;
}
✅ Even better (AOT-safe): direct call
If the type is known at compile time, just call the property. The reflection version exists only when the property name is dynamic.
Design patterns for this topic
Pattern 1 — Type-cache singleton
Intent: resolve reflection metadata once per type per app run.
public static class PropertyCache<T>
{
public static readonly Dictionary<string, Func<T, object?>> Getters = BuildGetters();
static Dictionary<string, Func<T, object?>> BuildGetters() { /* expression-compile each property */ }
}
Use when: a serializer/mapper needs property access by name across many calls.
Pattern 2 — Open-instance delegate
Intent: bind to a method without an instance; pass instance per call.
Avoids allocating closures.
Pattern 3 — Source generator over reflection
Intent: move the codegen to compile time.
System.Text.Json source-gen, [LoggerMessage], [GeneratedRegex], [LibraryImport] — all replace runtime reflection with generated code that's AOT-friendly.
Pattern 4 — [DynamicallyAccessedMembers] for trim safety
Intent: tell the trimmer which members must be preserved.
public static T Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>()
=> (T)Activator.CreateInstance(typeof(T))!;
Without this, the trimmer may remove the constructor and Activator.CreateInstance throws at runtime.
Pros & cons / trade-offs
| Tool | Pros | Cons |
|---|---|---|
| Direct call | Fastest, AOT-safe | Requires compile-time knowledge |
| Cached delegate | Near-native speed, AOT-safe | Resolve only by signature, not name |
Expression.Compile | Flexible, readable | No AOT, ~ms compile cost |
DynamicMethod+IL | Fast startup, fast invoke | Manual IL, no AOT |
MethodInfo.Invoke | Zero ceremony | Slow, allocations, boxing, no AOT |
| Source generator | Compile-time, AOT, zero alloc | Authoring complexity |
When to use / when to avoid
- Reach for it when: you genuinely don't know the type until runtime (plugins, generic mappers, dynamic configuration).
- Avoid when: the type is known at compile time, or you target NativeAOT — use source generators.
Interview Q&A
Q1. Why is MethodInfo.Invoke slow? A. Boxing value-type args, object[] allocation, security demand, marshaling, and an extra unmanaged transition. ~200-500 ns per call vs ~1 ns for a direct call.
Q2. What's the cheapest way to call a property by name repeatedly? A. propertyInfo.GetMethod.CreateDelegate<Func<T, TProp>>() cached in a static readonly field.
Q3. Why doesn't Expression.Compile work on NativeAOT? A. It uses Reflection.Emit which generates IL at runtime. AOT pre-compiles all code; there's no runtime JIT to consume the emitted IL.
Q4. When would you choose DynamicMethod over Expression.Compile? A. When you need lower compile latency (skip the expression-tree → IL conversion) or constructs Expression trees can't represent (e.g., Ldobj, custom calling conventions). Used by Dapper, MessagePack-CSharp, AutoMapper.
Q5. How does [DynamicallyAccessedMembers] work? A. It's a hint to the IL trimmer telling it which member kinds (constructors, properties, methods) on a type parameter must be preserved. The Roslyn analyzer warns when calls flow into reflection without proper annotation.
Q6. What's MakeGenericType and when do you need it? A. Constructs a closed generic type from an open one: typeof(List<>).MakeGenericType(typeof(int)). Required for runtime-generic instantiation. AOT requires the closed type to be reachable statically (DynamicDependency or rooted).
Q7. Why is Activator.CreateInstance<T>() faster than Activator.CreateInstance(typeof(T))? A. The generic version is constrained to new() and the JIT can devirtualize / inline it. The non-generic version goes through a slower lookup path.
Q8. How does System.Text.Json source-gen avoid reflection? A. The JsonSerializerContext-derived class contains pre-generated JsonTypeInfo<T> for each declared type — read methods walk the JSON token stream and call generated property setters directly. No MethodInfo lookups, no boxing, no Reflection.Emit.
Gotchas / common mistakes
- Calling
GetProperty/GetMethodinside a loop instead of caching. - Forgetting that
MethodInfo.Invokeboxes value-type returns. - Using
Expression.Compile()in code that ships to AOT — silent runtime failure. - Forgetting
BindingFlags.NonPublic | BindingFlags.Instancewhen reflecting private members. - Trim warnings ignored in CI — production crashes when the property/method gets stripped.
- Reflecting types from dynamically loaded assemblies without considering
AssemblyLoadContextlifetime.