C# 14: Introducing Interceptors
Introduction
C# 14 ships with a feature that sounds almost too good to be true: the ability to replace a method call with a different method call, transparently, at compile time — without modifying the original code, without interfaces, without proxies, and without any runtime overhead. That feature is called an interceptor.
It became a fully stable language feature with C# 14 and .NET 10, after spending two years in preview since C# 12. This article explains what an interceptor is, how it differs from everything you already know, and why it matters for your day-to-day .NET development.
The core idea in one sentence
An interceptor is a method that tells the compiler: “whenever you see a call to method X at this exact location in the source code, call me instead.”
That’s it. The caller writes perfectly normal code. The compiler silently reroutes the call. The result is baked into the compiled binary, there is no indirection, no wrapper, no performance cost at runtime.
Analogy
Think of it like a GPS rerouting your journey before you leave home, not while you’re driving. By the time the car moves, the new route is already the only route. The original road still exists, but your specific trip was redirected at planning time, not at driving time.
What it looks like in practice
Imagine you have a library with a simple Logger.Log method, and somewhere in your application you call it:
Without an interceptor, that call goes directly to Logger.Log at runtime. With an interceptor in place, the compiler replaces that specific call site with a generated method, before any binary is produced:
The developer’s source file never changes. The original Logger.Log method still exists in the library. But at that specific call site, it is never invoked, the interceptor took its place entirely.
How does the compiler know which call to intercept?
This is the part that makes interceptors unique. The compiler does not intercept all calls to a method, it intercepts a specific call at a specific location in the source code. That location is identified by an attribute placed on the interceptor method:
The data parameter of [InterceptsLocationAttribute] is a base64 string that encodes the file path, line number, column, and a content hash of the call site. It is recalculated by the compiler on every build — which is why interceptors must always be generated by a Source Generator, never written by hand.
If you hardcode the data value, your project will compile once and then silently break the next time any developer adds a line above the intercepted call, with no compiler error, just incorrect runtime behavior. The encoded position is not human-maintainable.
Compile-time vs runtime, why it matters
Most cross-cutting concern mechanisms in .NET operate at runtime. Interceptors operate at compile time. This distinction has profound consequences:
- Dynamic proxies (Castle Windsor, Autofac) generate wrapper classes at application startup using reflection and IL emit. They require virtual methods or interfaces and are incompatible with Native AOT.
- IL weaving (PostSharp, Fody) rewrites the compiled IL after the build. It works without interfaces but operates on bytecode — the transformation is invisible in the source and in the IDE.
- Interceptors (C# 14) replace the call site during compilation, before any IL is produced. No reflection, no virtual dispatch, no runtime overhead. Fully visible in the generated .g.cs file. Native AOT compatible.
The practical consequence is that an intercepted call is indistinguishable from a direct call in the compiled binary. The CPU executes the same instruction sequence it would if you had written the substitution yourself.
What can you actually do with interceptors?
Interceptors are not a general-purpose tool for everyday coding. They are designed for library and framework authors who want to transparently optimize or augment call sites without changing the developer experience. Here are concrete scenarios where they shine:
- Zero-allocation logging, replace ILogger.LogInformation(message, args) with a pre-compiled LoggerMessage.Define delegate, eliminating the params object[] boxing on every call.
- Automatic header injection, intercept every HttpClient.SendAsync call to inject correlation IDs, without requiring a DelegatingHandler registration that developers can forget.
- Native AOT support, replace reflection-heavy calls with source-generated equivalents at compile time, making previously AOT-incompatible code work without changes. This is already done by ASP.NET Core internally for Minimal API routing.
- Compile-time validation, intercept configuration access to verify that keys exist in appsettings.json at build time, turning runtime null surprises into compiler errors.
- Transparent telemetry, wrap specific call sites with OpenTelemetry Activity spans without polluting the business logic.
What interceptors are not
It is equally important to understand what interceptors are not designed for, to avoid misuse.
- They are not a replacement for dependency injection. Interceptors work on specific call sites identified at compile time. They cannot dynamically swap implementations based on runtime conditions or configuration.
- They are not meant to be written by hand. The encoded position value in [InterceptsLocationAttribute] changes every time the source file is modified. Only a Source Generator can maintain this reliably.
- They do not intercept all calls to a method. Each interceptor targets a specific call site — a specific line in a specific file. If the same method is called in ten different places, you need ten interceptors (which a generator produces automatically).
Who should use interceptors directly? If you are building a NuGet package, a framework, or an internal platform library that wants to transparently optimize or augment consumer code, interceptors are for you. If you are writing application business logic, you will benefit from interceptors through the libraries you consume, not by writing them yourself.
The relationship with Source Generators
Every interceptor in practice is produced by a Source Generator. The generator’s job is to walk the consumer’s syntax tree, find the target call sites, ask Roslyn for their encoded positions via SemanticModel.GetInterceptableLocation(), and emit a C# file containing the substitution methods with the correct [InterceptsLocationAttribute] values.
The consumer project opts in by declaring the generator’s namespace in <InterceptorsNamespaces> in its .csproj. This is a deliberate safety mechanism — no third-party package can silently intercept your calls without your explicit consent.
The relationship between interceptors and Source Generators is not incidental, it is by design. Interceptors give Source Generators the one capability they previously lacked: the ability to modify existing call sites, not just add new code alongside them.
Conclusion
An Interceptor is a compile-time call site substitution. It lets a Source Generator transparently replace a specific method call with another implementation, with zero runtime overhead, full Native AOT compatibility, and complete invisibility to the developer writing the consumer code. It is not a replacement for dependency injection, not a general-purpose AOP tool, and not something you write by hand. It is a precise, surgical mechanism for framework and library authors who need to optimize or augment call sites across an entire codebase without touching a single line of business logic.
In the next article, we put this into practice: building a Source Generator that intercepts every HttpClient.SendAsync call to automatically inject correlation headers, no DelegatingHandler, no manual plumbing, no way for a developer to accidentally forget it.