AI WisdomArchitecture & guides β†—
HT
How Things Work

Reflection & Source Generators

Inspecting types at runtime with Assembly.GetTypes() β€” and why source generators replaced most reflection use cases in .NET 7+.

How It Works

Reflection lets you inspect and manipulate types, methods, and properties at runtime β€” without compile-time knowledge of what those types are. It's how ORMs discover your entities, how serializers find your properties, and how DI containers scan assemblies for services. But reflection is expensive (allocations, metadata loading), bypasses access modifiers (security hole), and breaks under NativeAOT trimming. Source generators replace most reflection use cases with compile-time code generation β€” same flexibility, zero runtime cost.

1
Type metadata is embedded in every .NET assembly

Every .NET assembly (DLL/EXE) contains a metadata section alongside the IL code. This metadata describes every type, method, property, and attribute defined in the assembly. Reflection reads this metadata at runtime via the CLR's metadata reader.

2
Type, MethodInfo, PropertyInfo β€” the reflection API

System.Type is the entry point. typeof(Order) or Type.GetType("MyApp.Order") returns a Type object. From there, GetProperties(), GetMethods(), and GetCustomAttributes() return MethodInfo, PropertyInfo, and Attribute instances describing each member.

3
BindingFlags control what's visible

By default, reflection returns only public members. BindingFlags.NonPublic | BindingFlags.Instance returns private and protected members too. This is powerful but dangerous β€” it's how serializers and mocking frameworks work, but also how accidental access modifiers bypasses happen.

4
Source generators run at compile time in Roslyn

IIncrementalGenerator implementations receive the Roslyn syntax tree and semantic model at compile time. They emit additional C# source files that are compiled alongside your code. The generated code is visible in the IDE, debuggable, and doesn't require runtime reflection.

5
NativeAOT and the reflection death trap

NativeAOT (publish as native binary) uses tree-shaking: types and methods not referenced at compile time are trimmed. Reflection-accessed types are not statically referenced β€” they get trimmed, causing MissingMethodException or TypeLoadException at runtime. Source generators eliminate this problem by making all access statically visible.

Key Concepts

πŸ”¬System.Type

The runtime representation of a type. Every object has one (obj.GetType()). Contains the complete type metadata: name, assembly, properties, methods, base type, interfaces, and custom attributes.

🚩BindingFlags

Flags enum controlling which members reflection returns. Public | NonPublic | Instance | Static | DeclaredOnly. Without BindingFlags.NonPublic, private members are invisible. With it, everything is accessible.

🏭Activator.CreateInstance

Creates an instance of a type at runtime without a compile-time reference. Requires a parameterless constructor unless you pass constructor arguments. Bypasses DI β€” don't use in production service code.

⚑IIncrementalGenerator

The .NET 6+ source generator API. Receives an IncrementalGeneratorInitializationContext to register syntax-based transforms. Incremental means only changed files trigger re-generation β€” much faster than the older ISourceGenerator.

🌳Expression Trees

A middle ground between reflection and source generators: compile lambda expressions to delegates at runtime (Expression.Compile()). 10-100x faster than PropertyInfo.GetValue() for hot paths. Used heavily in Entity Framework and AutoMapper.

πŸ“Œrd.xml / [DynamicDependency]

NativeAOT/trimming directives to tell the linker 'keep this type even though it's not statically referenced.' rd.xml is XML-based; [DynamicDependency] is attribute-based. Both are escape hatches when refactoring to source generators isn't feasible.

Reflection, Source Generators & Expression Trees
tsx
1// ── RUNTIME REFLECTION ─────────────────────────────────────────────
2// Useful for: plugin architectures, ORMs, serializers, DI containers
3
4// Type discovery β€” all types in an assembly
5var assembly = Assembly.GetExecutingAssembly();
6var entityTypes = assembly
7 .GetTypes()
8 .Where(t => t.IsClass && !t.IsAbstract && typeof(IEntity).IsAssignableFrom(t))
9 .ToList();
10// ⚠️ Assembly.GetTypes() allocates a Type[] per call β€” cache this
11
12// Property access via reflection
13var order = new Order { Id = Guid.NewGuid(), TotalAmount = 99.99m };
14var prop = typeof(Order).GetProperty("TotalAmount",
15 BindingFlags.Public | BindingFlags.Instance);
16var value = prop?.GetValue(order); // object β€” boxed if value type
17
18// ⚠️ this bites everyone eventually β€” reflection bypasses access modifiers
19var privateField = typeof(OrderService)
20 .GetField("_internalState", BindingFlags.NonPublic | BindingFlags.Instance);
21privateField?.SetValue(orderService, newState); // compiles and runs β€” dangerous
22
23// Dynamic instantiation
24var instance = Activator.CreateInstance(
25 typeof(Repository<>).MakeGenericType(typeof(Order)));
26// ⚠️ NativeAOT: MakeGenericType with runtime types is not always preserved
27
28// ── SOURCE GENERATORS (.NET 6+) ─────────────────────────────────────
29// Use when: JSON serialization, DI registration, mapper generation,
30// validation, logging (LoggerMessage), Regex, pattern matching
31
32// System.Text.Json source gen β€” zero reflection at runtime:
33[JsonSerializable(typeof(Order))]
34[JsonSerializable(typeof(List<Order>))]
35internal partial class AppJsonContext : JsonSerializerContext { }
36
37// Usage β€” same API, same output, no Type.GetProperties() at runtime:
38var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
39var order2 = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);
40
41// LoggerMessage source gen β€” avoids boxing and string allocation per call:
42public static partial class Log
43{
44 [LoggerMessage(Level = LogLevel.Warning,
45 Message = "Order {OrderId} placed by {CustomerId}")]
46 public static partial void OrderPlaced(
47 ILogger logger, Guid orderId, Guid customerId);
48}
49
50// Usage β€” no string.Format, no object[] allocation:
51Log.OrderPlaced(_logger, order.Id, order.CustomerId);
52
53// ── EMIT (IL generation) ─────────────────────────────────────────────
54// Use for: high-performance dynamic proxies, expression trees
55// Largely replaced by source generators and compiled expressions
56
57// Compiled expression β€” faster than PropertyInfo.GetValue():
58var param = Expression.Parameter(typeof(Order), "o");
59var body = Expression.Property(param, "TotalAmount");
60var getter = Expression.Lambda<Func<Order, decimal>>(body, param).Compile();
61// getter(order) is now as fast as direct property access β€” cached once
πŸ’‘
Why This Matters

Understanding the boundary between reflection and source generators is essential for modern .NET development. System.Text.Json, Microsoft.Extensions.Logging (LoggerMessage), Regex (GeneratedRegex), and Entity Framework Core all now offer source generator modes. NativeAOT deployment β€” which gives near-instant startup times β€” is impossible without eliminating reflection. Any library or framework you build today should offer a source generator alternative if it currently uses reflection.

Common Pitfalls

⚠Reflection bypasses access modifiers: BindingFlags.NonPublic lets you read and write private fields and call private methods. This is how mocking frameworks work, but it also means reflection-heavy code can accidentally modify internal state and break invariants.
⚠Assembly.GetTypes() in a hot path: this allocates a new Type[] on every call and loads assembly metadata. Cache the result. Repeat calls to GetTypes() are a common source of allocation pressure in plugin-heavy architectures.
⚠Using GetType().Name instead of nameof(): GetType().Name is a runtime reflection call. nameof(MyClass) is resolved at compile time to a string literal. In logging, error messages, and serialization keys, always prefer nameof().
⚠NativeAOT trimming removes types you reflect on: if the linker doesn't see a static reference to a type's methods, it trims them. MakeGenericType(), Type.GetType(string), and Assembly.GetTypes() are all invisible to the linker. Use [DynamicDependency] or source generators to fix this.
⚠Reflection.Emit and dynamic IL generation: completely unsupported in NativeAOT. Expression trees (which use Emit internally) are also restricted. If your library uses Emit, you need a source generator alternative for NativeAOT targets.
Real-World Use Cases

1Publishing to NativeAOT Broke the Entire API

Scenario

We published our ASP.NET Core 8 API as a NativeAOT binary to reduce cold start time (achieved: 8ms vs 800ms). Deployment to production: API started, health check passed, first real request to POST /orders returned HTTP 500. The serializer returned an empty JSON object {}.

Problem

System.Text.Json was using reflection to discover Order's properties at runtime. NativeAOT's linker had trimmed all the property metadata from the assembly because no static code reference pointed to Order's properties. typeof(Order).GetProperties() returned an empty array at runtime.

Solution

Added [JsonSerializable(typeof(Order))] source generator context. Replaced all JsonSerializer.Serialize(obj) calls with JsonSerializer.Serialize(obj, AppJsonContext.Default.Order). The source generator emitted compile-time property access code β€” no runtime reflection, linker-safe. Took 3 hours to retrofit across 47 entity types.

πŸ’‘

Takeaway: If you're considering NativeAOT or aggressive trimming, audit your reflection usage first. System.Text.Json, EF Core, and most serializers now have source generator modes. Enabling <PublishTrimmed>true</PublishTrimmed> in debug builds will surface trimming warnings before you hit production.

2Reflection in the Serialization Hot Path

Scenario

Our API gateway was serializing ~10k OrderSummary objects per second for the dashboard endpoint. APM showed this endpoint consuming 35% of CPU. dotnet-counters showed Gen0 GC collections every 80ms. The serializer was custom-built using PropertyInfo.GetValue() in a loop.

Problem

PropertyInfo.GetValue() boxes value types (decimal, Guid, DateTimeOffset) on every call β€” one heap allocation per property per object. With 12 properties per OrderSummary and 10k objects/sec, that's 120k allocations/sec from the serializer alone. Plus Type.GetProperties() was being called on every request instead of cached.

Solution

Replaced the reflection-based serializer with compiled expression trees: for each property, compile a Func<OrderSummary, object> using Expression.Lambda, cache it in a ConcurrentDictionary, and call the compiled delegate. Gen0 collections dropped to every 2 seconds. Serialization CPU dropped from 35% to 4%. Later fully replaced with System.Text.Json source gen.

πŸ’‘

Takeaway: Never call PropertyInfo.GetValue() in a hot path. At minimum, compile and cache expression trees. At best, use source generators which eliminate runtime reflection entirely. The difference between uncached reflection and source-generated code can be 50-100x in allocations.