Reflection APIs, their run-time cost, faster alternatives in .NET 8+, and trimming/AOT annotations.
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.
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.
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).MethodInfo.Invoke is slowA direct call is a few nanoseconds. Invoke historically took hundreds of nanoseconds or more because it:
object[] for arguments and boxes value-type arguments and return values,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.
Type/MemberInfo lookups (static dictionaries or generic static caches).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).
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.Reflection.Emit, Expression.Compile() (falls back to interpretation), and MakeGenericType with unknown value-type instantiations may fail or be slow.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.
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.
The interviewer wants to know whether you understand what reflection costs and how modern .NET reduces or removes it.
Green flags
Invoke boxes, allocates and wraps exceptions.MethodInvoker or UnsafeAccessor (.NET 8).[DynamicallyAccessedMembers].Red flags
GetMethod returns null for private members.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.
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.UnsafeAccessor detailsstatic 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.
| 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 |
| 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) |
GetValue, a cached delegate, MethodInvoker and UnsafeAccessor.GetProperty without BindingFlags.Instance and observe null.<PublishTrimmed>true</PublishTrimmed> that uses Type.GetType(stringFromConfig) and read the warnings/failure.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)!;
}
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.
Invoke for frequent callsWhat happens: MethodInfo.Invoke inside loops.
Why it is wrong: argument arrays, boxing and validation per call.
Do instead: delegates, MethodInvoker, or source generators.
BindingFlagsWhat happens: GetProperty("X", BindingFlags.NonPublic) without Instance.
Why it is wrong: returns null; code fails silently.
Do instead: combine visibility and instance/static flags.
What happens: IL2026/IL3050 warnings suppressed. Why it is wrong: runtime failures in trimmed or AOT apps. Do instead: annotate correctly or replace reflection.
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.
TargetInvocationExceptionWhat happens: logging the wrapper exception.
Why it is wrong: hides the real error.
Do instead: inspect InnerException or use BindingFlags.DoNotWrapExceptions.
Expression.Compile() in AOT appsWhat happens: compiled expression accessors in NativeAOT builds.
Why it is wrong: falls back to slow interpretation.
Do instead: delegates, UnsafeAccessor or source generators.
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.
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.
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.
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.
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.
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.
Last updated 30 Oct 2026.