iscovery (e.g., plugin systems), convert PropertyInfo or MethodInfo into strongly-typed delegates and cache them.
Implementation:
public static class FastPropertyAccessor<TTarget, TResult>
{
private static readonly ConcurrentDictionary<string, Func<TTarget, TResult>> Cache = new();
public static Func<TTarget, TResult> GetGetter(string propertyName)
{
return Cache.GetOrAdd(propertyName, name =>
{
var property = typeof(TTarget).GetProperty(name,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (property == null)
throw new InvalidOperationException($"Property '{name}' not found on {typeof(TTarget)}.");
if (property.PropertyType != typeof(TResult))
throw new InvalidOperationException(
$"Property type mismatch: expected {typeof(TResult)}, got {property.PropertyType}.");
// Delegate.CreateDelegate is faster than Expression trees for simple accessors
return (Func<TTarget, TResult>)Delegate.CreateDelegate(
typeof(Func<TTarget, TResult>),
property.GetGetMethod());
});
}
}
Usage:
// Hot path
var getter = FastPropertyAccessor<MyEntity, string>.GetGetter("Name");
var value = getter(entity); // Direct call overhead
2. Expression Tree Compilation
For complex scenarios requiring dynamic logic (e.g., conditional mapping), use Expression trees. This allows building custom accessors that can be compiled once and reused.
Implementation:
public static class ExpressionAccessor<TTarget, TResult>
{
private static readonly ConcurrentDictionary<string, Func<TTarget, TResult>> Cache = new();
public static Func<TTarget, TResult> CreateGetter(string propertyName)
{
return Cache.GetOrAdd(propertyName, name =>
{
var parameter = Expression.Parameter(typeof(TTarget), "target");
var property = Expression.Property(parameter, name);
var lambda = Expression.Lambda<Func<TTarget, TResult>>(property, parameter);
return lambda.Compile();
});
}
}
Rationale: Expression.Compile emits IL directly. While compilation has a one-time cost, the resulting delegate performs nearly identically to Delegate.CreateDelegate. Use this when you need to inject null checks, default values, or type conversions dynamically.
3. Source Generator Pattern
For serialization, mappers, and DI, source generators are the optimal solution. They generate reflection-free code at compile time.
Generator Snippet:
[Generator]
public class PropertyAccessorGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var provider = context.SyntaxProvider.CreateSyntaxProvider(
(node, _) => node is ClassDeclarationSyntax { AttributeLists.Count: > 0 },
(ctx, _) => ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as INamedTypeSymbol)
.Where(static m => m is { IsAbstract: false, IsStatic: false });
context.RegisterSourceOutput(provider,
(spc, symbol) => Execute(symbol, spc));
}
private static void Execute(INamedTypeSymbol symbol, SourceProductionContext context)
{
var className = symbol.Name;
var properties = symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic);
var sb = new StringBuilder();
sb.AppendLine($"public static class {className}Accessor {{");
foreach (var prop in properties)
{
sb.AppendLine($" public static {prop.Type} Get{prop.Name}({className} target) => target.{prop.Name};");
}
sb.AppendLine("}");
context.AddSource($"{className}.Accessor.g.cs", sb.ToString());
}
}
Rationale: This generates direct property accessors. The JIT can inline these calls, resulting in performance identical to hand-written code. This approach is fully compatible with trimming and AOT.
4. Generic Reflection Optimization
When using MakeGenericType or GetGenericMethodDefinition, cache the constructed types and methods.
Pattern:
private static readonly ConcurrentDictionary<Type, MethodInfo> GenericMethodCache = new();
public static MethodInfo GetGenericMethod(Type typeArg)
{
return GenericMethodCache.GetOrAdd(typeArg, arg =>
{
return typeof(MyService).GetMethod("Process").MakeGenericMethod(arg);
});
}
When reading custom attributes, avoid string allocations. Use ReadOnlySpan<char> for attribute name matching.
public static bool HasAttribute<T>(Type type) where T : Attribute
{
var attributeName = typeof(T).Name;
// Custom attribute parsing using System.Reflection.Metadata for zero-allocation
// requires loading the assembly into MetadataReader, which is complex.
// For standard reflection, cache the boolean result.
return AttributeCache<T>.HasAttribute;
}
private static class AttributeCache<T> where T : Attribute
{
public static readonly bool HasAttribute = typeof(T).IsDefined(typeof(T), false);
}
Pitfall Guide
Mistake: Caching PropertyInfo objects but calling GetValue in the hot path.
Impact: GetValue still incurs boxing, argument array creation, and indirect dispatch. Performance improves marginally but remains 20x slower than delegates.
Fix: Always cache the compiled delegate or generated accessor.
2. Boxing Value Types
Mistake: Using Func<object, object> for value type properties.
Impact: Every access boxes the value type, causing allocation and CPU overhead.
Fix: Use strongly-typed generics Func<TTarget, TResult>. If TResult is a value type, no boxing occurs.
3. Thread-Safety in Caches
Mistake: Using Dictionary with manual locking or double-check locking incorrectly.
Impact: Race conditions can lead to duplicate compilations or corrupted state.
Fix: Use ConcurrentDictionary with GetOrAdd, or use Lazy<T> for singletons. Ensure delegate compilation is thread-safe.
4. Ignoring AOT and Trimming Compatibility
Mistake: Using dynamic reflection in libraries intended for Native AOT or trimmed apps.
Impact: Runtime crashes due to missing metadata or trimmed types. DynamicMethod and Delegate.CreateDelegate may fail in AOT.
Fix: Use Source Generators for all reflection needs in AOT contexts. Mark types with [DynamicDependency] if reflection is unavoidable, though this increases binary size.
5. Over-Engineering Cold Paths
Mistake: Applying complex caching or source generation to code executed once per application lifetime.
Impact: Increased code complexity and compilation time with negligible runtime benefit.
Fix: Profile first. Use naive reflection for startup, configuration loading, and one-time initialization.
6. Memory Leaks with DynamicMethod
Mistake: Creating DynamicMethod instances repeatedly without caching.
Impact: DynamicMethod allocations are not garbage collected in some runtime versions, leading to memory leaks.
Fix: Always cache compiled delegates. Avoid DynamicMethod in favor of Expression or Delegate.CreateDelegate which are safer.
7. Generic Type Explosion
Mistake: Using MakeGenericType with many type arguments, creating thousands of runtime types.
Impact: Increases memory usage and JIT compilation load. Can hit internal runtime limits.
Fix: Limit generic reflection to essential cases. Use object-based delegates with casting if type variance is too high, or refactor to use interfaces.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-Throughput API Serialization | Source Generator | Zero overhead, AOT safe, compile-time safety. | High dev effort, low runtime cost. |
| Plugin System / Dynamic Loading | Cached Delegates | Runtime flexibility with near-direct performance. | Low dev effort, low runtime cost. |
| Startup / Configuration | Naive Reflection | Simplicity; performance irrelevant for cold paths. | Minimal dev effort, negligible cost. |
| ORM Data Mapping | Compiled Expression / Delegate | Balance of flexibility and speed for row conversion. | Medium dev effort, low runtime cost. |
| Native AOT Application | Source Generator Only | Reflection is unsupported; generators produce static code. | High dev effort, zero runtime risk. |
| Generic Repository Pattern | Cached Generic Methods | Avoids MakeGenericType overhead in hot loops. | Low dev effort, medium runtime cost. |
Configuration Template
Robust Reflection Cache Helper:
public static class ReflectionCache
{
private static readonly ConcurrentDictionary<(Type Type, string Name, Type ReturnType), Delegate> PropertyCache = new();
public static Func<TTarget, TResult> GetPropertyGetter<TTarget, TResult>(string propertyName)
{
var key = (typeof(TTarget), propertyName, typeof(TResult));
return (Func<TTarget, TResult>)PropertyCache.GetOrAdd(key, k =>
{
var property = k.Type.GetProperty(k.Name,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (property is null)
throw new KeyNotFoundException($"Property '{k.Name}' not found on {k.Type}.");
if (property.PropertyType != k.ReturnType)
throw new InvalidOperationException(
$"Type mismatch for '{k.Name}': expected {k.ReturnType}, found {property.PropertyType}.");
return Delegate.CreateDelegate(
typeof(Func<TTarget, TResult>),
property.GetGetMethod());
});
}
}
Quick Start Guide
- Install Tools: Add
BenchmarkDotNet to your test project.
- Identify Hot Path: Locate loops or methods calling
PropertyInfo.GetValue, MethodInfo.Invoke, or Type.GetProperties.
- Refactor: Replace calls with
ReflectionCache.GetPropertyGetter<T, R>("Prop") and invoke the delegate.
- Validate: Run benchmarks comparing naive vs. cached approaches. Confirm latency reduction and allocation elimination.
- Deploy: For static contracts, introduce a source generator to eliminate runtime reflection entirely.
Conclusion
Reflection optimization in C# is not about avoiding reflection but about managing its cost. By caching delegates, eliminating boxing, and adopting source generators, you can achieve performance parity with direct calls while retaining metaprogramming capabilities. Prioritize source generation for static scenarios and delegate caching for dynamic ones. Always validate with profiling and ensure compatibility with modern deployment models like AOT.