ILLink trimming, TrimMode, trim warnings, DynamicallyAccessedMembers and RequiresUnreferencedCode, feature switches and IsTrimmable.
Trimming (<PublishTrimmed>true</PublishTrimmed>, self-contained only, and always on for NativeAOT) runs the ILLink trimmer at publish to remove types and members that static analysis proves are unreachable, shrinking the app. Reflection is its enemy: code that finds types or members by string at run time may lose them. The trimmer reports this with warnings such as IL2026 (RequiresUnreferencedCode) and IL2070-series (missing DynamicallyAccessedMembers). You make code trim-safe by using source generators instead of reflection, annotating Type parameters with [DynamicallyAccessedMembers], and marking unavoidable reflection with [RequiresUnreferencedCode]. Libraries declare <IsTrimmable>true</IsTrimmable>.
IL trimming (the ILLink trimmer) removes code that the app doesn't use from the published output — unused types, members, and sometimes whole assemblies — by statically analysing which code is reachable from entry points. It's required for NativeAOT and optional for self-contained apps. The challenge is reflection and dynamic code: the trimmer can't see what you access by name at run time, so you make code trim-safe by avoiding unanalysable patterns, annotating what's needed, and fixing warnings.
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed> <!-- self-contained publish; implied by PublishAot -->
<TrimMode>full</TrimMode> <!-- default in .NET 8+: trim everything; 'partial' trims only opted-in assemblies -->
</PropertyGroup>
For libraries: <IsTrimmable>true</IsTrimmable> (or <IsAotCompatible>true</IsAotCompatible>) marks the assembly as trimmable and enables trim analysers during build.
TrimmerRootAssembly, descriptors, [DynamicDependency]).typeof(Order).GetProperties() keeps Order's properties; Type.GetType("Constant.Name") with a constant string is resolved; generic parameters annotated with [DynamicallyAccessedMembers] propagate requirements.If you ignore warnings, the trimmed app may throw at run time (MissingMethodException, null PropertyInfo, JSON serialiser returning empty objects).
Type.GetType(stringFromConfig), Assembly.GetTypes() scanning, Activator.CreateInstance(type) with unknown types.System.Text.Json reflection mode, Newtonsoft.Json), mappers (AutoMapper), DI assembly scanning, MVC/Razor runtime compilation features, BinaryFormatter.dynamic (C# runtime binder), Expression.Compile of reflected members, MakeGenericType with unknown types.Prefer source generators over reflection:
JsonSerializerContext for JSON, [LoggerMessage], [GeneratedRegex], configuration binding generator, RDG for minimal APIs, Mapperly for mapping.Annotate necessary reflection so requirements flow to callers:
public static T Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>()
=> Activator.CreateInstance<T>();
public static void PrintProperties([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type)
{
foreach (var p in type.GetProperties()) Console.WriteLine(p.Name); // no warning: trimmer keeps public properties
}
[RequiresUnreferencedCode("Scans assemblies for handlers; not trim-safe.")]
public static void RegisterHandlersByScanning(IServiceCollection services) { ... } // warning moves to callers
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(LegacyPlugin))]
public static void Init() { ... } // keep specific members
[DynamicallyAccessedMembers] on Type parameters, fields, return values and generic parameters tells the trimmer which members to preserve for types flowing there.[RequiresUnreferencedCode] marks APIs that are inherently not trim-safe; callers get warnings.[UnconditionalSuppressMessage] only when you can prove the pattern is safe (with justification).ILLink.Descriptors.xml) or TrimmerRootAssembly as a last resort to keep whole types/assemblies.Process:
IsTrimmable, EnableTrimAnalyzer) and get to zero warnings.dotnet test on the untrimmed build)..mstat for AOT, size reports) to confirm benefits.IsTrimmable and include trim tests in CI so consumers can rely on it.A self-contained desktop tool was trimmed to cut its size from 90 MB to 35 MB. Users then reported that saving settings produced empty JSON files. The settings class was only used via JsonSerializer.Serialize(object) (reflection), and the trimmer removed its property getters' metadata. The build had shown IL2026 warnings that had been ignored. We introduced a JsonSerializerContext for the settings types, fixed the remaining warnings, and added a CI job that runs end-to-end tests against the trimmed publish output.
The interviewer checks you can ship small, self-contained or AOT apps safely.
Green flags: reachability analysis, reflection risk, IL2026 and DynamicallyAccessedMembers, source generators, IsTrimmable, feature switches, testing trimmed output.
Red flags: suppresses warnings; thinks trimming is safe for any code; does not know how to annotate.
The trimmer works at the IL level across assemblies. In full mode it can remove unused members from all assemblies, including the framework. In partial ("copyused" in older versions), it only trims assemblies marked IsTrimmable. Since .NET 8, most framework libraries are annotated, and full is default.
The trimmer performs intra-procedural data-flow analysis on Type values: if a Type comes from typeof(X) or from an annotated parameter, calls like GetMethod("Name"), GetProperties(), CreateInstance are understood. If a Type comes from an unannotated source (obj.GetType() on an object of unknown type, Type.GetType(variable)), it warns. Annotations make the analysis compositional across methods.
| Code | Meaning |
|---|---|
| IL2026 | Calling a member marked RequiresUnreferencedCode |
| IL2057 | Type.GetType with non-constant string |
| IL2070/IL2072/IL2075 | Type value doesn't satisfy DynamicallyAccessedMembers requirements |
| IL2080/IL2087/IL2090 | Field/generic parameter annotation mismatches |
| IL2104 | Assembly produced trim warnings (when not shown individually) |
| IL3050 | RequiresDynamicCode (AOT-related) |
The framework uses feature switches (<EventSourceSupport>false</EventSourceSupport>, <UseSystemResourceKeys>true</UseSystemResourceKeys>, <InvariantGlobalization>true</InvariantGlobalization>, <DebuggerSupport>false</DebuggerSupport>) that the trimmer treats as constants, removing entire features' code. These can cut size significantly but change behaviour — opt in deliberately.
TrimmerSingleWarn=false to see per-call-site warnings instead of per-assembly summaries.PublishTrimmed=true and run it.JsonSerializerContext.GetProperties with [DynamicallyAccessedMembers].using System.Diagnostics.CodeAnalysis;
Console.WriteLine(Factory.Create(typeof(EmailHandler))); // trim-safe: type is statically known
// Console.WriteLine(Factory.CreateByName("EmailHandler")); // IL2026 at this call site
static class Factory
{
// The trimmer keeps the public parameterless constructor of any type passed here
public static object Create(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type)
=> Activator.CreateInstance(type)!;
// Type name from a string: cannot be analysed, so warn callers
[RequiresUnreferencedCode("Creates types by name; the type may be removed by trimming.")]
public static object CreateByName(string typeName)
=> Activator.CreateInstance(Type.GetType(typeName, throwOnError: true)!)!;
}
sealed class EmailHandler
{
public override string ToString() => "EmailHandler created";
}
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<SelfContained>true</SelfContained>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
</PropertyGroup>
What happens: builds succeed with IL2xxx warnings that are ignored. Why it is wrong: runtime failures in trimmed apps. Do instead: reach zero warnings; treat them as errors.
What happens: unit tests pass on Debug builds. Why it is wrong: trimming removes members only in published output. Do instead: test published trimmed binaries.
What happens: suppressing warnings to get clean builds. Why it is wrong: hides real problems. Do instead: annotate or fix; suppress only with justification.
What happens: TrimmerRootAssembly for everything.
Why it is wrong: removes most size benefits.
Do instead: targeted annotations.
What happens: JsonSerializer.Serialize(object) in trimmed apps.
Why it is wrong: properties may be trimmed.
Do instead: source-generated contexts.
What happens: registering services by scanning types. Why it is wrong: not analysable. Do instead: explicit registration or source-generated registration.
What happens: library authors skip IsTrimmable.
Why it is wrong: consumers get warnings or can't trim safely.
Do instead: annotate and mark libraries trimmable.
What happens: turning off globalisation or event sources. Why it is wrong: behaviour changes (culture formatting, diagnostics). Do instead: choose switches deliberately.
Trimming forces you to make reflection visible, which is good design pressure.
| Pattern | Trim-friendly replacement |
|---|---|
| Reflection JSON | JsonSerializerContext source generation |
| Assembly scanning for DI | Explicit registration or a generator |
| Convention-based mappers | Generated or hand-written mapping |
| Config binding by reflection | Configuration binding source generator |
A senior view: aim for zero warnings in CI; a project with tolerated warnings is not truly trim-safe.
NativeAOT (<PublishAot>true</PublishAot>) compiles the whole app, with the parts of the runtime it needs, into one native executable with no JIT and no IL at run time. You get very fast startup, lower memory and a smaller deployment with no .NET runtime to install. The cost is a closed world: trimming is mandatory, Reflection.Emit and loading new assemblies at run time are not available, reflection works only on what the compiler can see, and libraries must be AOT-compatible. ASP.NET Core supports it for minimal APIs and gRPC but not MVC or Razor Pages. Fix the IL2xxx and IL3xxx warnings before shipping.
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.
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.
How to tell it (STAR)
In [our desktop tool], trimming cut the download size but some commands crashed in release only. The trim warnings pointed at our string-based command lookup. I replaced it with generated factories and made trim warnings errors, so the trimmed build became reliable.
Last updated 31 Oct 2026.