BrandGhost
C# Reflection Anti-Patterns: 8 Mistakes That Will Hurt Your App

C# Reflection Anti-Patterns: 8 Mistakes That Will Hurt Your App

C# Reflection Anti-Patterns: 8 Mistakes That Will Hurt Your App

Reflection is one of the most powerful tools in .NET. It lets you inspect types, invoke methods, read properties, and build dynamic systems that would be impossible with purely static code. But with great power comes great footgun potential. C# reflection anti-patterns sneak into codebases gradually -- a quick GetProperties() call here, a lazy Invoke() there -- and suddenly you're debugging a performance cliff you can't explain.

This article covers eight concrete mistakes developers make with reflection in .NET, along with the fixes. Whether you're working on a plugin system, a serialization library, or just trying to map some DTOs at runtime, these patterns will save you from painful surprises. All examples are written for .NET 10, which is the current stable release.

If you're new to reflection, start with Reflection in C#: 4 Simple But Powerful Code Examples before diving in here.


Anti-Pattern 1: Using Reflection in Hot Paths

The most common mistake: reaching for reflection inside a loop or a method that runs thousands of times per second.

The bad pattern:

public void ProcessItems(IEnumerable<MyModel> items)
{
    foreach (var item in items)
    {
        // This does a full metadata lookup EVERY iteration
        var prop = item.GetType().GetProperty("Name");
        var value = prop?.GetValue(item);
        Console.WriteLine(value);
    }
}

Every call to GetType().GetProperty("Name") triggers a lookup through the type's metadata tables. In a tight loop, this adds up fast.

The fix -- cache the lookup:

private static readonly PropertyInfo? _nameProp =
    typeof(MyModel).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance);

public void ProcessItems(IEnumerable<MyModel> items)
{
    foreach (var item in items)
    {
        var value = _nameProp?.GetValue(item);
        Console.WriteLine(value);
    }
}

Reflection metadata lookups are relatively expensive the first time. Once cached in a static field or dictionary, subsequent accesses are cheap comparisons against already-loaded metadata.


Anti-Pattern 2: Calling GetProperties() Repeatedly Without Caching

A close cousin of Anti-Pattern 1. GetProperties() allocates a new array every time it's called. Call it inside a method invoked per request or per record, and you're creating garbage the GC has to clean up constantly.

The bad pattern:

public Dictionary<string, object?> ToDictionary(object obj)
{
    var result = new Dictionary<string, object?>();
    // Allocates a new PropertyInfo[] on EVERY call
    foreach (var prop in obj.GetType().GetProperties())
    {
        result[prop.Name] = prop.GetValue(obj);
    }
    return result;
}

The fix -- cache the array:

private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propCache = new();

public Dictionary<string, object?> ToDictionary(object obj)
{
    var type = obj.GetType();
    var props = _propCache.GetOrAdd(
        type,
        t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));

    var result = new Dictionary<string, object?>(props.Length);
    foreach (var prop in props)
    {
        result[prop.Name] = prop.GetValue(obj);
    }
    return result;
}

In .NET 10, if your cache is built once at startup and never mutated after that, consider switching to FrozenDictionary<Type, PropertyInfo[]> from System.Collections.Frozen. It has lower lookup overhead than ConcurrentDictionary for read-heavy scenarios.


Anti-Pattern 3: Using Reflection When a Compiled Delegate Is Better

PropertyInfo.GetValue and MethodInfo.Invoke are late-bound. Every call goes through boxing, parameter validation, and runtime dispatch. For code that runs frequently, you can pay this cost once by compiling a delegate.

The bad pattern (invoked frequently):

var method = typeof(Calculator).GetMethod("Add")!;
var result = method.Invoke(calculator, new object[] { 3, 4 });

The fix -- compile once, call as a delegate:

// Build this once and cache it
var param1 = Expression.Parameter(typeof(int), "a");
var param2 = Expression.Parameter(typeof(int), "b");
var instance = Expression.Constant(calculator);
var call = Expression.Call(instance, typeof(Calculator).GetMethod("Add")!,
    param1, param2);
var addDelegate = Expression.Lambda<Func<int, int, int>>(call, param1, param2).Compile();

// Now call it cheaply -- near-native performance
var result = addDelegate(3, 4);

The compiled delegate runs at near-native speed after the first compile. This technique is widely used in serialization frameworks, ORMs, and dependency injection containers. You can see a real-world application of this in ConstructorInfo - How To Make Reflection in DotNet Faster for Instantiation.


Anti-Pattern 4: Ignoring BindingFlags and Getting Too Many Members

Most overloads of GetProperties(), GetMethods(), and GetMembers() without explicit BindingFlags return a broad default set. This is rarely what you want, and it wastes time filtering results that should never have been retrieved.

The bad pattern:

// Returns ALL public instance AND inherited members
var props = type.GetProperties();
// Then you filter...
var ownProps = props.Where(p => p.DeclaringType == type).ToArray();

The fix -- be explicit:

// Only get declared public instance properties -- nothing inherited
var props = type.GetProperties(
    BindingFlags.Public |
    BindingFlags.Instance |
    BindingFlags.DeclaredOnly);

Using explicit BindingFlags improves code clarity and intent -- and can avoid scanning unnecessary members, though the primary benefit is correctness and readability. It also removes the need for post-hoc filtering.


Anti-Pattern 5: Not Handling TargetInvocationException Properly

When you invoke a method via reflection and that method throws, the exception is wrapped in a TargetInvocationException. If you only catch the outer exception, you lose the actual error. If you catch Exception generically, you might swallow things you shouldn't.

The bad pattern:

try
{
    method.Invoke(target, args);
}
catch (Exception ex)
{
    // This logs the TargetInvocationException wrapper, not the real error
    _logger.LogError(ex, "Method invocation failed");
}

The fix -- unwrap the inner exception:

try
{
    method.Invoke(target, args);
}
catch (TargetInvocationException tie)
{
    // Re-throw the actual exception preserving its stack trace
    ExceptionDispatchInfo.Capture(tie.InnerException!).Throw();
}

Using ExceptionDispatchInfo.Capture(...).Throw() preserves the original stack trace instead of creating a new one. This is the correct pattern for any code that wraps invocations through reflection. Also watch out for TargetParameterCountException and ArgumentException when the parameter types don't match.


Anti-Pattern 6: Using Reflection to Bypass Access Modifiers

Sometimes you genuinely need to access a private member -- in tests, in a framework, in legacy interop code. The old pattern was to use BindingFlags.NonPublic with SetValue or Invoke. That still works, but .NET 8 introduced a cleaner alternative: [UnsafeAccessor].

The old pattern:

var field = typeof(SomeClass)
    .GetField("_privateField", BindingFlags.NonPublic | BindingFlags.Instance)!;
field.SetValue(instance, newValue);

The better pattern (.NET 8+ / .NET 10):

// Declare the accessor -- compiler verifies the signature at JIT time
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_privateField")]
static extern ref int GetPrivateField(SomeClass instance);

// Use it like a regular field reference
GetPrivateField(instance) = newValue;

[UnsafeAccessor] is zero-overhead once JIT-compiled. There's no boxing, no metadata lookup at call time, and the JIT can inline the access. It's the right tool whenever you need private member access that reflection was previously the only option for -- when the target type and member are statically known at compile time.


Anti-Pattern 7: Reflection in AOT Contexts Without [DynamicallyAccessedMembers]

Native AOT compilation (and the trimmer used in self-contained .NET 10 apps) removes code that appears unreachable. Reflection-based access to types that aren't referenced statically will fail silently at runtime -- the members simply won't be there.

The bad pattern (no AOT annotations):

public object? CreateInstance(string typeName)
{
    var type = Type.GetType(typeName); // AOT: type might be trimmed away
    return Activator.CreateInstance(type!);
}

The fix -- annotate to preserve members:

public object? CreateInstance(
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
    Type type)
{
    return Activator.CreateInstance(type);
}

The [DynamicallyAccessedMembers] attribute tells the trimmer which members must be preserved for the type passed in. The .NET 10 toolchain will emit warnings during publish if you access members dynamically without the right annotations -- pay attention to those warnings, they will become runtime errors in trimmed or AOT-compiled apps.

For a deeper look at how frameworks handle this tradeoff in the real world, see Source Generation vs Reflection in Needlr.


Anti-Pattern 8: Using Reflection When Source Generators Solve It at Compile Time

This is arguably the most impactful anti-pattern. Many use cases that developers reach for reflection for -- serialization, DI registration, mapping, validation -- can be handled by source generators instead. Source generators run at compile time, produce plain C# code, and add zero runtime overhead.

Before writing reflection-based code, ask: "Does a source generator already solve this?"

If you're building a plugin system and using reflection to discover and load types, check out Plugin Architecture in C#: The Complete Guide to Extensible .NET Applications for a structured approach that minimizes raw reflection exposure.

The pattern to avoid:

// Discovering all IPlugin implementations by scanning types -- done at every startup
var plugins = Assembly.GetExecutingAssembly()
    .GetTypes()
    .Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsInterface)
    .Select(t => (IPlugin)Activator.CreateInstance(t)!)
    .ToList();

Better -- cache the result at app startup and consider if a DI framework with source generation covers your use case:

// Run once, cache the result. Or better: register via source generation or explicit code.
private static readonly IReadOnlyList<Type> PluginTypes = Assembly
    .GetExecutingAssembly()
    .GetTypes()
    .Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsInterface)
    .ToList();

The compiled delegate approach and the [UnsafeAccessor] approach both beat raw reflection once you need real throughput. Source generators beat both when you can move work to compile time.


FAQ

What are the most common C# reflection anti-patterns?

The most common anti-patterns are: calling GetProperties() or GetProperty() in hot paths without caching, using MethodInfo.Invoke where a compiled delegate would be faster, not handling TargetInvocationException correctly, and using reflection in AOT-compiled or trimmed apps without [DynamicallyAccessedMembers] annotations.

Does reflection always hurt performance in C#?

No. The expensive part of reflection is member lookup, not member access itself once you have a cached PropertyInfo or MethodInfo. Cached reflection is reasonably fast for initialization code, admin tooling, or low-frequency paths. It's the uncached use in tight loops that causes real problems.

When should I use [UnsafeAccessor] instead of reflection in .NET 10?

Use [UnsafeAccessor] when you need to access a specific private field or method and the type is known at compile time. It's AOT-safe, zero-overhead once JIT-compiled, and far cleaner than BindingFlags.NonPublic reflection. Reflection is still necessary when the type is truly unknown at compile time.

Is reflection compatible with Native AOT in .NET 10?

Partially. Reflection still works, but the AOT trimmer removes unreachable code. You must annotate reflection call sites with [DynamicallyAccessedMembers], use [RequiresUnreferencedCode] to document unsafe reflection, or replace the reflection entirely with source generators or explicit code. The .NET 10 publish toolchain surfaces warnings for unannotated reflection.

What is a compiled delegate in C# reflection?

A compiled delegate is a Func<> or Action<> created by building an expression tree with Expression.Lambda(...).Compile(). Once compiled, calling the delegate has near-native performance -- the JIT treats it like a regular method call. The compilation itself is expensive, so you compile once at startup and cache the result.

Can I use FrozenDictionary to speed up reflection caching in .NET 10?

Yes. FrozenDictionary<TKey, TValue> (from System.Collections.Frozen) is optimized for read-heavy workloads with no mutations after construction. If you build your Type -> PropertyInfo[] cache once at startup and never add to it again, FrozenDictionary typically outperforms ConcurrentDictionary for steady-state lookups because it can use perfect-hash lookup internally.

How do source generators replace reflection in C#?

Source generators run at compile time and emit C# code into your build. Instead of walking type metadata at runtime with GetProperties(), a source generator reads the same metadata during compilation and emits a static, strongly-typed implementation. The result is code that compiles down to direct property access -- zero reflection overhead, full AOT compatibility.


Summary

Reflection is a tool, not a crutch. Used carelessly, it introduces performance overhead, AOT incompatibility, and subtle bugs. The eight anti-patterns covered here -- hot-path lookups, uncached GetProperties(), late-bound invocation, over-broad BindingFlags, swallowed TargetInvocationException, unnecessary NonPublic hacks, missing AOT annotations, and ignoring source generators -- cover the majority of reflection problems you'll encounter in real .NET 10 codebases.

For each of them, the fix is straightforward: cache what you look up, compile what you call frequently, annotate what the trimmer needs to preserve, and replace what you can with compile-time code generation. Keep these patterns in mind and your reflection usage will be both powerful and maintainable.

C# Source Generators vs Reflection: Which Should You Use?

Compare C# source generators vs reflection in .NET 10. Understand performance differences, AOT compatibility, and when to choose source generators over runtime reflection.

How to Master the Art of Reflection in CSharp and Boost Your Programming Skills

Learn about reflection in CSharp and how it can be used. See how reflection in C# allows you to explore and modify objects, classes, and assemblies at runtime.

C# Reflection: The Complete .NET 10 Guide

Master C# reflection in .NET 10 -- learn Type, PropertyInfo, MethodInfo, performance caching with FrozenDictionary, and when to avoid reflection entirely.

An error has occurred. This application may no longer respond until reloaded. Reload