📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are interceptors in C#, and what should a .NET developer know about them? Explain what problem they solve, how a source generator uses InterceptsLocation to redirect a specific call site, the InterceptorsNamespaces opt-in, where .NET already uses them (Request Delegate Generator, configuration binding generator), and why you should not hand-write them.

Awareness of interceptors: purpose, mechanics, opt-in, built-in uses and limits.

Senior Theory Advanced aspnet-core interceptors native-aot roslyn source-generators

🎯 Key points

  • Interceptors let a source generator replace a specific call site with generated code at compile time
  • They shipped experimentally in .NET 8 / C# 12 and are stable from the .NET 9.0.2xx SDK; they are aimed at generator authors
  • The generator marks its method with InterceptsLocation, using opaque location data obtained from Roslyn
  • They only apply in namespaces opted in via the InterceptorsNamespaces MSBuild property
  • The Request Delegate Generator and configuration binding generator use them for reflection-free, AOT-safe code
  • They are not a general aspect-oriented feature and should not be hand-written
  • When behaviour differs, inspect generated files with EmitCompilerGeneratedFiles

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. What problem they solve

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.

2. How it works

// 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
    }
}
  • The generator analyses the call (method, arguments, lambda signature) and emits an intercepting method with a compatible signature.
  • [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.
  • At compile time, the compiler substitutes the call target. At run time there's no lookup — it's an ordinary static call.

3. The opt-in

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.

4. Where .NET already uses them

  • Request Delegate Generator (RDG) for minimal APIs — <EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>, enabled automatically with PublishAot. It intercepts MapGet/MapPost/... calls and generates binding, validation and serialisation code.
  • Configuration binding source generator — <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> (automatic for AOT/trimmed apps). Intercepts ConfigurationBinder.Bind/Get/GetValue and services.Configure<T>(section) / OptionsBuilder.Bind.
  • Options validation, logging, and other generators in the ecosystem increasingly use them; third-party libraries (e.g. Dapper.AOT) use them to make connection.Query<T>(...) calls AOT-friendly.

5. How to inspect them

  • Enable <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> and look for generated files containing [InterceptsLocation].
  • In the IDE, "Go to Definition" on an intercepted call may navigate to the generated interceptor; debugger stepping enters generated code.
  • Binary logs show generator outputs.

6. Why not to hand-write them

  • The location encoding is opaque and versioned; hand-written locations break when code moves.
  • They create spooky action at a distance: the code you read (MapGet) isn't what runs; only tools can tell you.
  • They must be kept semantically identical to the original API, including edge cases — framework teams test this extensively.
  • They're tied to exact call sites; refactoring silently changes which calls are intercepted.
  • The design explicitly targets generators: a generator re-computes locations on every build, keeping them correct.

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.

7. A production example

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.

🔍 What the interviewer is checking

The interviewer checks awareness of a niche but important compiler feature behind AOT-friendly ASP.NET Core.

Green flags

  • Describes interceptors as compile-time redirection of specific call sites by generators.
  • Names RDG and the configuration binding generator.
  • Knows about the namespace opt-in and that the feature moved from experimental (.NET 8) to stable (.NET 9.0.2xx SDK).
  • Says not to hand-write them and how to inspect generated code.

Red flags

  • Confuses them with run-time interception or DI decorators.
  • Wants to use them for logging aspects.
  • Has never heard of them while claiming deep AOT experience.

📚 Learn in depth

1. Compiler mechanics

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).

2. Location encoding

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.

3. Interaction with generators

Generators producing interceptors typically:

  1. Find candidate invocations via SyntaxProvider (e.g. MapGet calls).
  2. Use the semantic model to confirm the target method.
  3. Analyse arguments (route pattern constants, lambda signatures).
  4. Emit an interceptor per call (or shared ones with multiple [InterceptsLocation] attributes).
  5. Fall back gracefully (no interception) when analysis isn't possible, emitting diagnostics.

4. AOT motivation

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.

5. Risks and governance

  • Generated interceptors can hide bugs; review generator-emitted code when debugging.
  • Restrict InterceptorsNamespaces to known generator namespaces.
  • Pin generator package versions; behaviour can change between versions.

Try it yourself

  1. Create a minimal API with PublishAot=true, build with EmitCompilerGeneratedFiles, and find [InterceptsLocation] in generated code.
  2. Enable the configuration binding generator and inspect interceptors for Get<T>().
  3. Pass a handler stored in a variable to MapGet and read RDG diagnostics.
  4. Step into an intercepted call in the debugger.

💻 Code

<!-- 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
{
}

⚠ Common mistakes

1. Hand-writing interceptors

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.

2. Assuming code does what it reads

What happens: debugging MapGet behaviour by reading framework source only. Why it is wrong: generated interceptors may run instead. Do instead: inspect generated files.

3. Patterns that defeat generators

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.

4. Opening InterceptorsNamespaces broadly

What 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).

5. Outdated preview settings

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.

6. Ignoring AOT warnings

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.

7. Confusing interceptors with AOP frameworks

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.

8. Not pinning generator versions

What happens: SDK/package updates change generated behaviour. Why it is wrong: subtle behavioural changes. Do instead: pin versions and test after upgrades.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions RDG or the configuration binder. Why: Interceptors are emitted by source generators.
    How to answer

    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.

  2. When: Candidate says interceptors make code AOT safe. Why: Interceptors exist to remove run-time reflection.
    How to answer

    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.

  3. When: Candidate mentions Native AOT publishing. Why: PublishAot switches these generators on.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Minimal API binding behaved differently after enabling Native AOT
Situation
A team moved a minimal API to Native AOT to cut cold start. Integration tests ran on normal JIT builds.
Signals
After the AOT deployment, one endpoint returned 400 for a query parameter format that had worked in testing.
Investigation
Reproduced locally by enabling EnableRequestDelegateGenerator in a JIT build; inspected the generated interceptor with EmitCompilerGeneratedFiles and saw the binding for a custom type used its static TryParse rather than the reflection-era behaviour the tests relied on.
Resolution
Fixed the custom type's TryParse to accept the format, and enabled the generators in all builds so tests exercise the same code as production.
Prevention
CI now runs the integration suite against the AOT-published binary as well as the JIT build.

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.

↩ Usually asked after

More in Modern C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me