Skip to content

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 + ILReflection.EmitAssemblyBuilder.
  • 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.GetMethod on the hot path; resolve once, store the delegate.
  • Senior choice in 2026: source generators first; Expression.Compile for dynamic-but-rare scenarios (mappers, validators, EF expression rewriters); raw DynamicMethod/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] or DynamicDependency.
  • Expression.Compile() — uses Reflection.Emit which 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.

var open = method.CreateDelegate<Func<Order, decimal>>();   // instance is the first arg

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 / GetMethod inside a loop instead of caching.
  • Forgetting that MethodInfo.Invoke boxes value-type returns.
  • Using Expression.Compile() in code that ships to AOT — silent runtime failure.
  • Forgetting BindingFlags.NonPublic | BindingFlags.Instance when reflecting private members.
  • Trim warnings ignored in CI — production crashes when the property/method gets stripped.
  • Reflecting types from dynamically loaded assemblies without considering AssemblyLoadContext lifetime.

Further reading