C# Reflection vs Source Generators in .NET 10: Which Should You Choose?
C# reflection vs source generators is one of the most consequential architectural decisions you'll make when building framework-level code in .NET 10. Both tools solve a similar family of problems -- inspecting types, generating behavior based on type structure, wiring things together without manual boilerplate -- but they operate at completely different phases of your application's life. Reflection runs at runtime, inspecting metadata that's already compiled into your assemblies. Source generators run at compile time, emitting C# code that gets compiled along with the rest of your project.
That phase difference has cascading implications for performance, AOT compatibility, debuggability, and how much you can do dynamically. This guide maps out every dimension so you can make the right choice for your specific context.
What Reflection Does
Reflection gives you runtime access to the metadata embedded in .NET assemblies. Every class name, property type, method signature, and custom attribute you define ends up in the assembly's metadata tables, and the System.Reflection namespace lets you read and act on all of it while the program is running.
Common reflection operations:
// Inspect type structure
Type type = typeof(OrderService);
PropertyInfo[] props = type.GetProperties();
MethodInfo[] methods = type.GetMethods();
ConstructorInfo[] ctors = type.GetConstructors();
// Read and write property values dynamically
PropertyInfo prop = type.GetProperty("Status")!;
prop.SetValue(orderInstance, OrderStatus.Shipped);
object? value = prop.GetValue(orderInstance);
// Create instances without knowing the type at compile time
Type? serviceType = Type.GetType("MyApp.Services.OrderService, MyApp");
object instance = Activator.CreateInstance(serviceType!)!;
// Invoke methods by name
MethodInfo method = type.GetMethod("Process")!;
method.Invoke(instance, new object[] { orderId });
For a hands-on introduction to the core reflection APIs, see Reflection in C#: 4 Simple But Powerful Code Examples.
The defining characteristic of reflection: it works at runtime with information that may not exist at compile time. You can load an assembly from disk, discover its types, and work with them -- all without any compile-time knowledge of what's in that assembly. This is what powers plugin systems, scripting engines, and any "open-ended" type discovery scenario.
What Source Generators Do
Source generators are Roslyn analyzers with write access. They run during compilation, receive a view of your source code as syntax trees and semantic models, and can emit additional C# source files that get compiled alongside your code.
A conceptually simple example -- a source generator that creates a ToJson() method for any class marked with [GenerateSerializer]:
// Your code -- just attribute marking, no implementation
[GenerateSerializer]
public sealed class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = "";
public decimal Total { get; set; }
}
// What the source generator emits into your compilation (you never write this)
// Order.Generated.cs
partial class Order
{
public string ToJson()
{
return $"{{"Id":{Id},"CustomerName":"{CustomerName}","Total":{Total}}}";
}
}
The generated code is real C# that the compiler sees. It gets JIT-compiled (or AOT-compiled) like any other code. At runtime there is no generator machinery present -- just the compiled output of what the generator wrote.
Source generators cannot inspect types that aren't in the current compilation. They can see your project's code and its transitive references, but they can't discover types that will be loaded at runtime from external plugins or dynamically assembled strings.
Feature Comparison
| Feature | Reflection | Source Generators |
|---|---|---|
| When it runs | Runtime | Compile time |
| Performance (per call) | Per-call overhead (can cache) | Zero runtime overhead |
| AOT / NativeAOT support | Limited -- dynamic invoke breaks trimmer | Full -- generated code is statically analyzable |
| Works with runtime-only-known types | Yes | No |
| Debugging | Limited runtime inspection | Normal debugger, generated files visible in IDE |
| Error feedback | Runtime exceptions | Compile-time errors and warnings |
| Code complexity | Medium -- familiar APIs | High -- requires Roslyn API knowledge |
| IDE support for generated code | N/A | Full IntelliSense on generated members |
| Startup cost | Metadata reads at startup | None (already compiled) |
| Incremental build support | N/A | Yes (with IIncrementalGenerator) |
Works with sealed types |
Yes | Yes |
| Works with private members | Yes (with flags) | Limited -- no access to truly private members from external assemblies by default |
| Suitable for plugins/dynamic loading | Yes | No |
When to Choose Reflection
Reflection is the right choice when the set of types you're operating on isn't fully known at compile time.
Plugin and extension systems. When your application loads .dll files at runtime from a plugin directory, the types inside those DLLs literally don't exist at your compile time. Reflection is the only way to discover and instantiate them. Source generators can't help here -- they only know about types in the compilation. For how plugin loading works in practice, see Plugin Architecture in C#: The Complete Guide to Extensible .NET Applications and Plugin Loading in .NET: AssemblyLoadContext with Dependency Injection.
Generic frameworks. If you're building a library that users install via NuGet and that must work with any types they define, you can't use source generators (your generator can't run in the user's compilation without them explicitly including it). Reflection lets your library work with user types at runtime regardless of when those types were written.
Prototyping and exploratory tooling. When you're building a CLI tool, a test harness, or an admin console that needs to introspect a running application's type system, reflection is the right fit. The overhead doesn't matter for interactive tooling, and the flexibility is exactly what you need.
Test frameworks. xUnit, NUnit, and MSTest all use reflection to discover test classes and test methods by convention and attribute. This is runtime discovery of user-defined types -- a core reflection use case.
Dynamic proxy generation. Frameworks like Castle DynamicProxy (used internally by Moq) generate proxy types at runtime using System.Reflection.Emit. This is beyond what source generators can do -- the proxy type doesn't exist until runtime.
When to Choose Source Generators
Source generators win whenever the full set of types to process is known at compile time and you want zero runtime overhead.
JSON serialization. System.Text.Json with source generation is the canonical example. Decorate your types with [JsonSerializable(typeof(MyType))], and the source generator emits a JsonSerializerContext subclass with fully optimized serialization/deserialization code. No runtime reflection, no startup cost, full AOT compatibility. Reflection-based System.Text.Json serialization, by contrast, inspects types on first use and has been a longstanding source of trimmer warnings in AOT scenarios.
Dependency injection registration. If every service type in your application is known at compile time (which is true for the vast majority of applications -- they're not loading external plugins), source generators can emit all your services.Add*() calls automatically, removing the startup reflection scan entirely. This is the core value proposition of Needlr.
Code generation for known patterns. If your codebase uses a consistent structural pattern -- all repositories follow the same interface shape, all event handlers have the same signature -- a source generator can generate the boilerplate from attributes or naming conventions. The output is strongly typed, inspectable in your IDE, and carries zero runtime cost.
Strongly typed resource access, logging, and mapping. The [LoggerMessage] attribute (built into .NET) uses a source generator to emit optimized logging calls. Similar generators exist for mapping (Mapperly), string resources, and more.
Real-World Example: JSON Serialization
The reflection vs source generator trade-off is most visible in System.Text.Json:
// Reflection-based -- convenient, but has startup cost and AOT warnings
var json = JsonSerializer.Serialize(myOrder);
var order = JsonSerializer.Deserialize<Order>(json);
// Source generator approach -- zero runtime reflection
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
internal partial class AppJsonContext : JsonSerializerContext { }
// Usage -- compiled, AOT-safe, fast
var json = JsonSerializer.Serialize(myOrder, AppJsonContext.Default.Order);
var order = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);
The source-generated version:
- Emits type-specific serialization logic at compile time.
- Has no runtime reflection calls.
- Passes NativeAOT trimming with no warnings.
- Shows the generated code in your IDE (under
Analyzersin Solution Explorer).
The reflection version is easier to write but carries ongoing runtime cost and AOT incompatibility. In .NET 10, the System.Text.Json source generator has matured to the point where it handles virtually all real-world scenarios, making the reflection-based path mainly useful for prototyping.
Needlr as a Case Study
Needlr is a DI registration library that explicitly chose source generation over reflection for its core type discovery mechanism. The decision illustrates the trade-off clearly.
The initial reflection-based approach scanned assemblies at startup using Assembly.GetTypes() and attribute inspection to find services to register. This worked but had two problems: it added startup latency proportional to the number of types scanned, and it wasn't AOT-compatible because the trimmer couldn't see which types would be accessed through GetTypes().
The source-generator approach changed the fundamental model. Needlr's generator runs at compile time, finds all types marked with [AutoRegister] (or matching a registration convention), and emits a registration method containing explicit services.AddTransient<IMyService, MyService>() calls. At startup, your application calls this generated method -- no reflection, no scanning, no AOT issues.
For the full story of that architectural decision, see Source Generation vs Reflection in Needlr. For how the automatic service discovery mechanism works in practice, see Automatic Service Discovery in C# with Needlr. And for how Needlr handles the filtering and organization of type discovery that would otherwise require runtime assembly scanning, see Assembly Scanning in Needlr: Filtering and Organizing Type Discovery.
The Hybrid Approach: Reflection at Startup, Source Generators for the Hot Path
Many production .NET 10 applications use both tools strategically:
- Source generators handle the high-frequency, compile-time-known paths: serialization, DI registration, logging, mapping.
- Reflection handles the low-frequency, runtime-dynamic paths: plugin loading, admin tooling, diagnostic endpoints.
The boundary is roughly: if a code path runs on every request or in a tight loop, eliminate reflection from it. If a code path runs once at startup or in response to rare admin operations, reflection is fine.
Here's a practical example of this hybrid in a plugin-capable application:
// Startup: reflection for plugin discovery (runs once)
var pluginTypes = pluginAssembly
.GetTypes()
.Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract);
foreach (var pluginType in pluginTypes)
{
// Register each discovered plugin type
services.AddSingleton(typeof(IPlugin), pluginType);
}
// Compile: source generator handles core application services
// (generated by Needlr or similar -- no startup reflection here)
services.AddGeneratedServices(); // auto-generated method
// Runtime: both paths converge -- DI resolves everything the same way
var plugins = serviceProvider.GetServices<IPlugin>();
The reflection happens once during startup for plugins, and the source-generated registration handles the rest. Hot-path resolution goes through compiled DI call sites regardless.
Migration Path: From Reflection to Source Generators
Migrating an existing reflection-based system to source generators is a multi-step process. Doing it incrementally is the practical approach:
Step 1: Identify the reflection hotspots. Use a profiler or add timing around your startup code. Find the specific reflection calls that dominate startup time or hot-path execution.
Step 2: Check if types are compile-time-known. For each hotspot, ask: "Could a source generator know which types to process?" If the answer is yes, the migration is viable.
Step 3: Write the source generator. This is the hard part -- Roslyn source generator APIs have a learning curve. The IIncrementalGenerator interface in .NET 6+ is the right starting point. The generator reads syntax trees and semantic models, finds the types you care about, and emits registration/initialization code.
Step 4: Run both in parallel first. Add the source generator output alongside the existing reflection code. Validate they produce identical results. Only then remove the reflection path.
Step 5: Update tests. Tests that mock or intercept reflection calls will need to be updated to work with the generated code's direct method calls.
The migration is effort-intensive but the result is often dramatic: faster startup, AOT compatibility, better trimmer output, and error feedback that moves from runtime to compile time.
AOT Compatibility in .NET 10
NativeAOT is increasingly mainstream in .NET 10. It compiles your application to a self-contained native binary with no JIT, no MSIL, and a minimal runtime. The performance and deployment benefits are significant -- but the compatibility constraints are strict.
Reflection in AOT is limited: reading metadata is supported (with trimmer annotations), but dynamic invocation (MethodInfo.Invoke, Activator.CreateInstance with type strings, Expression.Compile) is either restricted or removed. The trimmer also aggressively removes members that aren't referenced from a static analysis of your code, which means reflection-discovered types may be trimmed away unless you annotate them with [DynamicallyAccessedMembers].
Source generators are fully AOT-compatible by design -- they emit real C# code that the static analyzer can see and that the trimmer can track. If AOT is on your roadmap for .NET 10, source generators are not just preferred -- they're often required for the features you need.
FAQ
What is the main difference between reflection and source generators in C#?
Reflection runs at runtime and inspects metadata in compiled assemblies. Source generators run at compile time and emit additional C# source code that gets compiled with your project. Reflection works with any types, including those loaded dynamically. Source generators only work with types visible in the current compilation, but produce zero runtime overhead.
Are source generators always faster than reflection in .NET 10?
Yes, for the hot path -- generated code has no runtime reflection overhead. However, source generator compilation adds time to your build. For large codebases with many generated files, build times can increase noticeably. The runtime savings almost always outweigh the build-time cost, but it's worth measuring in very large projects.
Can I use both reflection and source generators in the same project?
Absolutely -- and this is the recommended hybrid approach. Use source generators for high-frequency paths where types are known at compile time (serialization, DI registration, logging). Use reflection for low-frequency or truly dynamic paths (plugin loading, admin tooling, diagnostic introspection). The two tools complement each other.
Do source generators work with NativeAOT in .NET 10?
Yes -- source generators are one of the primary tools for writing AOT-compatible .NET code. Because generators emit real C# code, the trimmer can statically analyze it and know which types are used. Reflection-based dynamic invocation, by contrast, can cause types to be trimmed away and runtime failures in NativeAOT scenarios.
Is System.Text.Json serialization faster with source generators?
Yes, measurably. The source-generated JsonSerializerContext path skips runtime type inspection entirely. For startup-sensitive applications and high-throughput APIs, the difference is significant. In .NET 10, the source-generated path is also the recommended default for new projects because of its AOT compatibility.
When should I NOT use a source generator?
Don't use source generators when the types you need to process are not known at compile time -- for example, in plugin systems that load external assemblies, in generic library code that works with any user-defined types, or in tooling that introspects a running application dynamically. Also avoid them for small, infrequent operations where the added complexity of a Roslyn generator isn't justified.
How do I get started writing a source generator in .NET 10?
Create a new class library project targeting netstandard2.0 (still the common compatibility target for source generator analyzer assemblies), add Microsoft.CodeAnalysis.CSharp as a package reference, implement IIncrementalGenerator, and register it with [Generator]. The SyntaxProvider.ForAttributeWithMetadataName method is the most ergonomic starting point for finding types marked with a specific attribute. Microsoft's documentation and the dotnet/roslyn-sdk samples repository are the best references for the current API surface.
Conclusion
Reflection and source generators are two answers to the same fundamental question: "How do I write code that behaves differently for different types?" The right answer depends almost entirely on when you know which types you'll be working with.
If the types are known at compile time -- which is true for the majority of patterns in typical business applications -- source generators are often the better fit: they produce zero reflection overhead and are fully AOT-compatible. Zero runtime overhead, full AOT compatibility, compile-time error feedback, and better tooling support are hard to argue against. The learning curve for the Roslyn generator API is real, but once you've written one generator, the pattern is repeatable.
If the types are only known at runtime -- plugins, dynamic assembly loading, generic framework libraries -- reflection is still the correct tool. Used carefully with the "reflect once, cache forever" pattern, it remains practical and productive.
The convergence point in .NET 10 is the hybrid application: source generators for the 90% of services and types that are compile-time-known, reflection for the 10% that aren't. Understanding both tools and their boundaries lets you apply each where it actually belongs, rather than defaulting to one out of habit.

