Making Reflection Native AOT Safe in .NET 10: DynamicallyAccessedMembers Guide
If you've ever tried to publish a .NET application with Native AOT enabled, you've probably run into a wall of warnings -- and possibly a wall of runtime crashes. C# reflection native AOT compatibility is one of the trickiest areas to get right when you're moving your codebase toward ahead-of-time compilation in .NET 10. The trimmer removes types and members it thinks are unreachable at compile time, and reflection -- which resolves types dynamically -- can silently break when those members disappear.
This guide walks you through exactly what's happening, what the warnings mean, and how to fix them the right way. You'll learn to use [DynamicallyAccessedMembers], [RequiresUnreferencedCode], and [RequiresDynamicCode] correctly, understand when source generators are the permanent answer, and build a practical checklist to audit your own codebase.
Why Native AOT and Reflection Don't Mix by Default
Native AOT compilation works by analyzing your code statically and stripping out everything that can't possibly be reached at runtime. This is great for startup time, binary size, and cold-start performance in serverless or container workloads. But it creates a fundamental tension with reflection.
Reflection is inherently dynamic. When you write Type.GetType("MyApp.SomeService"), the trimmer has no idea at publish time that MyApp.SomeService will be needed. So it removes it. At runtime, your reflection call returns null -- or worse, throws a TypeLoadException that's very hard to diagnose.
The same problem applies to:
Activator.CreateInstance(type)whentypeis only known at runtimeMethodInfo.Invokeon a method discovered by nametypeof(T)whereTis passed through without annotations- Expression tree compilation (which emits IL dynamically)
If you want to understand how reflection works before diving into the AOT constraints, check out Reflection in C#: 4 Simple But Powerful Code Examples for a solid foundation.
How the .NET Trimmer Works
When you publish with <PublishTrimmed>true</PublishTrimmed> or <PublishAot>true</PublishAot>, the trimmer (ILLink) performs a rooted reachability analysis. It starts from your entry points and static constructors, follows every static reference it can see, and marks everything reachable. Anything unmarked is removed from the output.
The trimmer understands normal method calls, field accesses, and type references just fine. What it can't track is indirect type access -- anything that resolves a type or member based on a runtime string or Type object that was only known at runtime.
When the trimmer spots a call pattern it can't statically analyze, it emits a warning:
IL2075: 'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to
'System.Type.GetMethod(String)'. The return value of method
'System.Type.GetType(String)' does not have matching annotations.
These warnings are your map. Each one points to a place where you either need to annotate properly, switch to a source generator, or acknowledge you're doing something inherently unsafe.
Understanding the ILLink Warnings
The IL2xxx warning codes follow a consistent pattern:
- IL2026 -- A member decorated with
[RequiresUnreferencedCode]is being called - IL2060 -- A call to a generic method whose type argument may not survive trimming
- IL2067 / IL2068 -- A parameter or return value is missing a
[DynamicallyAccessedMembers]annotation - IL2070 / IL2072 / IL2075 -- A
thisargument to a reflective API lacks the needed annotation - IL2111 -- A method with a
[DynamicallyAccessedMembers]-annotated parameter is being referenced via reflection
The root cause is almost always the same: somewhere in your call chain, a Type value flows from a place without annotations into a reflective API that needs them. The fix is to propagate the annotations consistently through your entire call graph.
The [DynamicallyAccessedMembers] Attribute
DynamicallyAccessedMembersAttribute is your primary tool -- and it has been available since .NET 5, so it is not a .NET 10-exclusive feature. You apply it to a Type parameter, field, property, or generic type parameter to tell the trimmer: "I am going to reflect on this type, and here's specifically what I need to survive trimming."
using System.Diagnostics.CodeAnalysis;
public static object CreateInstance(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type)
{
return Activator.CreateInstance(type)!;
}
When the trimmer sees this annotation, it understands two things:
- Any type passed to this parameter needs to have its public constructors preserved.
- The responsibility for ensuring that propagates to every call site.
The annotation flows through your call graph automatically. If you annotate a parameter with [DynamicallyAccessedMembers], the trimmer will enforce that any value supplied for that parameter either also has the annotation, or is a known concrete type that the trimmer can preserve explicitly.
DynamicallyAccessedMemberTypes Values
The DynamicallyAccessedMemberTypes enum lets you be precise about what you need. Requesting too much forces the trimmer to preserve more than necessary; requesting too little means your reflection calls will fail at runtime.
| Value | What it preserves |
|---|---|
PublicConstructors |
All public .ctor overloads |
NonPublicConstructors |
All non-public .ctor overloads |
PublicMethods |
All public instance and static methods |
NonPublicMethods |
All non-public instance and static methods |
PublicFields |
All public instance and static fields |
NonPublicFields |
All non-public fields |
PublicProperties |
All public properties (includes backing members) |
NonPublicProperties |
All non-public properties |
PublicEvents |
All public events |
Interfaces |
All implemented interfaces |
All |
Everything -- use only as last resort |
The principle is the same as with dependency injection lifetimes: be as specific as possible. All is the "I give up" option. It works, but it defeats the purpose of trimming.
Code Example: Annotating a Generic Factory
Here's a common pattern -- a factory that creates instances of T via reflection:
// ❌ Produces IL2077 -- T's constructors may be trimmed
public static T Create<T>() where T : class
{
return (T)Activator.CreateInstance(typeof(T))!;
}
The fix is to annotate the generic type parameter:
// ✅ Trim-safe -- trimmer knows to preserve public constructors of T
public static T Create<T>()
where T : class
{
return (T)Activator.CreateInstance(typeof(T))!;
}
// The constraint flows automatically. But if you're passing Type explicitly:
public static object Create(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type)
{
return Activator.CreateInstance(type)!;
}
Wait -- for the generic case, the annotation on T is actually applied using a where attribute on the type parameter itself, like this:
public static T Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>()
where T : class
{
return (T)Activator.CreateInstance(typeof(T))!;
}
This tells the trimmer: "Whoever calls Create<MyClass>(), make sure MyClass's public constructors are preserved." The annotation propagates upward to every concrete call site.
For more on Activator.CreateInstance patterns, see Activator.CreateInstance in C# - A Quick Rundown which covers the common pitfalls even in non-AOT scenarios.
Code Example: Annotating a Service Locator
Service locators that resolve types by string name are a classic AOT problem:
// ❌ Unsafe -- both Type.GetType and Activator.CreateInstance are dynamic
public static object Resolve(string typeName)
{
var type = Type.GetType(typeName) ?? throw new InvalidOperationException($"Type not found: {typeName}");
return Activator.CreateInstance(type)!;
}
There's no way to make Type.GetType(string) trim-safe because the string value is not known at compile time. The trimmer cannot know which type to preserve. Your options here are:
Option A: Pre-register types with a dictionary
private static readonly Dictionary<string, Type> _registry = new()
{
["EmailService"] = typeof(EmailService),
["SmsService"] = typeof(SmsService),
};
public static object Resolve(string name)
{
if (!_registry.TryGetValue(name, out var type))
throw new KeyNotFoundException(name);
return Activator.CreateInstance(type)!;
}
By using typeof(EmailService) directly, the trimmer sees the reference and preserves the type.
Option B: Mark the method with [RequiresUnreferencedCode]
[RequiresUnreferencedCode("This method uses Type.GetType and is not trim-safe.")]
public static object Resolve(string typeName)
{
var type = Type.GetType(typeName) ?? throw new InvalidOperationException($"Type not found: {typeName}");
return Activator.CreateInstance(type)!;
}
[RequiresUnreferencedCode]: Acknowledging You Can't Make It Safe
Sometimes reflection is genuinely too dynamic to annotate. Maybe you're loading a plugin from a user-supplied path, or resolving types from a configuration file. In these cases, you can't tell the trimmer what to preserve -- you don't know at compile time.
[RequiresUnreferencedCode] is the honest answer:
[RequiresUnreferencedCode("Loads plugin types from external assemblies. Not compatible with trimming.")]
public IEnumerable<IPlugin> LoadPlugins(string pluginDirectory)
{
// ... assembly loading and reflection ...
}
This does three things:
- Suppresses the IL2xxx warnings inside the method body (you've acknowledged the risk).
- Emits an IL2026 warning at every call site, forcing callers to also opt in.
- Documents the intent in a machine-readable way.
You suppress it at a call site like this:
[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "PluginHost is only used in non-AOT deployments.")]
public void Initialize()
{
var plugins = LoadPlugins(_config.PluginDirectory);
// ...
}
Use [UnconditionalSuppressMessage] sparingly. Every suppression is a place where you've told the trimmer to trust you. Be sure you mean it.
For plugin scenarios specifically, see Plugin Architecture in C#: The Complete Guide to Extensible .NET Applications for patterns that are easier to make AOT-compatible.
[RequiresDynamicCode]: Expression Trees and Dynamic IL
AOT compilation goes further than just trimming: it also prohibits runtime IL generation. This affects:
System.Linq.Expressions.Expression.Compile()System.Reflection.Emit(DynamicMethod, AssemblyBuilder, etc.)Delegate.CreateDelegateon dynamic types- Some serializers and mappers that code-generate internally
[RequiresDynamicCode] is the parallel attribute for these scenarios:
[RequiresDynamicCode("Compiles expression trees at runtime. Not compatible with Native AOT.")]
public static Func<T, TResult> BuildAccessor<T, TResult>(string propertyName)
{
var param = Expression.Parameter(typeof(T), "x");
var body = Expression.Property(param, propertyName);
var lambda = Expression.Lambda<Func<T, TResult>>(body, param);
return lambda.Compile(); // This line requires dynamic code generation
}
In .NET 10, the AOT-compatible path for expression tree-like scenarios is almost always source generators or compile-time code generation via Roslyn analyzers.
Source Generators: The Permanent Solution
[DynamicallyAccessedMembers] is a band-aid when you can't change the architecture. Source generators are the cure. They move the reflection logic to compile time, generating static, trimmer-visible code instead of dynamic runtime lookups.
For example, instead of scanning assemblies at startup to find all IValidator<T> implementations via reflection, a source generator can emit a registration method at compile time that explicitly lists every implementation.
The pattern is: reflection at build time, not runtime.
This is exactly the approach taken by libraries like Needlr. Source Generation vs Reflection in Needlr covers the trade-offs in detail, and Automatic Dependency Injection in C#: The Complete Guide to Needlr shows how the compile-time approach enables both performance and AOT compatibility.
If you're doing assembly scanning for service discovery, Assembly Scanning in Needlr: Filtering and Organizing Type Discovery shows how to move that into a source-generated pattern as well.
Practical AOT Compatibility Checklist
Here's a checklist to work through when auditing a .NET 10 codebase for Native AOT readiness:
1. Enable trimmer warnings
Add these properties to your project file and fix every warning before enabling AOT:
<PropertyGroup>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
</PropertyGroup>
Or go straight to AOT analysis:
<PropertyGroup>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
2. Search for reflective APIs
Grep for these patterns -- each is a candidate for a warning:
Type.GetType(Assembly.GetType(Activator.CreateInstance(GetMethod(,GetProperty(,GetField(MethodInfo.Invoke(Expression.Compile(Emit.
3. Annotate call chains consistently
Every Type parameter that flows into a reflective API needs [DynamicallyAccessedMembers]. Start from the reflective call site and trace backwards. Annotate every intermediate method, property, and field that carries the Type value.
4. Replace Type.GetType(string) with direct typeof() references
Where possible, switch from string-based type resolution to direct type references. A dictionary from string to Type with typeof() values is both faster and trim-safe.
5. Mark genuinely dynamic code with [RequiresUnreferencedCode]
Don't pretend something is trim-safe when it isn't. Mark it honestly, then decide whether to redesign it or accept the limitation in non-AOT deployments.
6. Evaluate source generators for high-traffic reflection paths
If you're reflecting on thousands of objects per second (serialization, mapping, etc.), source generators eliminate both the performance penalty and the AOT incompatibility. For a comparison of the approaches, see ConstructorInfo - How To Make Reflection in DotNet Faster for Instantiation which covers pre-caching strategies before you reach for code generation.
7. Test your AOT binary explicitly
Don't just look for clean warning output -- actually publish and run the binary:
dotnet publish -r win-x64 --self-contained -p:PublishAot=true
Then run your integration tests against the published artifact. Some trimming issues only manifest at runtime.
8. Use root descriptor files only as a last resort
Root descriptor files (rd.xml) are available as an escape hatch for third-party libraries or dynamic reflection patterns that cannot be annotated at source level -- prefer [DynamicallyAccessedMembers] and [RequiresUnreferencedCode] annotations in your own code first. If you must use an rd.xml file, scope it as narrowly as possible and document why annotation at source level wasn't an option.
FAQ
What is the difference between PublishTrimmed and PublishAot in .NET 10?
PublishTrimmed applies the IL trimmer to a regular .NET runtime application -- it reduces binary size by removing unused code, but the runtime is still the JIT-based CoreCLR. PublishAot compiles your entire application ahead-of-time to native machine code using NativeAOT, which also implies trimming. PublishAot is a superset: it also prohibits runtime IL generation and has stricter requirements. In .NET 10, PublishAot is the target to aim for in latency-sensitive or container-cold-start scenarios.
Can I use [DynamicallyAccessedMembers] on a generic type parameter?
Yes. Apply it directly to the type parameter in angle brackets:
public static T CreateService<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>()
where T : class
{
return (T)Activator.CreateInstance(typeof(T))!;
}
This tells the trimmer that whichever concrete type is substituted for T must have its public constructors preserved.
Does [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] just fix everything?
It suppresses the trimmer warnings for that type, yes -- but it forces the trimmer to preserve every member of every type passed to that parameter. This completely defeats the purpose of trimming for those types. Use All only as a short-term fix while you investigate a proper solution.
What happens if I suppress IL2026 incorrectly?
If you suppress IL2026 but the code really does use reflection on a trimmed member, the application will fail at runtime -- not at compile time. You might get TypeNotFoundException, MissingMethodException, NullReferenceException from a GetMethod returning null, or other hard-to-diagnose failures. Be careful with suppressions: they exist to tell the trimmer you've taken responsibility for a known-safe pattern, not to make warnings disappear.
How do I handle third-party libraries that are not yet AOT-compatible?
This is one of the most common real-world blockers. Your options are:
- Check if the library has a newer version with AOT annotations (many popular libraries added these in .NET 8 and 9).
- File an issue with the library maintainer and use
[RequiresUnreferencedCode]to mark your code as using a non-trim-safe dependency. - Replace the library with an AOT-compatible alternative (e.g.,
System.Text.Jsoninstead of Newtonsoft.Json). - Maintain separate publishing configurations -- AOT for the core path, trimmed (non-AOT) for the plugin/interop layer.
Does Native AOT work with dependency injection in .NET 10?
Yes. Microsoft.Extensions.DependencyInjection has been AOT-compatible since .NET 8. The key is using AddSingleton<TInterface, TImplementation>() with concrete type parameters (not string-based registration), so the trimmer can see the type references statically. For assembly-scanning-based auto-registration patterns, source generators are the recommended approach -- the DI container itself is fine, but the scanning layer needs to be compile-time.
Should I aim for Native AOT for all my .NET 10 applications?
Not necessarily. Native AOT is most valuable for: serverless functions (cold start), CLIs, containerized microservices where startup time matters, and mobile/embedded scenarios. For long-running ASP.NET Core applications or services where JIT warm-up amortizes over millions of requests, the trade-off (stricter constraints, some library incompatibilities, longer publish times) may not be worth it. In .NET 10, AOT tooling has improved significantly, but evaluating the compatibility of your specific dependency graph before committing is still important.
Conclusion
Making your C# code compatible with Native AOT in .NET 10 is not about avoiding reflection entirely -- it's about being explicit with the trimmer about what you need. [DynamicallyAccessedMembers] is your precision instrument: it preserves exactly what you annotate and nothing more. [RequiresUnreferencedCode] and [RequiresDynamicCode] are your honest acknowledgements when the code genuinely can't be made safe.
The long-term direction is clear: source generators move reflection to compile time, giving you the dynamism you need with none of the runtime risk. For new features in .NET 10 codebases, start with source-generated patterns. For existing reflection-heavy code, work through the IL2xxx warnings methodically -- each one is a clear, actionable signal.
AOT compatibility isn't a one-time task. It's a discipline you build into your development workflow so that the publish-time safety net stays clean as the codebase grows.

