Implementing C# Interceptors with a Minimal Example
c# interceptors: Learn how C# interceptors work, how to write a source generator to emit interceptors, and when compile-time interception suits your codebase.
C# interceptors are a preview feature that lets one method replace a call to another method at compile time. The replacement is chosen by a source generator that sees both the original call site and a candidate method marked with an [InterceptsLocation] attribute. The compiler then routes the call to the interceptor instead of the original implementation.
The feature is controlled by the Microsoft.CodeAnalysis.CSharp.Interceptors experimental type. To enable it, add that type to your project, either by referencing the experimental NuGet package or by declaring a compatible placeholder in your source. The attribute takes a string literal containing a file path and two integers: the line and column of the call to intercept. Because the file path in the attribute must match the actual source file path used during compilation, interceptors are practical only when the call sites are stable, such as in generated code or code you fully control.
A Minimal Interceptor Implementation
The simplest way to see interception in action is to write a source generator that emits an interceptor method. The generator receives a compilation and can inspect syntax trees for calls to a specific method. Here is a minimal generator that intercepts calls to Console.WriteLine:
using System.Collections.Generic; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; [Generator] public sealed class ConsoleInterceptorGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var calls = context.SyntaxProvider.CreateSyntaxProvider( static (node, _) => node is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax ma } && ma.Name.Identifier.Text == "WriteLine", static (ctx, _) => ctx.Node); context.RegisterSourceOutput(calls.Collect(), static (spc, nodes) => { if (nodes.Length == 0) return; var sb = new StringBuilder(); sb.AppendLine("namespace GeneratedInterceptors"); sb.AppendLine("{"); sb.AppendLine(" public static class ConsoleInterceptor"); sb.AppendLine(" {"); foreach (var node in nodes) { var loc = node.GetLocation(); if (loc.SourceTree == null) continue; var lineSpan = loc.GetLineSpan(); var file = loc.SourceTree.FilePath.Replace("\\\\", "\\\\"); var line = lineSpan.StartLinePosition.Line + 1; var col = lineSpan.StartLinePosition.Character + 1; sb.AppendLine($" [InterceptsLocation(\"{file}\", {line}, {col})]"); sb.AppendLine(" public static void InterceptWriteLine(string value)"); sb.AppendLine(" {")); sb.AppendLine(" global::System.Console.WriteLine($\"[intercepted] {value}\");"); sb.AppendLine(" }"); } sb.AppendLine(" }"); }); } }
This generator creates a static method for each Console.WriteLine call it finds. The [InterceptsLocation] attribute tells the compiler to route calls at that exact location to the interceptor. The $ interpolated string then prefixes the original output with [intercepted], so you can see that interception actually happened. The key detail is that the line and column are 1-based while Roslyn uses 0-based positions, so the generator adds 1 to both.
When to Use Source Generators
Writing a generator is the common path for C# interceptors because the attribute's file, line, and column values must exactly match the original call site. A manually maintained interceptor breaks as soon as you move the call to another line. Generators compute the location at build time, so they stay correct as long as the generator runs before the compiler uses the attribute.
A generator fits when the interception logic is uniform across many call sites, such as adding tracing, validating arguments, or substituting a mock implementation. If you need different behaviors at different call sites, the generator has to inspect the surrounding syntax to decide which interceptor method to emit, which adds complexity.
How the Compiler Resolves the Interceptor
The compiler matches an interceptor to a call using the file name and position from [InterceptsLocation]. The file name comparison is ordinary string equality, so the exact spelling matters. A relative path in the attribute may not match the absolute path the compiler uses. In practice, the generator can call SyntaxTree.FilePath to get the exact path, but that path can vary across machines and build environments, which makes interceptors fragile when the same source is built on different agents.
The compiler also requires that the interceptor method is accessible at the call site. It must be in a namespace the caller can reference, and it must be callable with the same arguments as the original method. The generator in the previous example emits the interceptor as public static, so it is accessible from any caller in the same assembly.
A Practical Example: Compile-Time Logging
A more realistic use is intercepting every call to a Log method in a specific class to add a correlation ID or execution time. The generator can inspect the call's arguments and generate a wrapper that adds the extra data.
[InterceptsLocation("Program.cs", 9, 13)] public static void LogWithTimestamp(string message) { Console.WriteLine($"{DateTime.UtcNow:O} {message}"); }
Here the interceptor is declared in a source file, not generated, to show that manual interception is also possible. The attribute must point to the actual call location. When the compiler sees a call to Log at that location, it substitutes LogWithTimestamp. This technique is useful when you want a one-off interception without the overhead of a full generator.
Performance Considerations
Interception happens at compile time, so there is no runtime dispatch or reflection. The generated interceptor method is called directly, which means the IL is nearly identical to calling the original method. The overhead is only what the interceptor itself does, such as formatting a string or computing a timestamp. Because no runtime interception framework is involved, there is no additional allocation or indirection just from using the feature.
The main cost is compile time. A source generator that scans every syntax tree and emits many interceptors can slow the build, especially in large solutions. If the generator uses Collect() as in the example, it also forces the compilation to buffer all matching nodes before emitting output, which increases memory pressure during the build.
Compatibility and Limitations
C# interceptors are experimental and require the Microsoft.CodeAnalysis.CSharp.Interceptors type to be available. The feature is tied to a specific compiler version, so upgrading the SDK can change behavior or break the attribute resolution. You should also be aware that interceptors are not a runtime mechanism; they cannot intercept calls in dependencies that were compiled without the attribute, nor can they intercept calls in code that uses dynamic or reflection.
WebAssembly and AOT scenarios may have additional restrictions because the compiler may not have access to the original source file path in the same way. If you target those platforms, test interception early.
Where Interceptors Fit in Your Toolbox
C# interceptors fill a narrow slot between full AOP frameworks like Castle DynamicProxy and manual code changes. They give you compile-time substitution without runtime proxy overhead, but they require careful build setup and are sensitive to file paths and line positions. Use them when you have a repetitive cross-cutting concern that must be applied at many call sites and your build pipeline can reliably provide the source locations. For scattered or dynamic interception needs, a runtime proxy or manual refactoring remains more maintainable.