Awareness of interceptors: purpose, mechanics, opt-in, built-in uses and limits.
Interceptors are a compiler feature, first shipped experimentally in .NET 8 / C# 12 and stable from the .NET 9.0.2xx SDK onwards, that lets a source generator replace one specific method call in your code with a call to generated code, at compile time. The generator emits a method marked [InterceptsLocation(...)] that identifies the exact call site; the compiler then emits a call to the interceptor instead of the original method. It exists so generators can make existing APIs reflection-free and AOT-safe without you changing your code: ASP.NET Core's Request Delegate Generator intercepts MapGet/MapPost, and the configuration binding generator intercepts Bind, Get<T> and Configure<T>. You opt in per namespace with the InterceptorsNamespaces MSBuild property (the SDK does this for those generators). Developers should know they exist when debugging, but not hand-write them: the location data is generated and tied to the exact file contents.
Interceptors let a source generator redirect a specific method call in your source code to a different, generated method — at compile time, without you changing the call site. They exist to replace reflection-heavy framework APIs with generated, AOT-friendly code while keeping familiar APIs like app.MapGet(...) and config.Get<T>(). As a .NET developer, you mostly need to know they exist, how they're enabled, where .NET uses them, how to see what was intercepted, and why you shouldn't hand-write them.
Some APIs are inherently dynamic: app.MapGet("/orders/{id}", (int id, IOrderService svc) => ...) must inspect the lambda's parameters to build request binding, and section.Get<SmtpOptions>() must discover properties to bind. Historically this used reflection and runtime code generation — slow start-up and incompatible with NativeAOT/trimming.
A source generator can generate specialised binding code for each call, but it can't modify your code to call the generated method. Interceptors close that gap: the generator emits a method marked with [InterceptsLocation] pointing at the exact call site, and the compiler rewrites that call to target the generated method instead.
// Your code (unchanged)
app.MapGet("/orders/{id:int}", (int id, IOrderService svc) => svc.FindAsync(id));
// Generated by the Request Delegate Generator (simplified)
file static class GeneratedRouteBuilderExtensions
{
[InterceptsLocation(1, "zP3lV2...base64 location data...")] // identifies the exact call
internal static RouteHandlerBuilder MapGet0(this IEndpointRouteBuilder endpoints,
[StringSyntax("Route")] string pattern, Delegate handler)
{
// generated, reflection-free parameter binding and response writing
}
}
[InterceptsLocation] identifies the call site. In early previews it took file path + line + column; current versions use a versioned, opaque encoding (InterceptsLocation(int version, string data)) produced by Roslyn APIs (GetInterceptableLocation), so it's robust to path differences and not meant to be written by hand.Interceptors must be enabled for the namespace containing the generated interceptor methods:
<PropertyGroup>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated</InterceptorsNamespaces>
</PropertyGroup>
(Earlier SDKs used InterceptorsPreviewNamespaces.) In practice, the .NET SDK and NuGet packages set this automatically when you enable their generators, so most developers never touch it. The opt-in prevents arbitrary generators from silently rewriting calls.
The feature started as experimental in C# 12/.NET 8 and was moved towards a supported model for source generator authors in the .NET 9 timeframe (with the new location format); it's not intended as a general language feature for application code.
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>, enabled automatically with PublishAot. It intercepts MapGet/MapPost/... calls and generates binding, validation and serialisation code.<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> (automatic for AOT/trimmed apps). Intercepts ConfigurationBinder.Bind/Get/GetValue and services.Configure<T>(section) / OptionsBuilder.Bind.connection.Query<T>(...) calls AOT-friendly.<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> and look for generated files containing [InterceptsLocation].MapGet) isn't what runs; only tools can tell you.If you want to change behaviour for your own code, use normal techniques: wrappers, extension methods, decorators, DI, or a source generator that generates new APIs you call explicitly.
Publishing a minimal API with NativeAOT produced trimming warnings from reflection-based endpoint binding and configuration binding. Enabling the SDK's RDG and configuration binding generator (automatic with PublishAot) made the warnings disappear; inspecting the emitted files showed interceptors for each MapGet/MapPost and Configure<T> call. When a developer later wrote app.MapGet(route, handlerFromVariable) with a delegate stored in a variable, RDG couldn't analyse it and emitted a warning — we refactored to inline lambdas so the generator could intercept.
The interviewer checks awareness of a niche but important compiler feature behind AOT-friendly ASP.NET Core.
Green flags
Red flags
During binding, when the compiler resolves a method invocation, it checks whether any method in the compilation has an [InterceptsLocation] attribute matching that invocation's location (and whether its namespace is opted in). If so, the call's target is replaced with the interceptor method (signature compatibility is checked: this parameter/receiver, parameter types, ref kinds). Only calls can be intercepted (not property accesses or constructors in the original design), and only in source (not calls compiled in other assemblies).
Roslyn's SemanticModel.GetInterceptableLocation(InvocationExpressionSyntax) returns an InterceptableLocation with Version and Data (a checksum of the file content plus position), and GetInterceptsLocationAttributeSyntax() produces the attribute text for generators. Because it includes a content checksum, any edit to the file invalidates old locations — generators regenerate them on each build.
Generators producing interceptors typically:
SyntaxProvider (e.g. MapGet calls).[InterceptsLocation] attributes).Without interceptors, AOT-friendly versions would need new APIs (app.MapGetGenerated(...)) that developers must adopt manually. Interceptors let frameworks keep existing APIs while swapping implementations at compile time — a big ergonomic win for NativeAOT adoption.
InterceptorsNamespaces to known generator namespaces.PublishAot=true, build with EmitCompilerGeneratedFiles, and find [InterceptsLocation] in generated code.Get<T>().MapGet and read RDG diagnostics.<!-- Api.csproj: enabling generators that use interceptors -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Both are switched on automatically when PublishAot is true -->
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
<!-- See what was generated, including the interceptors -->
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
</Project>
// Program.cs: ordinary code; the generators intercept these calls at compile time
using System.Text.Json.Serialization;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default));
// Intercepted by the configuration binding generator: no reflection
var shipping = builder.Configuration.GetSection("Shipping").Get<ShippingOptions>() ?? new ShippingOptions();
var app = builder.Build();
// Intercepted by the Request Delegate Generator: parameter binding generated at build time
app.MapGet("/quote/{postcode}", (string postcode) =>
new Quote(postcode, postcode.StartsWith('6') ? shipping.RemoteRate : shipping.StandardRate));
app.Run();
public sealed class ShippingOptions
{
public decimal StandardRate { get; set; } = 9.95m;
public decimal RemoteRate { get; set; } = 19.95m;
}
public sealed record Quote(string Postcode, decimal Price);
[JsonSerializable(typeof(Quote))]
internal partial class ApiJsonContext : JsonSerializerContext
{
}
What happens: developers write [InterceptsLocation] attributes manually.
Why it is wrong: opaque, fragile locations and hidden behaviour.
Do instead: use wrappers or generators that create explicit APIs.
What happens: debugging MapGet behaviour by reading framework source only.
Why it is wrong: generated interceptors may run instead.
Do instead: inspect generated files.
What happens: handlers stored in variables or built dynamically. Why it is wrong: generators can't analyse them; falls back to reflection (AOT warnings). Do instead: inline lambdas or method groups with static signatures.
InterceptorsNamespaces broadlyWhat happens: enabling interception for arbitrary namespaces. Why it is wrong: any generator could rewrite calls. Do instead: limit to known generator namespaces (usually set by the SDK).
What happens: using InterceptorsPreviewNamespaces and old location formats.
Why it is wrong: newer SDKs use the versioned format and new property.
Do instead: follow current SDK guidance.
What happens: assuming generators handle everything. Why it is wrong: some call shapes aren't intercepted. Do instead: treat AOT/trim warnings as errors in AOT projects.
What happens: expecting runtime interception (like Castle proxies). Why it is wrong: interceptors are compile-time call redirection only. Do instead: use decorators/middleware for runtime cross-cutting concerns.
What happens: SDK/package updates change generated behaviour. Why it is wrong: subtle behavioural changes. Do instead: pin versions and test after upgrades.
Interceptors are part of the same strategy as source generators: move work from run time to compile time so .NET apps start faster and can use Native AOT. Their special trick is doing this without changing user code, which is why Microsoft could make minimal APIs and configuration binding AOT-friendly without new APIs.
The senior perspective is about observability of behaviour: when an interceptor is active, the code you read at a call site is not the code that runs. That matters in debugging, code review and incident analysis.
| Concern | Practice |
|---|---|
| Tests versus production differences | enable the same generators in all builds |
| Debugging surprises | EmitCompilerGeneratedFiles, read the generated file |
| Library design | prefer explicit generated APIs unless redirecting existing calls is essential |
| Upgrades | read SDK release notes for generator and interceptor changes |
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.
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.
By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.
How to tell it (STAR)
**Situation:** after we published our [quote API] with Native AOT, one endpoint started rejecting requests that worked in tests. **Task:** I had to explain the difference and stop it happening again. **Action:** I learned that AOT turns on the Request Delegate Generator, which intercepts MapGet calls, so production ran different binding code than our tests. I reproduced it by enabling the generator locally, read the generated file, fixed our TryParse, and changed CI to test the published AOT binary. **Result:** the bug was fixed in [a day], and we caught [two] more AOT-only differences before they reached production.
Last updated 30 Oct 2026.