rectly calls the getter. Source generators eliminate runtime reflection altogether by emitting strongly-typed accessors at compile time. Understanding this hierarchy prevents teams from over-investing in caching strategies that still leave 95% of performance on the table.
Core Solution
Optimizing reflection requires a layered strategy: identify hot paths, cache metadata safely, compile to delegates, and fallback to compile-time alternatives when deployment constraints demand it.
Step 1: Profile Before Optimizing
Reflection is rarely the bottleneck in application code. Use BenchmarkDotNet or dotnet-counters to isolate invocation frequency and allocation sources. Only optimize paths exceeding 10k calls/second or residing in latency-sensitive pipelines.
Never call GetProperty, GetField, or GetMethod repeatedly. Cache MemberInfo instances using ConcurrentDictionary. Avoid GetProperties().FirstOrDefault() patterns; they enumerate all members and allocate arrays.
public static class TypeMetadataCache
{
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propertyCache = new();
public static PropertyInfo[] GetProperties(Type type)
{
return _propertyCache.GetOrAdd(type, t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
}
}
Step 3: Compile to Delegates
Caching PropertyInfo reduces lookup cost but retains GetValue overhead. Compile accessors to strongly-typed delegates using Expression.Lambda or Delegate.CreateDelegate.
public static class DelegateCompiler
{
private static readonly ConcurrentDictionary<(Type, string), Delegate> _delegateCache = new();
public static Func<T, object> GetGetter<T>(string propertyName)
{
var key = (typeof(T), propertyName);
return (Func<T, object>)_delegateCache.GetOrAdd(key, _ =>
{
var property = typeof(T).GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
if (property == null) throw new MissingMemberException(typeof(T).Name, propertyName);
var instance = Expression.Parameter(typeof(T), "instance");
var propertyAccess = Expression.Property(instance, property);
var convert = Expression.Convert(propertyAccess, typeof(object));
return Expression.Lambda<Func<T, object>>(convert, instance).Compile();
});
}
}
Usage:
var getter = DelegateCompiler.GetGetter<MyDto>("Id");
var value = getter(instance); // ~18ns, zero allocation
Step 4: Architecture Decisions & Rationale
- Cache scope: Type-level caching is safe and idiomatic. Avoid instance-level caches; they multiply memory pressure and defeat the purpose.
- Delegate compilation:
Expression.Lambda.Compile() generates dynamic methods. It's fast after compilation but adds startup cost. Compile once per type/property pair.
- AOT/Trimming: Native AOT disables runtime delegate compilation. Use
System.Reflection.Metadata for read-only metadata, or switch to Source Generators for frameworks targeting trimmed/AOT deployments.
- Thread safety:
ConcurrentDictionary handles concurrency, but avoid GetOrAdd with heavy factories. Use Lazy<T> inside the value if delegate compilation might be contested.
Pitfall Guide
-
Caching MemberInfo but calling Invoke repeatedly
PropertyInfo.GetValue and MethodInfo.Invoke perform runtime argument packing, security checks, and late binding. Caching the member only eliminates metadata lookup. Always compile to delegates for hot paths.
-
Ignoring BindingFlags defaults
Calling GetProperties() without BindingFlags returns all members, including non-public and inherited ones. This increases enumeration time and allocation. Always specify BindingFlags.Public | BindingFlags.Instance unless you explicitly need otherwise.
-
Dictionary contention under high concurrency
ConcurrentDictionary is thread-safe, but GetOrAdd with expensive factories can cause thread pooling delays. Wrap delegate compilation in Lazy<T> or use GetOrAdd with a pre-compiled factory to avoid lock contention.
-
Unbounded cache growth
Caching every Type encountered in a dynamic system (e.g., JSON serializers) can cause memory leaks. Implement eviction policies (MemoryCache, ConditionalWeakTable, or size-limited dictionaries) for frameworks processing unbounded type sets.
-
AOT/Trimming incompatibility
Runtime reflection and Expression.Compile fail in Native AOT or trimmed deployments. Guard with RuntimeFeature.IsDynamicCodeSupported and provide fallback paths (Source Generators, pre-compiled accessors, or metadata-only readers).
-
Profiling wall time instead of allocations
Reflection's true cost is often Gen0 allocations from argument arrays and boxed values. Use dotnet-gc or BenchmarkDotNet allocation columns. Optimizing invocation time without addressing allocations yields diminishing returns.
-
Over-engineering cold paths
Configuration loading, startup diagnostics, and admin endpoints rarely exceed 100 calls/second. Applying delegate compilation here adds complexity with zero measurable impact. Reserve optimization for hot paths only.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Framework serialization (hot path) | Compiled delegates + type cache | Eliminates late-binding overhead, zero allocation | High upfront dev, massive runtime savings |
| DI container resolution | Cached ConstructorInfo + Activator.CreateInstance | Constructor caching balances speed and simplicity | Moderate CPU, low allocation |
| ORM materialization | Source Generators or compiled delegates | AOT-compatible, predictable performance | Compile-time cost, zero runtime reflection |
| Admin/config loading | Raw reflection or cached MemberInfo | Cold path, infrequent calls | Negligible impact, minimal code |
| Native AOT deployment | System.Reflection.Metadata or Source Generators | Runtime reflection disabled in AOT | Zero runtime overhead, build-time complexity |
Configuration Template
public sealed class ReflectionAccessorCache<T>
{
private readonly ConcurrentDictionary<string, Func<T, object>> _getters = new();
private readonly ConcurrentDictionary<string, Action<T, object>> _setters = new();
public Func<T, object> GetGetter(string propertyName)
{
return _getters.GetOrAdd(propertyName, name =>
{
var prop = typeof(T).GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
if (prop?.CanRead != true) throw new InvalidOperationException($"Property {name} not found or read-only.");
var instance = Expression.Parameter(typeof(T));
var access = Expression.Property(instance, prop);
var convert = Expression.Convert(access, typeof(object));
return Expression.Lambda<Func<T, object>>(convert, instance).Compile();
});
}
public Action<T, object> GetSetter(string propertyName)
{
return _setters.GetOrAdd(propertyName, name =>
{
var prop = typeof(T).GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
if (prop?.CanWrite != true) throw new InvalidOperationException($"Property {name} not found or write-only.");
var instance = Expression.Parameter(typeof(T));
var value = Expression.Parameter(typeof(object));
var convert = Expression.Convert(value, prop.PropertyType);
var assign = Expression.Assign(Expression.Property(instance, prop), convert);
return Expression.Lambda<Action<T, object>>(assign, instance, value).Compile();
});
}
}
Quick Start Guide
- Install dependencies: Add
BenchmarkDotNet to your test project for validation. No runtime dependencies required.
- Identify hot paths: Search for
GetProperty, GetMethod, Activator.CreateInstance, or Invoke in your codebase. Filter by call frequency using profiling or logging.
- Apply caching: Replace direct reflection calls with
ConcurrentDictionary-backed caches. Use the provided ReflectionAccessorCache<T> template as a starting point.
- Compile delegates: For paths exceeding 5k invocations/second, switch from
GetValue/SetValue to compiled Func<T, object> and Action<T, object> delegates.
- Validate deployment: Run
dotnet publish -r win-x64 -p:PublishAot=true (or your target RID) to verify AOT compatibility. Add RuntimeFeature.IsDynamicCodeSupported guards where necessary.