Home C# / .NET C# Reflection Explained — Internals, Performance & Real-World Patterns

C# Reflection Explained — Internals, Performance & Real-World Patterns

In Plain English 🔥
Imagine your app is a locked filing cabinet. Normally you open specific drawers you already know about. Reflection is like getting an X-ray machine — you can see every drawer, every folder, and every sheet of paper inside, even the ones you didn't know existed, and you can read or change them at runtime without a key. It's the CLR letting your code inspect and manipulate itself.
⚡ Quick Answer
Imagine your app is a locked filing cabinet. Normally you open specific drawers you already know about. Reflection is like getting an X-ray machine — you can see every drawer, every folder, and every sheet of paper inside, even the ones you didn't know existed, and you can read or change them at runtime without a key. It's the CLR letting your code inspect and manipulate itself.

Most C# code is written with full knowledge of the types it works with — you reference a class, call its methods, and the compiler keeps everything honest. But entire categories of powerful software — dependency injection containers, ORMs, serialisers, test frameworks, and plugin systems — work without knowing the types in advance. They discover, inspect, and invoke code at runtime. That capability is Reflection, and it's one of the most consequential APIs in the entire .NET ecosystem.

The problem Reflection solves is compile-time ignorance. When you're building a framework that loads user-supplied assemblies, or a serialiser that must handle any POCO ever written, you can't hard-code type knowledge. Reflection gives you a runtime mirror of the CLR's metadata — every assembly, every type, every method, field, property, and attribute — as a navigable object graph you can query and act on.

By the end of this article you'll understand exactly how the CLR stores and exposes metadata, how to walk the full reflection object model, how to invoke code dynamically with and without caching, how to write and read custom attributes, how to emit IL at runtime with lightweight techniques, where Reflection genuinely kills performance and how to fix it, and the production patterns that make Reflection safe to ship. This is the article you wish existed the first time Reflection bit you in production.

How the CLR Metadata System Actually Works Under the Hood

Every .NET assembly is a PE (Portable Executable) file. Alongside the IL bytecode, the compiler embeds a rich metadata section — tables of type definitions, method signatures, field layouts, custom attributes, and inter-assembly references. This is the same data Visual Studio uses for IntelliSense. Reflection is simply a managed API that reads those tables at runtime.

The entry point is always System.Type. It's an abstract class whose concrete implementation, System.RuntimeType, is created and cached by the CLR the first time a type is loaded into an AppDomain. That means Type.GetType() calls after the first one are cheap — you're reading a cached CLR object, not re-parsing the binary.

The hierarchy goes: AppDomain → Assembly → Module → Type → MemberInfo (MethodInfo, FieldInfo, PropertyInfo, ConstructorInfo, EventInfo). Every one of these is an object you can hold a reference to, compare, and pass around. MethodInfo.Invoke() ultimately calls into native CLR code that performs a late-bound dispatch — it finds the compiled JIT stub for the method and calls it, bypassing compile-time binding but still executing fully-compiled IL.

Understanding this model matters because it tells you exactly where costs live: type lookup is cheap after the first load, MemberInfo retrieval has moderate overhead (array allocation), and Invoke itself is expensive — roughly 50-100x slower than a direct call — because of argument boxing, security checks, and the late-bind dispatch mechanism.

ReflectionMetadataExplorer.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
using System;
using System.Reflection;
using System.Linq;

// A realistic domain class we'll inspect at runtime
public class OrderProcessor
{
    private readonly string _regionCode;

    public int ProcessedCount { get; private set; }

    public OrderProcessor(string regionCode)
    {
        _regionCode = regionCode;
    }

    public decimal CalculateTotal(decimal subtotal, decimal taxRate)
    {
        ProcessedCount++;
        return subtotal + (subtotal * taxRate);
    }

    private void ResetCounters() => ProcessedCount = 0;
}

class ReflectionMetadataExplorer
{
    static void Main()
    {
        // GetType() on a live instance is the fastest path — no string lookup
        Type orderProcessorType = typeof(OrderProcessor);

        Console.WriteLine($"Full name   : {orderProcessorType.FullName}");
        Console.WriteLine($"Assembly    : {orderProcessorType.Assembly.GetName().Name}");
        Console.WriteLine($"Is abstract : {orderProcessorType.IsAbstract}");
        Console.WriteLine($"Base type   : {orderProcessorType.BaseType?.Name}");
        Console.WriteLine();

        // GetMembers with BindingFlags to include private and instance members
        // Without these flags you only get public instance members — a common trap
        BindingFlags allMembers = BindingFlags.Public | BindingFlags.NonPublic
                                | BindingFlags.Instance | BindingFlags.Static;

        Console.WriteLine("=== ALL MEMBERS ===");
        foreach (MemberInfo member in orderProcessorType.GetMembers(allMembers)
                                                         .OrderBy(m => m.MemberType.ToString()))
        {
            // MemberType tells you Constructor / Field / Method / Property / Event
            Console.WriteLine($"  [{member.MemberType,-12}] {member.Name}");
        }

        Console.WriteLine();
        Console.WriteLine("=== METHOD SIGNATURES ===");

        // GetMethods returns MethodInfo[], each carrying full parameter metadata
        foreach (MethodInfo method in orderProcessorType.GetMethods(allMembers)
                                                         .Where(m => !m.IsSpecialName)) // skip get_/set_ accessors
        {
            string parameters = string.Join(", ",
                method.GetParameters()
                      .Select(p => $"{p.ParameterType.Name} {p.Name}"));

            string visibility = method.IsPublic ? "public" : "private";
            Console.WriteLine($"  {visibility} {method.ReturnType.Name} {method.Name}({parameters})");
        }
    }
}
▶ Output
Full name : OrderProcessor
Assembly : ReflectionDemo
Is abstract : False
Base type : Object

=== ALL MEMBERS ===
[Constructor ] .ctor
[Field ] _regionCode
[Field ] <ProcessedCount>k__BackingField
[Method ] CalculateTotal
[Method ] Equals
[Method ] Finalize
[Method ] GetHashCode
[Method ] GetType
[Method ] MemberwiseClone
[Method ] ResetCounters
[Method ] ToString
[Method ] get_ProcessedCount
[Method ] set_ProcessedCount
[Property ] ProcessedCount

=== METHOD SIGNATURES ===
public Decimal CalculateTotal(Decimal subtotal, Decimal taxRate)
public Boolean Equals(Object obj)
public Int32 GetHashCode()
public Type GetType()
public String ToString()
private Void ResetCounters()
⚠️
Watch Out: The Compiler-Generated Backing Field NameWhen you reflect over an auto-property like `public int ProcessedCount { get; private set; }`, the CLR generates a backing field named `k__BackingField`. If you're trying to set it directly via FieldInfo.SetValue() (a common serialiser trick), search for it by that exact pattern — don't assume the field name matches the property name.

Dynamic Invocation, Custom Attributes, and Caching for Production Use

Raw Reflection.Invoke() is the sledgehammer — powerful but slow. In production you have two main upgrade paths: cache MemberInfo objects so you avoid repeated metadata lookups, and use compiled delegates or expression trees to turn a one-time reflection cost into a near-zero per-call cost.

Custom attributes are where Reflection really earns its keep in frameworks. By decorating types and members with attributes you create a declarative metadata layer — think [Required], [HttpGet], or your own [AuditLog]. Reflection reads those attributes at startup to build routing tables, validation rules, or processing pipelines without any hard-coded type knowledge.

The pattern that scales: do your reflection work once at application startup, compile it into Func<> delegates via Expression.Compile() or Delegate.CreateDelegate(), then call those delegates at request time. The delegate call is indistinguishable from a direct call in the JIT — no boxing overhead, no late-bind dispatch.

Below is a realistic mini-framework that discovers all types decorated with a custom [CommandHandler] attribute, builds a dispatch table from command name to handler delegate, and invokes handlers at near-native speed — the same pattern used by MediatR, minimal API routing, and plugin systems.

CommandDispatcher.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

// ── Custom attribute that marks a class as a command handler ──────────────────
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class CommandHandlerAttribute : Attribute
{
    public string CommandName { get; }
    public CommandHandlerAttribute(string commandName) => CommandName = commandName;
}

// ── Handler contract ──────────────────────────────────────────────────────────
public interface ICommandHandler
{
    string Execute(string payload);
}

// ── Two handlers discovered purely via reflection — no registration code ──────
[CommandHandler("greet")]
public class GreetHandler : ICommandHandler
{
    public string Execute(string payload) => $"Hello, {payload}!";
}

[CommandHandler("shout")]
public class ShoutHandler : ICommandHandler
{
    public string Execute(string payload) => payload.ToUpperInvariant() + "!!";
}

// ── The dispatcher — built once, invoked thousands of times ───────────────────
public class CommandDispatcher
{
    // Stores pre-compiled factory delegates: () => ICommandHandler
    // Using Func<ICommandHandler> means we get a fresh handler instance per call
    // (swap for a singleton if your handlers are stateless and thread-safe)
    private readonly Dictionary<string, Func<ICommandHandler>> _handlerFactories
        = new(StringComparer.OrdinalIgnoreCase);

    public CommandDispatcher(Assembly searchAssembly)
    {
        // ── STARTUP COST: do all reflection here, once ────────────────────────
        foreach (Type candidateType in searchAssembly.GetTypes())
        {
            // IsDefined is cheaper than GetCustomAttribute when you only need presence
            if (!candidateType.IsClass || candidateType.IsAbstract)
                continue;

            CommandHandlerAttribute? attr =
                candidateType.GetCustomAttribute<CommandHandlerAttribute>();

            if (attr is null) continue;

            // Verify the type actually implements ICommandHandler at startup
            // Fail fast here rather than at dispatch time
            if (!typeof(ICommandHandler).IsAssignableFrom(candidateType))
                throw new InvalidOperationException(
                    $"{candidateType.Name} has [CommandHandler] but doesn't implement ICommandHandler");

            // ── Build a compiled factory: Expression<Func<ICommandHandler>> ──
            // This is the key performance trick. We use Expression trees to emit
            // a delegate that calls `new GreetHandler()` directly — no Activator,
            // no boxing, no reflection at invocation time.
            ConstructorInfo ctor = candidateType.GetConstructor(Type.EmptyTypes)
                ?? throw new InvalidOperationException(
                    $"{candidateType.Name} needs a parameterless constructor");

            // NewExpression represents `new T()` in the expression tree
            NewExpression newExpression = Expression.New(ctor);

            // Cast to ICommandHandler so the delegate type is uniform
            UnaryExpression castExpression =
                Expression.Convert(newExpression, typeof(ICommandHandler));

            // Compile into an actual delegate — this is the one-time JIT cost
            Func<ICommandHandler> factory =
                Expression.Lambda<Func<ICommandHandler>>(castExpression).Compile();

            _handlerFactories[attr.CommandName] = factory;

            Console.WriteLine($"[Startup] Registered '{attr.CommandName}' → {candidateType.Name}");
        }
    }

    public string Dispatch(string commandName, string payload)
    {
        if (!_handlerFactories.TryGetValue(commandName, out Func<ICommandHandler>? factory))
            return $"Unknown command: {commandName}";

        // This is now essentially a direct constructor call + interface dispatch
        // Zero reflection overhead here — just a compiled delegate invocation
        ICommandHandler handler = factory();
        return handler.Execute(payload);
    }
}

class Program
{
    static void Main()
    {
        // All reflection happens here — one time, at startup
        var dispatcher = new CommandDispatcher(Assembly.GetExecutingAssembly());

        Console.WriteLine();
        Console.WriteLine("=== DISPATCHING COMMANDS ===");

        // These calls have no reflection overhead — just compiled delegate + vtable dispatch
        Console.WriteLine(dispatcher.Dispatch("greet", "World"));
        Console.WriteLine(dispatcher.Dispatch("shout", "this works"));
        Console.WriteLine(dispatcher.Dispatch("unknown", "anything"));
    }
}
▶ Output
[Startup] Registered 'greet' → GreetHandler
[Startup] Registered 'shout' → ShoutHandler

=== DISPATCHING COMMANDS ===
Hello, World!
THIS WORKS!!
Unknown command: unknown
⚠️
Pro Tip: Expression.Compile() vs Delegate.CreateDelegate()For simple instance methods that match a known delegate signature, Delegate.CreateDelegate() is faster to set up than building an Expression tree and compiling it — use it when the method signature is fixed. Use Expression trees when you need to adapt signatures, coerce types, or construct objects. Both produce near-native invocation speed.

Reflection Performance — Benchmarks, Bottlenecks, and the Source Generator Alternative

The performance story of Reflection has two distinct chapters: .NET Framework (slow, always) and modern .NET (much better, but still has sharp edges). On .NET 7+ the JIT can devirtualise some reflection calls and the metadata reader is significantly faster, but MethodInfo.Invoke() still carries boxing overhead for value types and a security stack-walk on first invocation.

Here's what actually costs time, ranked: (1) Assembly scanning with GetTypes() — O(n) where n is total types, can be tens of milliseconds for large assemblies; (2) GetCustomAttributes() — allocates an array on every call if not cached; (3) MethodInfo.Invoke() — approximately 50-100ns per call vs ~1ns for a direct call; (4) Activator.CreateInstance() — slower than a direct constructor but faster than the non-generic overload due to avoided boxing.

The modern answer for hot-path scenarios is Source Generators. Introduced in .NET 5, they run at compile time and generate the type-inspection code that Reflection would have run at runtime. System.Text.Json switched from Reflection to Source Generators in .NET 6 and saw 2-3x serialisation throughput improvements. If you're writing a library that does Reflection-heavy work, offering a Source Generator path is now table-stakes for performance-sensitive users.

For scenarios where you can't use Source Generators — runtime plugin loading, for example — the cached-delegate pattern shown earlier is your best tool. The benchmark below makes the cost differences concrete.

ReflectionPerformanceBenchmark.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Requires: dotnet add package BenchmarkDotNet
// Run with: dotnet run -c Release

using System;
using System.Linq.Expressions;
using System.Reflection;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

public class PriceCalculator
{
    public decimal ApplyDiscount(decimal price, decimal discountRate)
        => price * (1m - discountRate);
}

[MemoryDiagnoser]   // shows allocations per operation
[RankColumn]        // adds a rank column so differences are obvious
public class InvocationBenchmarks
{
    private readonly PriceCalculator _calculator = new();

    // ── Pre-fetched and cached — this is what good code does ─────────────────
    private readonly MethodInfo _cachedMethodInfo;
    private readonly Func<PriceCalculator, decimal, decimal, decimal> _compiledDelegate;

    public InvocationBenchmarks()
    {
        // Cache MethodInfo once — reuse thousands of times
        _cachedMethodInfo = typeof(PriceCalculator)
            .GetMethod(nameof(PriceCalculator.ApplyDiscount))!;

        // Build and compile an Expression tree delegate once
        // Signature: (PriceCalculator instance, decimal price, decimal discountRate) => decimal
        ParameterExpression instanceParam   = Expression.Parameter(typeof(PriceCalculator), "instance");
        ParameterExpression priceParam      = Expression.Parameter(typeof(decimal), "price");
        ParameterExpression discountParam   = Expression.Parameter(typeof(decimal), "discountRate");

        MethodCallExpression callExpression = Expression.Call(
            instanceParam, _cachedMethodInfo, priceParam, discountParam);

        _compiledDelegate = Expression
            .Lambda<Func<PriceCalculator, decimal, decimal, decimal>>(
                callExpression, instanceParam, priceParam, discountParam)
            .Compile();
    }

    [Benchmark(Baseline = true)]
    public decimal DirectCall()
        // This is the gold standard — what we compare everything else to
        => _calculator.ApplyDiscount(100m, 0.1m);

    [Benchmark]
    public decimal CachedMethodInfoInvoke()
        // Uses pre-fetched MethodInfo — avoids lookup overhead, still pays boxing cost
        // decimal args get boxed into object[] here — that's the main remaining cost
        => (decimal)_cachedMethodInfo.Invoke(_calculator, new object[] { 100m, 0.1m })!;

    [Benchmark]
    public decimal UncachedMethodInfoInvoke()
        // Worst case: reflects AND invokes on every call — never do this in a loop
        => (decimal)typeof(PriceCalculator)
            .GetMethod(nameof(PriceCalculator.ApplyDiscount))!
            .Invoke(_calculator, new object[] { 100m, 0.1m })!;

    [Benchmark]
    public decimal CompiledExpressionDelegate()
        // Best of both worlds: discovered via reflection once, executes like a direct call
        // No boxing, no late-bind overhead — the JIT sees this as a regular delegate call
        => _compiledDelegate(_calculator, 100m, 0.1m);
}

class Program
{
    static void Main() => BenchmarkRunner.Run<InvocationBenchmarks>();
}
▶ Output
| Method | Mean | Ratio | Allocated | Rank |
|---------------------------|------------|--------|-----------|------|
| DirectCall | 0.45 ns | 1.00 | 0 B | 1 |
| CompiledExpressionDelegate| 1.10 ns | 2.44 | 0 B | 2 |
| CachedMethodInfoInvoke | 68.20 ns | 151.6 | 96 B | 3 |
| UncachedMethodInfoInvoke | 412.50 ns | 916.7 | 312 B | 4 |
🔥
Interview Gold: Why Does CachedMethodInfoInvoke Still Allocate?Even with a pre-fetched MethodInfo, Invoke() takes an `object[]` parameter array. Passing value types like `decimal` causes boxing — wrapping the value in a heap-allocated object. That's the 96 bytes you see in the benchmark. The compiled delegate avoids this entirely because it's strongly typed — the JIT passes decimals as value types on the stack, just like a direct call.

Production Gotchas — Private Members, Security, AOT Compatibility, and Thread Safety

Reflection in production has a few traps that only reveal themselves at scale or in unusual deployment environments. Let's cover the ones that actually hurt teams.

Private member access works fine in standard .NET but is restricted in Ahead-of-Time (AOT) compiled apps (.NET NativeAOT, Blazor WASM in full AOT mode, and iOS/Android with Xamarin/MAUI). AOT strips metadata for private members by default to reduce binary size. If your serialiser or DI container tries to set a private field, it silently fails or throws. The fix is rd.xml trim directives (for older platforms) or the newer [DynamicallyAccessedMembers] attribute, which tells the trimmer exactly what metadata to preserve.

Thread safety: Type and MemberInfo objects themselves are thread-safe to read — the CLR guarantees that. But if you're building a Dictionary discovery cache, make sure you use ConcurrentDictionary or initialise it once before any concurrent access. The bug pattern is a lazy-initialised static dictionary populated in a static constructor that gets hit from multiple threads — the static constructor is thread-safe, but populating a regular Dictionary after construction is not.

Generic type reflection adds a wrinkle: typeof(List<>) gives you the open generic type definition. You need Type.MakeGenericType() to get List at runtime. And GetMethod() on a generic type requires you to filter by parameter count and then check IsGenericMethodDefinition — GetMethod by name alone will throw AmbiguousMatchException if there are overloads.

ProductionReflectionPatterns.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Reflection;
using System.Diagnostics.CodeAnalysis;

// ── Pattern 1: Thread-safe lazy discovery cache ───────────────────────────────
public static class TypeMetadataCache
{
    // ConcurrentDictionary is safe for concurrent reads AND writes
    // Regular Dictionary with concurrent writes causes data corruption — not just exceptions
    private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propertyCache = new();

    // GetOrAdd is atomic: if two threads race, only one factory runs, one result is stored
    public static PropertyInfo[] GetPublicProperties(Type targetType)
        => _propertyCache.GetOrAdd(targetType,
            t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance));
}

// ── Pattern 2: AOT-safe reflection with [DynamicallyAccessedMembers] ──────────
public static class SafeActivator
{
    // The [DynamicallyAccessedMembers] attribute tells the IL trimmer:
    // "keep the parameterless constructor for whatever type T resolves to"
    // Without this, NativeAOT / Blazor WASM AOT may trim the constructor away
    public static T CreateInstance<
        [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>()
        where T : new()
    {
        return new T(); // Activator.CreateInstance<T>() would also work here
    }
}

// ── Pattern 3: Generic type construction at runtime ───────────────────────────
public static class GenericTypeBuilder
{
    public static object CreateGenericList(Type elementType)
    {
        // typeof(List<>) is the open generic definition — List with no type args
        Type openListType = typeof(List<>);

        // MakeGenericType closes the generic: List<> + string → List<string>
        Type closedListType = openListType.MakeGenericType(elementType);

        // Now we can construct it — Activator is fine here since it's a one-time setup
        return Activator.CreateInstance(closedListType)
               ?? throw new InvalidOperationException("Could not create list instance");
    }

    public static void AddItemToList(object list, object item)
    {
        // list is a List<T> for some T — we need to find Add(T item)
        // GetMethod("Add") would throw AmbiguousMatchException for types with overloads
        // Safe approach: filter by name AND parameter count
        Type listType = list.GetType();

        MethodInfo addMethod = Array.Find(
            listType.GetMethods(),
            m => m.Name == "Add" && m.GetParameters().Length == 1)
            ?? throw new InvalidOperationException("No Add(T) method found");

        addMethod.Invoke(list, new[] { item });
    }
}

// ── Pattern 4: Reflecting over generic method definitions ─────────────────────
public class DataConverter
{
    public T Convert<T>(string input) where T : IParsable<T>
        => T.Parse(input, null);

    public static void DemonstrateGenericMethodReflection()
    {
        Type converterType = typeof(DataConverter);

        // GetMethod with just the name works here because there's only one overload
        // But if overloads existed, we'd use GetMethods().Where(m => m.IsGenericMethodDefinition)
        MethodInfo openGenericMethod = converterType.GetMethod("Convert")
            ?? throw new MissingMethodException("Convert method not found");

        Console.WriteLine($"Is generic method def: {openGenericMethod.IsGenericMethodDefinition}");

        // Close the generic by specifying the type argument: Convert<int>
        MethodInfo closedMethod = openGenericMethod.MakeGenericMethod(typeof(int));

        DataConverter instance = new DataConverter();
        object result = closedMethod.Invoke(instance, new object[] { "42" })!;
        Console.WriteLine($"Converted result: {result} (type: {result.GetType().Name})");
    }
}

class Program
{
    static void Main()
    {
        // Thread-safe cache demo
        PropertyInfo[] props1 = TypeMetadataCache.GetPublicProperties(typeof(DateTime));
        PropertyInfo[] props2 = TypeMetadataCache.GetPublicProperties(typeof(DateTime)); // cache hit
        Console.WriteLine($"DateTime properties found: {props1.Length} (both calls same array: {ReferenceEquals(props1, props2)})");

        // Generic type construction
        object stringList = GenericTypeBuilder.CreateGenericList(typeof(string));
        GenericTypeBuilder.AddItemToList(stringList, "hello");
        GenericTypeBuilder.AddItemToList(stringList, "world");
        Console.WriteLine($"List type: {stringList.GetType().Name}, contents: {string.Join(", ", (List<string>)stringList)}");

        // Generic method reflection
        DataConverter.DemonstrateGenericMethodReflection();
    }
}
▶ Output
DateTime properties found: 18 (both calls same array: True)
List type: List`1, contents: hello, world
Is generic method def: True
Converted result: 42 (type: Int32)
⚠️
Watch Out: Reflection + .NET Native AOT = Silent Data LossIn NativeAOT and Blazor WASM AOT builds, the IL trimmer removes metadata it can't statically prove is needed. GetProperties() or GetFields() on a type that isn't annotated will return an empty array — no exception, just empty results. Annotate all reflection entry points with [DynamicallyAccessedMembers] and run `dotnet publish -r --self-contained` locally to catch trim warnings before they reach production.
Invocation StrategyPer-Call SpeedAllocationsSetup CostAOT SafeBest Used When
Direct method call~0.5 ns (baseline)0 BNoneYesYou know the type at compile time
Compiled Expression delegate~1-2 ns0 BOne-time compile (~1ms)Partial*Hot path, type unknown at compile time
Delegate.CreateDelegate()~1-2 ns0 BNegligibleYes (public)Simple method with known delegate signature
Cached MethodInfo.Invoke()~68 ns~96 BOne-time GetMethod()NoModerate frequency, value type args acceptable
Uncached MethodInfo.Invoke()~412 ns~312 BNone (paid per call)NoNever in hot paths — acceptable for tools/utils
Activator.CreateInstance()~15 nsObject size onlyNoneYes (with annotation)Creating instances of known interface
Source Generator~0.5 ns0 BBuild-time codegenYesLibrary authors, serialisation, DI containers

🎯 Key Takeaways

  • Reflection is a runtime API over CLR metadata — Type objects are cached by the runtime after first load, so typeof() and GetType() are cheap, but GetCustomAttributes() and Invoke() allocate on every call if not handled carefully.
  • The performance cliff is MethodInfo.Invoke() for value-type arguments — boxing turns a stack operation into a heap allocation. Compiled Expression delegates eliminate this entirely and benchmark at near-direct-call speed.
  • Do all reflection work at startup (assembly scanning, attribute reading, MethodInfo resolution) and compile the results into delegates — then your hot path has zero reflection overhead, just delegate invocations.
  • NativeAOT and IL trimming are the new production minefield for Reflection — always annotate with [DynamicallyAccessedMembers], validate with a trimmed publish, and consider Source Generators for library code that needs both reflection-style flexibility and AOT compatibility.

⚠ Common Mistakes to Avoid

  • Mistake 1: Calling GetType().GetMethod() inside a loop — Every GetMethod() call traverses the metadata tables and allocates a new MethodInfo object — in a tight loop processing thousands of objects this burns CPU and creates GC pressure. The exact symptom is high Gen0 GC collections and unexplained CPU spikes in profiling. Fix: call GetMethod() once before the loop, store the MethodInfo in a local variable, and invoke the cached reference inside the loop.
  • Mistake 2: Forgetting BindingFlags when accessing non-public members — GetMethod("ResetCounters") returns null if the method is private, with zero indication of why. Developers waste hours thinking the method doesn't exist. The fix is always explicit: BindingFlags.Instance | BindingFlags.NonPublic for private instance members. Make it a habit to always pass explicit BindingFlags — never rely on the default (public instance only).
  • Mistake 3: Using Reflection in a NativeAOT or trimmed publish without trim annotations — The trimmer silently removes metadata, so GetProperties() returns an empty array and GetMethod() returns null even for existing methods. There's no runtime exception to guide you. Fix: add [DynamicallyAccessedMembers] attributes to all reflection entry points, enable trim warnings in your csproj with or true, and validate by doing a test publish with --self-contained before shipping.

Interview Questions on This Topic

  • QWhat's the performance difference between a cached MethodInfo.Invoke() call and a compiled Expression tree delegate, and why does the difference exist at the IL/JIT level?
  • QHow does [DynamicallyAccessedMembers] interact with the IL trimmer, and what happens if you omit it in a NativeAOT-published application that uses Reflection?
  • QIf you're building a plugin system that loads external assemblies at runtime and calls methods on discovered types, walk me through the full approach: how you'd discover types, handle versioning mismatches, ensure thread safety on the discovery cache, and keep invocation overhead low.

Frequently Asked Questions

Is C# Reflection slow and should I avoid it in production?

It depends entirely on how you use it. MethodInfo.Invoke() in a hot loop is slow — roughly 100x a direct call with value-type boxing overhead. But if you cache MemberInfo objects and compile Expression tree delegates at startup, per-call overhead drops to near zero. Modern production frameworks like ASP.NET Core and Entity Framework use Reflection extensively — they just front-load the cost at startup.

What's the difference between Type.GetType(string) and typeof(T)?

typeof(T) is resolved at compile time by the C# compiler — it emits a ldtoken IL instruction and costs essentially nothing at runtime. Type.GetType(string) performs a runtime string lookup across loaded assemblies, is significantly slower, and returns null (not an exception) if the type isn't found. Always prefer typeof() when you know the type at compile time.

Can I use Reflection to access private methods and fields in C#?

Yes, using BindingFlags.NonPublic in your GetMethod() or GetField() call will return private members. However, this is fragile — private members are implementation details with no stability guarantee between versions — and it's blocked by default in partial-trust environments and some AOT deployment targets. It's legitimate for unit testing internals or writing serialisers, but never rely on it in library code that targets unknown environments.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousIndexers in C#Next →Attributes in C#
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged