C# Source Generators vs Reflection: Which Should You Use?
Every .NET developer has used reflection at some point. It feels almost magical -- you can inspect types, invoke methods, and map properties at runtime without knowing their names at compile time. But as .NET has evolved toward NativeAOT and aggressive trimming in .NET 8, 9, and 10, reflection carries real costs that are hard to ignore. That's why source generators vs reflection c# has become one of the most pressing architectural decisions in modern .NET development.
This article gives you a clear, practical comparison. You'll understand what each approach does, where they differ in performance and AOT compatibility, and -- most importantly -- how to decide which one belongs in your code.
Quick Overview: What Is Reflection?
Reflection in C# is a runtime mechanism that lets your code examine and interact with the type system dynamically. Using the System.Reflection namespace, you can read property names and types, invoke methods by string, create instances of types you don't know at compile time, and walk the full metadata tree of any assembly.
The appeal is obvious. Reflection has powered serializers, dependency injection containers, test frameworks, and object mappers for years. It's flexible, expressive, and requires minimal boilerplate. The cost, however, is paid at runtime -- every GetProperty, GetMethod, and Invoke call has overhead, and the JIT can't optimize what it doesn't know about statically.
Reflection enabled enormous flexibility in .NET libraries for decades. But in a world where startup time, binary size, and AOT compilation matter more than ever, its limitations are increasingly prominent.
Quick Overview: What Are Source Generators?
Source generators are a Roslyn compiler extension that run during compilation and inspect your code, then emit new C# source files that get compiled alongside your project. The generated code is plain, statically typed C# -- no runtime discovery, no late binding, no magic.
Unlike reflection, source generators do the "discovery" work at build time. If you need to know all classes implementing an interface, all properties on a type, or all methods with a specific attribute, a source generator can produce that list as a static lookup table during the build -- rather than enumerating them at runtime.
A key thing to understand: source generators don't replace all uses of reflection. They replace the use cases where you already know the relevant types at compile time. That distinction is the foundation of every decision in this article.
The Core Difference: Runtime vs Compile-Time
This is the fundamental split. Reflection operates at runtime -- it examines live type metadata while your application is running. Source generators operate at compile time -- they examine your code through Roslyn's syntax and semantic APIs before the binary is produced.
This distinction has cascading effects on nearly every quality attribute of your code: performance, AOT safety, debuggability, and maintainability. Reflection's runtime nature makes it inherently flexible but costly. Source generators' compile-time nature makes them efficient and type-safe, but requires that the relevant information be statically knowable before you ship.
Think of it this way: reflection asks "what types are here?" at runtime, while source generators answer that question at compile time and bake the answer into your binary. When working with patterns like the factory method design pattern, reflection-based factories were historically common -- source generators now offer a much faster alternative where all concrete types are known at build time.
Performance: Source Generators Win
The performance difference when weighing source generators vs reflection in c# is significant and measurable. A typical PropertyInfo.GetValue call sits around 100-200 nanoseconds per invocation. That sounds small, but multiply it across hundreds of properties and thousands of object mappings per second and it adds up quickly.
Source-generated code compiles down to direct property accesses. There's no metadata lookup, no unnecessary boxing, and no late-binding overhead. The runtime cost of source-generated hot paths is essentially zero -- the output is identical to code you would have written by hand.
Here's a practical illustration. Imagine you're mapping 1,000 objects per request with 10 properties each:
- Reflection-based mapping: ~150 ns/operation average
- Source-generated mapping: ~2-5 ns/operation (direct property copies)
- At 1,000 objects/request: reflection adds ~145 µs of overhead per request
That doesn't include JIT cold-start costs, cache pressure from reflection's internal caches, or the memory overhead of keeping PropertyInfo objects alive.
The startup cost matters too. Libraries that use reflection-heavy initialization -- scanning assemblies, building type maps, caching metadata -- can add hundreds of milliseconds to application startup. Source generators move that work entirely to build time.
Code Example 1: Reflection-Based Property Mapping
Here's the classic reflection approach for mapping properties between two types:
// Runtime reflection approach -- works but has overhead and AOT issues
public static TDest Map<TSource, TDest>(TSource source)
where TDest : new()
{
var dest = new TDest();
foreach (var prop in typeof(TSource).GetProperties())
{
var destProp = typeof(TDest).GetProperty(prop.Name);
destProp?.SetValue(dest, prop.GetValue(source));
}
return dest;
}
This is readable and works correctly in a standard .NET runtime. But every call to GetProperties, GetProperty, GetValue, and SetValue is a late-bound operation. The JIT can't inline any of it, and the trimmer can't safely remove property metadata because it doesn't know which properties might be accessed at runtime.
Code Example 2: Source Generator Approach
With a source generator, you define a marker attribute and let the generator produce the mapping at compile time:
// Mark the mapper with an attribute for the source generator to discover
[GenerateMapper]
public partial class OrderMapper
{
// The source generator fills in the implementation at build time
}
// What the generator emits (visible in obj/Generated folder):
public partial class OrderMapper
{
public static OrderDto Map(Order source)
{
return new OrderDto
{
Id = source.Id,
CustomerName = source.CustomerName,
TotalAmount = source.TotalAmount,
CreatedAt = source.CreatedAt,
};
}
}
The generated code is direct property assignment. It's statically typed, fully AOT-safe, and zero-overhead at runtime. The generator ran during the build and produced the exact same code you would have written by hand -- but you didn't have to write it.
AOT Compatibility: The Game Changer in .NET 7+
This is where the source generators vs reflection c# conversation shifts from "performance preference" to "architectural requirement." NativeAOT compilation, available since .NET 7 and expanded significantly in .NET 8, requires your code to be fully statically analyzable at build time. The trimmer and AOT compiler need to know every type that will be used so they can include it in the binary and remove everything else.
Reflection breaks this guarantee. When you call Assembly.GetTypes(), Activator.CreateInstance(typeName), or Type.GetMethod("DoSomething"), the AOT compiler has no way to know which types, methods, or properties will be accessed. It must either include everything (defeating the purpose of trimming) or include nothing and risk runtime failures.
Code Example 3: NativeAOT Trim Warning with Reflection
// This triggers IL2026 (Type.GetType annotated RequiresUnreferencedCode) and IL2072 trim warnings
public static object? CreateByName(string typeName)
{
// IL2072: Value returned by Type.GetType() does not match DynamicallyAccessedMemberTypes requirements
var type = Type.GetType(typeName);
return type is not null ? Activator.CreateInstance(type) : null;
}
// The AOT-safe equivalent uses a source-generated registry instead:
public static object? CreateByName(string typeName)
{
// Generated at compile time -- no reflection, no trim warnings
return typeName switch
{
nameof(OrderService) => new OrderService(),
nameof(PaymentService) => new PaymentService(),
nameof(ShippingService) => new ShippingService(),
_ => null
};
}
The trim warnings (IL2026, IL2072) are not just warnings -- they indicate places where your application may fail silently or throw MissingMethodException at runtime when published with PublishTrimmed=true or as NativeAOT. Source generators eliminate these warnings entirely because the generated code contains only direct type references that the trimmer can track.
Plugin systems that historically relied on reflection-based assembly scanning at startup -- like those covered in the plugin architecture with Needlr guide -- need to be rethought for AOT targets. A source generator that scans referenced assemblies at build time and produces a static plugin registry is the correct pattern for AOT-safe plugin loading.
Debuggability
Reflection errors are runtime errors. You discover them when the application runs: a MissingMethodException because a method was renamed, a NullReferenceException from a GetProperty returning null when a property doesn't exist, or a silent mapping failure because a property name changed. These bugs can reach production before they surface.
Source generator errors are build errors. If a type used by a source generator doesn't have a required property, the build fails. If you rename a class the generator depends on, the compiler tells you immediately. Errors are caught in your CI pipeline, not in production logs.
This shift from runtime failures to compile-time failures is a significant safety improvement. The builder design pattern in C# illustrates this well -- a source-generated builder will fail to compile if a required property is missing, whereas a reflection-based builder might silently skip it at runtime and produce an incomplete object.
There's also an IDE benefit worth noting. Generated code is visible. In Visual Studio or Rider, you can navigate to the generated files in your obj folder, inspect exactly what the generator produced, and set breakpoints in it. Reflection's dynamic behavior, by contrast, is often opaque -- you can't easily step through a PropertyInfo.SetValue call and see which property it's setting without extra tooling.
Maintainability Trade-offs
Reflection wins on initial simplicity. Writing a generic mapper with reflection takes 20 lines of code and requires no additional project setup. Writing a source generator requires understanding Roslyn APIs, incremental generators, syntax trees, and compilation semantics. The learning curve is real, and the setup cost -- a separate project, generator attributes, registration -- is non-trivial for small projects.
Source generators win on long-term maintainability. Once written, a source generator requires no updates when new types are added -- it automatically handles new classes that use the marker attribute. Reflection-based code, by contrast, can silently fail when types change, or require careful synchronization when new properties are introduced.
For teams building long-lived production applications, the one-time cost of learning and implementing a source generator typically pays back quickly through eliminated runtime failures and faster execution.
When implementing design patterns like the strategy design pattern in C#, strategy dispatch tables were often built with reflection-based type scanning. Source generators can produce the dispatch table at compile time, eliminating both the runtime overhead and the potential for late-bound failures. Similarly, proxy generation for the decorator design pattern in C# is a classic use case -- instead of a reflection-based proxy generated at runtime, a source generator produces it at compile time with zero runtime overhead.
When to Use Reflection
Reflection is still the right tool in several important scenarios, and recognizing them prevents over-engineering.
Dynamic plugin loading from external assemblies. When plugins are delivered as DLLs at runtime -- genuinely not known at compile time -- you cannot use a source generator. Assembly.Load and type inspection are required. This is one of reflection's irreplaceable use cases.
Test frameworks and developer tooling. NUnit, xUnit, and similar frameworks discover tests at runtime. This is expected behavior -- the overhead is acceptable in test runs, and AOT compatibility isn't a concern for test runner hosts.
Late-bound scenarios. Scripting engines, REPL environments, and dynamic configuration systems that evaluate type operations at runtime based on user input or external data must use reflection. The types simply don't exist at build time.
Small projects with no AOT requirements. If your project runs on a standard .NET runtime, doesn't need NativeAOT, and the team is small, reflection's simplicity can be worth more than source generators' performance.
Prototyping and rapid iteration. When the shape of your types is still changing rapidly, reflection-based code adapts automatically without a regeneration step. Source generators are better suited to stable, well-defined APIs.
When to Use Source Generators
Understanding the right scenarios for source generators vs reflection in c# comes down to a few clear signals.
Any AOT or NativeAOT publish target. If your project uses PublishAot=true or PublishTrimmed=true in .NET 7, 8, 9, or 10, reflection is a liability. Source generators are the architectural answer.
High-performance serialization, mapping, and dispatch. When generated code runs in hot paths -- deserialization, object mapping, event dispatch -- the per-call overhead of reflection is too high. Source generators eliminate it entirely, and the generated code is indistinguishable from handwritten code.
Boilerplate elimination for known types. If you're writing repetitive code that varies only by type -- INotifyPropertyChanged implementations, ToString overrides, equality members, command dispatch tables -- source generators produce it reliably without maintenance burden.
DI container registration. Rather than scanning assemblies at startup to register services, a source generator can produce a static registration method at build time. This is faster, AOT-safe, and produces no trim warnings. Lifetime management patterns like those in the singleton design pattern in C# benefit too -- a source generator can enforce singleton lifetime constraints at compile time rather than discovering misuse at runtime.
Types known at compile time. If the question is "which types have this attribute?" or "what are the properties of this class?", and the answer is available during the build, a source generator is almost always the right choice -- the main exception being small or short-lived projects where reflection's simplicity outweighs the generator setup cost.
Decision Matrix
| Scenario | Recommended Approach | Reason |
|---|---|---|
| NativeAOT or trimmed publish | Source Generator | Reflection breaks trimming; AOT-safe code required |
| High-frequency object mapping | Source Generator | Eliminates ~150 ns/op reflection overhead |
| Plugin loading from external DLLs | Reflection | Types not known at compile time |
| Test discovery in test frameworks | Reflection | Runtime discovery is the design intent |
| DI service registration at startup | Source Generator | Faster startup, AOT-safe, no trim warnings |
| Scripting engine / REPL | Reflection | Dynamic type evaluation required |
| INotifyPropertyChanged boilerplate | Source Generator | Repetitive code, types fully known at compile time |
| Rapid prototype with changing types | Reflection | Adapts without regeneration during iteration |
| JSON serialization | Source Generator | JsonSerializerContext produces AOT-safe serializers |
| Factory dispatch by type name | Source Generator | Static switch expression, zero runtime lookup |
| Decorator proxy generation | Source Generator | Compile-time proxy is faster and AOT-safe |
Migration Path: From Reflection to Source Generators
Migrating existing reflection-based code to source generators is a gradual process -- you don't need to rewrite everything at once.
Start with the hot paths. Profile your application and identify where reflection is called most frequently. Object mappers, serializers, and dispatch tables are common targets with the biggest performance return.
Introduce a marker attribute. Define a [GenerateX] attribute that flags the types you want the generator to handle. This lets you migrate incrementally -- types with the attribute get generated code, types without it continue to use the existing reflection path.
Use existing libraries first. You don't need to write a source generator from scratch for common scenarios. System.Text.Json source generation ([JsonSerializable]), [LoggerMessage] source generation for structured logging (available since .NET 6), and libraries like Mapperly handle many common reflection patterns with production-quality generated code.
Validate with AOT warnings. Add <PublishAot>true</PublishAot> to a test publish configuration and examine the trim warnings. Each IL2xxx warning identifies a reflection usage that will fail under NativeAOT. Work through these systematically, replacing reflection with source-generated alternatives.
Keep reflection where it belongs. Plugin loading from external assemblies, test discovery, and dynamic scripting are legitimate reflection use cases. Don't migrate them.
FAQ
Does source generation work with .NET Standard?
Incremental source generators (IIncrementalGenerator) require Roslyn 4.0+, which ships with the .NET 6 SDK and Visual Studio 2022. They can generate code that targets netstandard2.0 libraries, but the generator project itself requires Microsoft.CodeAnalysis.CSharp 4.x package references.
Can source generators replace all uses of reflection?
No. Source generators can only access information that is statically available at compile time. Dynamic plugin loading, runtime type inspection of assemblies loaded after startup, scripting engines, and any scenario where types are discovered at runtime cannot use source generators. They replace reflection in the majority of common use cases -- serialization, mapping, DI registration, dispatch -- but not in genuinely dynamic scenarios where type information isn't available until the application is running.
Are source generators difficult to write?
The learning curve is real but manageable. The Roslyn APIs (SyntaxTree, SemanticModel, ITypeSymbol) take time to learn, and the incremental generator pattern (IIncrementalGenerator) -- the modern, performance-friendly approach -- requires understanding SyntaxValueProvider and IncrementalValuesProvider. For common scenarios like mapping, registration, and boilerplate, using an existing source generator library is often faster than writing your own.
Will source generators slow down my build?
They can add time on first run and after clean builds. Incremental generators cache their output and only re-run when their inputs change, keeping incremental builds fast. Full rebuilds include generator execution time, which for most generators is under 100ms. The build cost rarely outweighs the runtime performance and AOT-safety gains.
How does System.Text.Json source generation work?
System.Text.Json supports source generation through JsonSerializerContext. You define a partial class that inherits from JsonSerializerContext, annotate it with [JsonSerializable(typeof(MyType))] for each type you want to serialize, and the framework's built-in source generator produces the serialization code. The result is fully AOT-safe JSON serialization with no reflection at runtime. This source generation support has been available since .NET 6 and is the required pattern for NativeAOT publishing targets in .NET 6 through 10.
Does reflection have any advantages source generators cannot replicate?
Reflection's key advantage is true runtime dynamism. It can inspect types from assemblies loaded after startup, work with types whose names are provided at runtime by users, and support scripting and late-binding scenarios that are impossible at compile time. Source generators have no answer for these scenarios because the types simply don't exist during the build. For everything else -- serialization, mapping, boilerplate generation, dispatch tables -- source generators are equal or superior in every measurable dimension.
What happens if I use reflection in a NativeAOT-published app?
The trimmer emits IL2xxx warnings for reflection calls it can't analyze statically. In many cases, the trimmer removes the types or methods you're trying to access at runtime because it has no evidence they're needed -- resulting in MissingMethodException or TypeLoadException at runtime. The correct fix is to eliminate the reflection call with a source-generated alternative, not suppress the warning with [DynamicDependency].
Conclusion
The source generators vs reflection c# decision isn't about which technology is "better" in the abstract -- it's about which is appropriate for your constraints. Reflection remains valuable for dynamic scenarios where types are genuinely unknowable at compile time. For most production scenarios in modern .NET development -- particularly any target that uses NativeAOT, trimming, or high-performance hot paths -- source generators are the right choice.
The direction Microsoft has taken is clear. System.Text.Json source generation, LoggerMessage source generation, Regex source generation -- the framework has steadily moved away from runtime reflection toward compile-time alternatives. Start with your hottest reflection-based code path and migrate outward. The performance, AOT-compatibility, and debuggability gains are worth the one-time investment.
When you're ready to go deeper, explore how design patterns like the factory method pattern and builder pattern can be modernized with source generators -- the compile-time guarantees they bring make these patterns safer and faster at scale.

