📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does reflection work in .NET and what does it cost? Explain Type, MemberInfo and BindingFlags, why MethodInfo.Invoke is slow, how to make reflection fast (cached delegates, MethodInvoker in .NET 8, UnsafeAccessor), and why reflection breaks trimming and Native AOT unless you annotate it.

Reflection APIs, their run-time cost, faster alternatives in .NET 8+, and trimming/AOT annotations.

Senior Theory Advanced native-aot performance reflection trimming unsafe-accessor

🎯 Key points

  • Reflection reads assembly metadata at run time through Type, MemberInfo and BindingFlags
  • Lookups allocate and search metadata, so cache MemberInfo objects per type
  • MethodInfo.Invoke allocates an object array, boxes value types and wraps exceptions in TargetInvocationException
  • Typed delegates from CreateDelegate and .NET 8 MethodInvoker make repeated calls fast
  • UnsafeAccessor (.NET 8) reaches private members with no run-time reflection and is AOT safe
  • The trimmer cannot see string-based member access; annotate with DynamicallyAccessedMembers or RequiresUnreferencedCode
  • Reflection.Emit cannot generate code under Native AOT
  • Prefer source generators when the shapes are known at compile time

⏱ 30-second answer

Reflection reads the metadata the compiler wrote into an assembly at run time: typeof(T) or obj.GetType() gives a Type, and from it you get PropertyInfo, MethodInfo, FieldInfo and so on, filtered by BindingFlags. It is flexible but slow on hot paths: lookups search metadata, Invoke takes an object[], boxes value types, checks arguments and wraps exceptions in TargetInvocationException. Make it fast by looking up once and caching, turning a MethodInfo into a typed delegate, using MethodInvoker (.NET 8) when you only have a MethodInfo, or [UnsafeAccessor] (.NET 8) to reach a private member with no reflection at all. Reflection also hides dependencies from the trimmer, so in trimmed or Native AOT apps you must annotate with [DynamicallyAccessedMembers] or move the work to a source generator.

🗣 Full answer (senior)

How I would answer this in an interview

Reflection lets code inspect and use type metadata at run time: discover types, members and attributes, create instances, read/write properties and invoke methods by name. It's how DI containers, serialisers, ORMs, test frameworks and model binding historically worked. It's powerful but slow when used naively, and it fundamentally conflicts with trimming and NativeAOT, because the tools can't see which members you'll access by name at run time.

1. The building blocks

Type t = typeof(Order);                          // or obj.GetType(), Type.GetType("Ns.Order, Assembly")
PropertyInfo? total = t.GetProperty("Total", BindingFlags.Public | BindingFlags.Instance);
MethodInfo? submit = t.GetMethod("Submit");
ConstructorInfo? ctor = t.GetConstructor([typeof(Guid)]);

object order = ctor!.Invoke([Guid.NewGuid()]);
decimal value = (decimal)total!.GetValue(order)!;
submit!.Invoke(order, null);

var attrs = t.GetCustomAttributes<AuditedAttribute>();
  • Type: the entry point (IsClass, IsGenericType, GetInterfaces(), BaseType, GetGenericArguments()).
  • MemberInfo hierarchy: FieldInfo, PropertyInfo, MethodInfo, ConstructorInfo, EventInfo.
  • BindingFlags: filters — Public/NonPublic, Instance/Static, DeclaredOnly, IgnoreCase, FlattenHierarchy. Forgetting Instance or Static returns nothing (a common bug).
  • Assembly: GetTypes(), GetExportedTypes(), used for scanning (registering handlers).

2. Why MethodInfo.Invoke is slow

A direct call is a few nanoseconds. Invoke historically took hundreds of nanoseconds or more because it:

  • validates arguments and the target type,
  • allocates an object[] for arguments and boxes value-type arguments and return values,
  • performs security/accessibility checks,
  • goes through a generic invocation stub (unless the runtime generates an optimised one after repeated calls),
  • wraps exceptions in TargetInvocationException.

Lookups themselves (GetProperty, GetMethod, GetCustomAttributes) also cost string comparisons and allocations — cache MemberInfo objects.

.NET 7/8 made reflection invocation much faster (emitting IL stubs after a few calls), but it's still far slower than direct calls and allocates.

3. Making reflection fast

  1. Cache Type/MemberInfo lookups (static dictionaries or generic static caches).
  2. Create delegates once and call them:
var getter = (Func<Order, decimal>)Delegate.CreateDelegate(typeof(Func<Order, decimal>), total!.GetGetMethod()!);
decimal v = getter(order);   // near-direct speed

(Or compile expression trees once — but that needs runtime code generation.) 3. MethodInvoker / ConstructorInvoker (.NET 8): MethodInvoker.Create(methodInfo) gives a reusable invoker with overloads that avoid object[] allocation for up to four arguments and skip repeated validation. 4. UnsafeAccessor (.NET 8): declare an extern method with [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_secret")] and the runtime binds it directly to a private member — no reflection at run time, AOT- and trim-friendly, essentially direct-call speed. Great for serialisers and tests needing private access (use sparingly; it bypasses encapsulation). 5. Best of all, source generators that write the code at compile time (System.Text.Json source generation, logging, DI registration).

4. Why reflection breaks trimming and NativeAOT

  • Trimming (ILLink) removes unused types and members by static analysis. If code does Type.GetType("MyApp.Plugins." + name) or GetProperty(propertyName), the trimmer can't know what's needed and may remove it → null/MissingMethodException at run time.
  • NativeAOT compiles everything ahead of time: no JIT, so Reflection.Emit, Expression.Compile() (falls back to interpretation), and MakeGenericType with unknown value-type instantiations may fail or be slow.
  • The SDK reports trim/AOT warnings (IL2026, IL2070, IL2075, IL3050) for reflection that can't be analysed.

Annotate to make reflection analysable:

public static object Create([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type)
    => Activator.CreateInstance(type)!;

[RequiresUnreferencedCode("Uses reflection over arbitrary types")]
[RequiresDynamicCode("Builds generic types at run time")]
public static void LegacyScan(...) { ... }

[DynamicallyAccessedMembers] tells the trimmer to keep certain members of types flowing into that parameter; [RequiresUnreferencedCode]/[RequiresDynamicCode] propagate warnings to callers; [DynamicDependency] keeps specific members. Ultimately, the AOT-friendly approach is to replace runtime reflection with compile-time generation.

5. A production example

A custom mapper used PropertyInfo.GetValue/SetValue per property per object, mapping millions of rows in a nightly job; profiling showed most CPU in reflection and boxing. Caching compiled getter/setter delegates per type cut the job time by ~80%. Later, moving the API to NativeAOT surfaced IL2075 warnings for the mapper; we replaced it with Mapperly (source-generated), which removed both the reflection cost and the AOT warnings.

🔍 What the interviewer is checking

The interviewer wants to know whether you understand what reflection costs and how modern .NET reduces or removes it.

Green flags

  • Separates lookup cost from invoke cost and caches lookups.
  • Knows Invoke boxes, allocates and wraps exceptions.
  • Mentions typed delegates, MethodInvoker or UnsafeAccessor (.NET 8).
  • Raises trimming and Native AOT, and knows [DynamicallyAccessedMembers].
  • Suggests a source generator when the types are known at build time.

Red flags

  • "Reflection is just slow, never use it" with no nuance.
  • Does not know why GetMethod returns null for private members.
  • Suppresses trimming warnings to make the build pass.

📚 Learn in depth

1. Metadata and runtime type info

Assemblies contain metadata tables (TypeDef, MethodDef, FieldDef, CustomAttribute...). Reflection reads these via the runtime's type loader, creating RuntimeType, RuntimeMethodInfo etc., which are cached by the runtime. GetCustomAttributes instantiates attribute objects each time (allocations) — cache results or use IsDefined for checks.

2. Reflection invocation internals (.NET 8+)

  • First few calls use an interpreted path; after a threshold, the runtime emits an IL stub (using DynamicMethod) specialised for the signature, making subsequent calls faster.
  • MethodInvoker exposes this optimised path directly, with Invoke(object? obj, object? arg1, ...) overloads avoiding the args array.
  • Under NativeAOT, invocation uses precomputed stubs for methods kept by the compiler; reflection over methods not kept fails.

3. UnsafeAccessor details

static class OrderAccessors
{
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_lines")]
    public static extern ref List<OrderLine> Lines(Order order);

    [UnsafeAccessor(UnsafeAccessorKind.Constructor)]
    public static extern Order Create(Guid id);
}

The JIT/AOT compiler resolves the accessor to the target member at compile/JIT time; there's no runtime lookup. It works with private members, fields (returned by ref), methods, constructors and static members. Since .NET 9, generic support improved. It's statically analysable, so trimming keeps the targets.

4. Delegates vs expression compilation

Technique Speed per call Setup cost AOT-friendly
PropertyInfo.GetValue Slow (boxing) Low Yes (if members kept)
Delegate.CreateDelegate (open instance) Near direct Low Yes
Expression.Compile() Near direct High No (interpreted)
MethodInvoker Fast Low Mostly
UnsafeAccessor Direct None Yes
Source generators Direct Build time Yes

5. Common reflection uses and modern replacements

Use Modern approach
JSON serialisation System.Text.Json source generation (JsonSerializerContext)
DI assembly scanning Source-generated registrations or explicit registration
Mapping Mapperly / manual mapping
Logging message parsing [LoggerMessage]
Enum names/attributes Generic static caches or source generators
Private access in tests UnsafeAccessor / InternalsVisibleTo
Plugin loading AssemblyLoadContext + interfaces (still reflection-based; not AOT)

Try it yourself

  1. Benchmark direct property access, GetValue, a cached delegate, MethodInvoker and UnsafeAccessor.
  2. Call GetProperty without BindingFlags.Instance and observe null.
  3. Publish a small app with <PublishTrimmed>true</PublishTrimmed> that uses Type.GetType(stringFromConfig) and read the warnings/failure.
  4. Replace a reflection-based mapper with Mapperly and compare.

💻 Code

using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;

public sealed class Invoice
{
    private decimal _discount = 0.1m;
    public decimal Total { get; set; } = 100m;
    private decimal ApplyDiscount(decimal amount) => amount * (1 - _discount);
}

public static class InvoiceAccessors
{
    // .NET 8+: bound by the JIT, no reflection at run time, Native AOT safe
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_discount")]
    public static extern ref decimal Discount(Invoice invoice);

    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ApplyDiscount")]
    public static extern decimal ApplyDiscount(Invoice invoice, decimal amount);
}

public static class Program
{
    // Look up once, reuse forever
    private static readonly MethodInfo ApplyMethod =
        typeof(Invoice).GetMethod("ApplyDiscount", BindingFlags.Instance | BindingFlags.NonPublic)!;

    private static readonly Func<Invoice, decimal> GetTotal =
        typeof(Invoice).GetProperty(nameof(Invoice.Total))!.GetGetMethod()!.CreateDelegate<Func<Invoice, decimal>>();

    private static readonly MethodInvoker ApplyInvoker = MethodInvoker.Create(ApplyMethod);

    public static void Main()
    {
        var invoice = new Invoice();

        // 1. Classic reflection: object[] allocation, boxing, TargetInvocationException wrapping
        Console.WriteLine(ApplyMethod.Invoke(invoice, [50m]));      // 45.0

        // 2. Typed delegate: close to a direct call
        Console.WriteLine(GetTotal(invoice));                        // 100

        // 3. MethodInvoker (.NET 8): no argument array
        Console.WriteLine(ApplyInvoker.Invoke(invoice, 50m));        // 45.0

        // 4. UnsafeAccessor: write a private field, call a private method
        InvoiceAccessors.Discount(invoice) = 0.2m;
        Console.WriteLine(InvoiceAccessors.ApplyDiscount(invoice, 50m)); // 40.0

        Console.WriteLine(Create(typeof(Invoice)).GetType().Name);   // Invoice
    }

    // Tells the trimmer to keep the parameterless constructor of whatever type is passed
    private static object Create(
        [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type)
        => Activator.CreateInstance(type)!;
}

⚠ Common mistakes

1. Uncached reflection in hot paths

What happens: GetProperty/GetCustomAttributes called per request or per row. Why it is wrong: repeated lookups, allocations and boxing. Do instead: cache metadata and compiled delegates.

2. Using Invoke for frequent calls

What happens: MethodInfo.Invoke inside loops. Why it is wrong: argument arrays, boxing and validation per call. Do instead: delegates, MethodInvoker, or source generators.

3. Wrong BindingFlags

What happens: GetProperty("X", BindingFlags.NonPublic) without Instance. Why it is wrong: returns null; code fails silently. Do instead: combine visibility and instance/static flags.

4. Ignoring trim/AOT warnings

What happens: IL2026/IL3050 warnings suppressed. Why it is wrong: runtime failures in trimmed or AOT apps. Do instead: annotate correctly or replace reflection.

5. Using reflection to break encapsulation casually

What happens: setting private fields of library types. Why it is wrong: brittle across versions and bypasses invariants. Do instead: use public APIs; UnsafeAccessor only when justified.

6. Not unwrapping TargetInvocationException

What happens: logging the wrapper exception. Why it is wrong: hides the real error. Do instead: inspect InnerException or use BindingFlags.DoNotWrapExceptions.

7. Expression.Compile() in AOT apps

What happens: compiled expression accessors in NativeAOT builds. Why it is wrong: falls back to slow interpretation. Do instead: delegates, UnsafeAccessor or source generators.

8. Loading types by user-supplied names

What happens: Type.GetType(request.TypeName) and Activator.CreateInstance. Why it is wrong: security risk (arbitrary type instantiation) and trimming issues. Do instead: allow-list types with a known registry.

🏛 Senior insight: trade-offs

A senior answer treats reflection as a build-time problem wearing a run-time costume. Most reflection in business code (mapping, validation, DI scanning, serialisation) only discovers shapes that are already known at compile time, so the modern .NET answer is to move it into a source generator: System.Text.Json source generation, the configuration binding generator and the Request Delegate Generator all exist for this reason.

Need Prefer
Shapes known at compile time Source generator
Hot path, signature known Cached typed delegate
Hot path, signature known only at run time MethodInvoker / ConstructorInvoker
One private member of a library you cannot change [UnsafeAccessor]
Plugin discovery at startup Reflection once, cache results, annotate for trimming

Also mention that reflection makes behaviour invisible to tooling: "find all references" misses it, renames break it and trimming removes it. That is a maintenance cost as real as the CPU cost.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions GetCustomAttribute. Why: Reading attributes is the most common reflection use.
    How to answer

    An attribute is declarative metadata: the compiler serialises the constructor arguments and named properties into the assembly's metadata, and nothing runs until someone reads it. An instance is created only when code calls GetCustomAttribute(s), and a new instance is created on every call. [AttributeUsage] controls valid targets, AllowMultiple and Inherited. Arguments must be compile-time constants, typeof, enums or one-dimensional arrays of those. C# 11 added generic attributes ([Validator<OrderValidator>]). Some attributes are read by the compiler itself ([Obsolete], [Conditional], nullable attributes such as [NotNullWhen]), and caller info attributes ([CallerMemberName], [CallerFilePath], [CallerLineNumber], C# 10's [CallerArgumentExpression]) make the compiler fill optional parameters at the call site.

  2. When: Candidate mentions Native AOT or trimming warnings. Why: Checks whether the candidate knows the build-time alternative to reflection.
    How to answer

    A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.

  3. When: Candidate mentions PublishAot or PublishTrimmed. Why: Trimming and Native AOT are publish options with reflection consequences.
    How to answer

    By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.

🛠 Real-world problems: how you handle them

Reflection-based mapper made a nightly export miss its window
Situation
A finance service exported [200,000] invoice rows each night to a partner over SFTP. A generic mapper copied properties between entities and DTOs with PropertyInfo.GetValue and SetValue.
Signals
The export grew from [12] to [55] minutes as the data grew, CPU sat at 100% on one core, and the partner's import window was missed twice in a week.
Investigation
A dotnet-trace CPU profile in PerfView showed most samples in RuntimePropertyInfo.GetValue, argument validation and boxing. The mapper also called GetProperties() for every row.
Resolution
Short term, cached compiled getter and setter delegates per type pair in a ConcurrentDictionary, which brought the export back to [6] minutes. Long term, replaced the mapper with a source-generated one so the service could also be published trimmed.
Prevention
Added a BenchmarkDotNet test for the mapper to CI, a code review rule against uncached reflection in loops, and enabled trimming analysers (IsTrimmable) on shared libraries.

How to tell it (STAR)

**Situation:** our nightly invoice export to [partner] started missing its window as volumes reached [200,000] rows. **Task:** I owned the export and had to get it under [15] minutes without rewriting the pipeline. **Action:** I profiled it with dotnet-trace and found the generic mapper calling GetProperties and PropertyInfo.GetValue for every row, with boxing on every decimal. I cached typed delegates per type, then proposed and built a source-generated mapper. **Result:** the export dropped from [55] to [6] minutes, CPU use fell by [80%], and the service became trim-safe, which later let us publish it with Native AOT.

↩ Usually asked after

More in Advanced Language Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me