Mastering C# 14 Interceptors: Boosting Performance in .NET 10 Microservices
Welcome to 2026! With .NET 10 having firmly established itself as the go-to framework for high-performance, cloud-native applications, the focus has shifted from mere adoption to deep optimization. Developers are now keenly exploring advanced C# 14 features to squeeze every ounce of performance out of their systems. Among these, C# 14 Interceptors stand out as a revolutionary mechanism, offering unprecedented control over method execution at compile time. This capability is proving indispensable for developers aiming for top-tier .NET 10 performance tuning, especially within Native AOT microservices environments where startup times and memory footprints are critical.
Traditional approaches to cross-cutting concerns often involved runtime proxies or reflection-based AOP frameworks, which, while powerful, could introduce performance overhead and were often incompatible with Native AOT compilation. C# 14 Interceptors, however, leverage the power of C# source generators to redirect method calls during compilation, eliminating runtime penalties. This article will dive deep into how this cutting-edge C# 14 feature works, demonstrating its practical application in building high-performance .NET 10 web API optimization strategies for your cloud-native C# applications.
If you're looking to significantly reduce startup times, optimize execution paths, and unlock the full potential of your .NET 10 microservices in a Native AOT context, mastering C# 14 Interceptors is no longer an option—it's a necessity. Let's embark on this journey to elevate your C# development to new heights.
Understanding C# 14 Interceptors
C# 14 Interceptors represent a groundbreaking paradigm shift in how developers can alter program execution flow. At its core, an interceptor is a method that can "intercept" calls to another method at a specific call site within your code. Unlike traditional Aspect-Oriented Programming (AOP) frameworks that often rely on runtime bytecode weaving or dynamic proxies, C# 14 Interceptors operate entirely at compile time. This is a crucial distinction, especially for optimizing Native AOT microservices.
The mechanism behind interceptors is rooted in C# source generators. A source generator, running during the compilation process, analyzes your code, identifies specific call sites marked for interception, and then replaces the original method call with a call to the interceptor method. This redirection happens before the code is even compiled into an assembly, meaning there's absolutely no runtime overhead associated with the interception itself. The compiler simply sees a direct call to your interceptor method instead of the original one.
This compile-time nature makes C# 14 Interceptors perfectly suited for scenarios where runtime performance is paramount. For instance, in high-performance .NET 10 microservices, interceptors can be used for tasks like request caching, logging, metrics collection, authorization checks, or even implementing circuit breakers, all without incurring the performance penalties typically associated with dynamic proxies or reflection. The result is faster startup times, reduced memory consumption, and predictable performance, which are critical for cloud-native C# applications deployed in resource-constrained environments or those requiring rapid scaling.
Key Features and Concepts
Feature 1: Compile-Time Method Redirection via [InterceptsLocation]
The cornerstone of C# 14 Interceptors is the [InterceptsLocation] attribute. This attribute, used within an interceptor method in a source generator, tells the compiler precisely which call site in the user's code it should intercept. The location is specified using file path, line number, and character offset, ensuring an exact match. This highly specific targeting is what differentiates interceptors from broader AOP approaches and makes them incredibly efficient.
When a source generator creates an interceptor method, it annotates it with [InterceptsLocation], providing the precise location of the original method call. During compilation, if the compiler finds a call to the original method at that exact location, it redirects that call to the interceptor method instead. The original method is never actually invoked from that specific call site. This is a powerful mechanism for high-performance .NET 10 applications, as it allows for fine-grained control over execution flow without any runtime reflection or dynamic code generation.
// In your application code (e.g., MyService.cs)
public class MyService
{
public virtual string GetData(int id)
{
// This is the call site we want to intercept
return $"Data for ID: {id}";
}
}
// In your source generator (e.g., MyInterceptorGenerator.cs)
// The attribute needs to be defined in a common assembly or the generator itself.
// For simplicity, we'll assume it's available.
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
file sealed class InterceptsLocationAttribute : Attribute
{
public InterceptsLocationAttribute(string filePath, int line, int character) { }
}
}
// The interceptor method within your source generator output
public static class MyServiceInterceptors
{
[InterceptsLocation("C:\\Path\\To\\MyService.cs", 10, 24)] // Example path, line, char
public static string InterceptGetData(this MyService service, int id)
{
// Custom logic before/after or instead of the original call
Console.WriteLine($"Intercepting GetData for ID: {id}");
// Optionally call the original or return cached data
return $"Intercepted Data for ID: {id}";
}
}
The [InterceptsLocation] attribute allows source generators to programmatically "patch" method calls, making it an ideal tool for implementing cross-cutting concerns in a performance-optimal way.
Feature 2: Native AOT Compatibility and Performance
One of the most significant advantages of C# 14 Interceptors is their inherent compatibility with Native AOT (Ahead-Of-Time) compilation. Native AOT, a key feature in .NET 10, compiles your application directly into machine code, eliminating the need for a JIT (Just-In-Time) compiler at runtime. This results in incredibly fast startup times and a smaller memory footprint, which are critical for cloud-native microservices.
However, Native AOT has strict limitations regarding runtime code generation and reflection. Traditional AOP frameworks that rely on dynamic proxies or reflection-based method invocation often struggle or are entirely incompatible with Native AOT. C# 14 Interceptors bypass these limitations entirely because all the "weaving" or redirection happens during the build process. By the time the Native AOT compiler processes your code, the method calls have already been statically redirected to the interceptor methods.
This compile-time nature means that interceptors introduce zero runtime overhead that would conflict with Native AOT. They contribute directly to faster startup, predictable execution, and a smaller executable size—all essential for high-performance .NET 10 applications and efficient resource utilization in microservice architectures. For cloud-native C# developers targeting minimal cold-start times and maximum throughput, interceptors are a game-changer.
Feature 3: Use Cases in Microservices Architecture
C# 14 Interceptors unlock a new realm of possibilities for optimizing .NET 10 microservices. Their compile-time nature makes them suitable for various cross-cutting concerns without impacting runtime performance.
- Request Caching: Intercept calls to data retrieval methods (e.g., database queries, external API calls) and serve cached results if available, significantly reducing latency and load on backend services. This is a prime example of .NET 10 web API optimization.
- Logging and Telemetry: Automatically log method entry/exit, execution duration, and parameters without cluttering business logic. This provides robust observability for cloud-native C# applications.
- Metrics Collection: Instrument critical methods to collect performance metrics (e.g., call count, average duration) for monitoring and alerting systems.
- Authorization Checks: Intercept calls to sensitive operations and perform permission checks before executing the original method, ensuring granular security.
- Circuit Breakers/Retries: Wrap calls to external dependencies with resilience patterns. If a service is unhealthy, the interceptor can fail fast or retry, preventing cascading failures in a microservices mesh.
- Input Validation: Pre-validate method arguments to ensure they meet specific criteria, reducing boilerplate validation code within business logic.
Each of these use cases benefits immensely from the compile-time nature of interceptors, contributing to high-performance .NET 10 applications that are robust, observable, and efficient.
Implementation Guide
Let's walk through a practical, step-by-step example of implementing C# 14 Interceptors to add a simple caching mechanism to a .NET 10 microservice. We'll create a basic service that fetches data and then use an interceptor to cache the results, demonstrating how to leverage source generators for compile-time method redirection.
Step 1: Set up the .NET 10 Microservice Project
First, create a new .NET 10 Web API project. This will be our target microservice.
dotnet new webapi -n MyCachingMicroservice
cd MyCachingMicroservice
dotnet add package Microsoft.Extensions.Caching.Memory # For simple in-memory caching
Next, define a simple data service and a controller to expose it.
Create a file named Services/IDataService.cs:
// Services/IDataService.cs
namespace MyCachingMicroservice.Services;
public interface IDataService
{
Task GetDataAsync(int id);
}
And its implementation in Services/DataService.cs:
// Services/DataService.cs
using System.Threading.Tasks;
namespace MyCachingMicroservice.Services;
public class DataService : IDataService
{
private static int _callCount = 0;
public async Task GetDataAsync(int id)
{
// Simulate a costly operation
await Task.Delay(100);
_callCount++;
Console.WriteLine($"[DataService] Fetching data for ID {id}. Call count: {_callCount}");
return $"Data for ID {id} (Fetched at {DateTime.UtcNow:HH:mm:ss.fff})";
}
}
Create a controller in Controllers/DataController.cs:
// Controllers/DataController.cs
using Microsoft.AspNetCore.Mvc;
using MyCachingMicroservice.Services;
using System.Threading.Tasks;
namespace MyCachingMicroservice.Controllers;
[ApiController]
[Route("[controller]")]
public class DataController : ControllerBase
{
private readonly IDataService _dataService;
public DataController(IDataService dataService)
{
_dataService = dataService;
}
[HttpGet("{id}")]
public async Task Get(int id)
{
var data = await _dataService.GetDataAsync(id);
return Ok(data);
}
}
Finally, register the service and add memory caching in Program.cs:
// Program.cs
using MyCachingMicroservice.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache(); // Add in-memory caching
builder.Services.AddSingleton();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
At this point, if you run the application and hit /data/1 multiple times, you'll see the "Fetching data..." message every time, indicating no caching.
Step 2: Create a Source Generator Project for Interception
Now, let's create a new project for our source generator. This project will generate the interceptor code.
dotnet new classlib -n MyInterceptorGenerator
cd MyInterceptorGenerator
dotnet add package Microsoft.CodeAnalysis.Analyzers --version 3.3.4
dotnet add package Microsoft.CodeAnalysis.CSharp --version 4.9.2 # Use .NET 10 compatible versions
Edit the MyInterceptorGenerator.csproj file to configure it as a source generator:
net8.0
enable
enable
Preview
true
true
true
$(BaseIntermediateOutputPath)\GeneratedFiles
Now, create the source generator class CachingInterceptorGenerator.cs:
// MyInterceptorGenerator/CachingInterceptorGenerator.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Linq;
using System.Text;
namespace MyInterceptorGenerator;
[Generator]
public class CachingInterceptorGenerator : IIncrementalGenerator
{
// Define the InterceptsLocationAttribute as a file-scoped type
// This allows the attribute to be used by the generated code without a separate assembly.
private const string InterceptsLocationAttributeCode = @"
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
file sealed class InterceptsLocationAttribute : Attribute
{
public InterceptsLocationAttribute(string filePath, int line, int character) { }
}
}";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Add the attribute definition to the compilation
context.RegisterPostInitializationOutput(ctx =>
{
ctx.AddSource("InterceptsLocationAttribute.g.cs", InterceptsLocationAttributeCode);
});
// Find all method calls to IDataService.GetDataAsync
var methodCalls = context.SyntaxProvider.CreateSyntaxProvider(
predicate: static (node, _) => node is InvocationExpressionSyntax invocation &&
invocation.Expression is MemberAccessExpressionSyntax memberAccess &&
memberAccess.Name.Identifier.Text == "GetDataAsync",
transform: static (ctx, _) =>
{
var invocation = (InvocationExpressionSyntax)ctx.Node;
var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(memberAccess.Expression);
var typeSymbol = symbolInfo.Symbol as ILocalSymbol ?? symbolInfo.Symbol as IParameterSymbol ?? symbolInfo.Symbol as IFieldSymbol ?? symbolInfo.Symbol as IPropertySymbol;
if (typeSymbol?.Type?.ToDisplayString() == "MyCachingMicroservice.Services.IDataService")
{
// Get file path, line, and character for the call site
var lineSpan = invocation.GetLocation().GetLineSpan();
var filePath = lineSpan.Path;
var line = lineSpan.StartLinePosition.Line + 1; // 1-based
var character = lineSpan.StartLinePosition.Character + 1; // 1-based
// Extract the argument for GetDataAsync (the 'id')
var argument = invocation.ArgumentList.Arguments.FirstOrDefault();
var argumentText = argument?.Expression.ToString() ?? "0"; // Default or handle error
// Get the fully qualified name of the calling method
var callingMethod = invocation.Ancestors().OfType().FirstOrDefault();
var callingMethodSymbol = callingMethod != null ? ctx.SemanticModel.GetDeclaredSymbol(callingMethod) : null;
var callingMethodFullName = callingMethodSymbol?.ToDisplayString() ?? "UnknownMethod";
return (filePath, line, character, argumentText, callingMethodFullName);
}
return default;
})
.Where(static m => m.filePath != null); // Filter out nulls
context.RegisterSourceOutput(methodCalls, static (ctx, call) =>
{
var (filePath, line, character, argumentText, callingMethodFullName) = call;
// Generate the interceptor method
var source = new StringBuilder();
source.AppendLine("// ");
source.AppendLine($"using System;");
source.AppendLine($"using System.Threading.Tasks;");
source.AppendLine($"using Microsoft.Extensions.Caching.Memory;");
source.AppendLine($"using System.Runtime.CompilerServices;");
source.AppendLine($"using MyCachingMicroservice.Services;"); // Ensure the service namespace is included
source.AppendLine($"namespace MyCachingMicroservice.Interceptors");
source.AppendLine("{");
source.AppendLine($" public static class DataServiceInterceptor");
source.AppendLine($" {{");
source.AppendLine($" // This interceptor will replace the original call at the specified location.");
// The file path here MUST be the absolute path to the file containing the original call
// In a real-world scenario, you'd get this dynamically, e.g., from MSBuild properties.
// For this example, we'll use a placeholder that matches our target.
// IMPORTANT: Replace "PATH_TO_YOUR_PROJECT" with the actual absolute path to your MyCachingMicroservice project.
// Example: [InterceptsLocation("/src/MyCachingMicroservice/Controllers/DataController.cs", 21, 36)]
source.AppendLine($" [InterceptsLocation(@\"{filePath.Replace("\\", "\\\\")}\", {line}, {character})]");
source.AppendLine($" public static async Task InterceptGetDataAsync(this IDataService service, int id)");
source.AppendLine($" {{");
source.AppendLine($" // Access services like IMemoryCache (requires DI, which is tricky for static interceptors directly)");
source.AppendLine($" // For demonstration, we'll use a simplified static cache. In a real app, you'd inject IMemoryCache.");
source.AppendLine($" // For this example, we'll assume a way to get IMemoryCache, e.g., via a static helper or making the interceptor a service.");
source.AppendLine($" // A more robust solution would involve a custom attribute on the original method, which the generator reads,");
source.AppendLine($" // and then generates a class that takes dependencies, and the interceptor calls that generated class.");
// Simplified caching logic for demonstration.
// In a real app, IMemoryCache would be resolved. For a static interceptor, this is complex.
// A common pattern is for the generator to create a class that *can* resolve services,
// and the interceptor method calls into that class.
source.AppendLine($" var cacheKey = $\"DataService_GetDataAsync_{{id}}\";");
source.AppendLine($" // NOTE: In a real app, you'd get IMemoryCache from DI. For simplicity, we'll use a dummy cache here.");
source.AppendLine($" // This example assumes a static cache manager or a way to resolve IMemoryCache.");
source.AppendLine($" // For true DI integration, the generator would need to emit a class that takes IMemoryCache,");
source.AppendLine($" // and the interceptor would then call a static method on that generated class.");
source.AppendLine($" // For now, let's just log and call the original method.");
source.AppendLine($" Console.WriteLine($\"[Interceptor] Checking cache for ID: {{id}}...\");");
source.AppendLine($" // Simulating cache hit/miss for demonstration without actual IMemoryCache");
source.AppendLine($" // In a real scenario, you'd try to get from cache here.");
source.AppendLine($" // For simplicity, we'll just always call the original and log.");
source.AppendLine($" // To actually implement caching, the interceptor would need access to IMemoryCache.");
source.AppendLine($" // This requires the interceptor to be part of the DI graph, or for the generator to emit a wrapper class that does.");
source.AppendLine($" // A simpler approach for a demo is to generate a *different* type of interceptor that wraps the call.");
source.AppendLine($" // For the purpose of showing InterceptsLocation, we'll just log and call the original service.");
source.AppendLine($" // For a complete caching solution with Interceptors and DI, you'd typically:");
source.AppendLine($" // 1. Mark the original method with a custom attribute (e.g., [Cacheable]).");
source.AppendLine($" // 2. The source generator detects this attribute.");
source.AppendLine($" // 3. The generator creates a *new class* (e.g., CachedDataServiceWrapper) that implements IDataService.");
source.AppendLine($" // 4. This new class takes IDataService and IMemoryCache as constructor parameters.");
source.AppendLine($" // 5. The new class's GetDataAsync method implements the caching logic.");
source.AppendLine($" // 6. The generator then emits a *new method* (the interceptor) in a static class.");
source.AppendLine($" // 7. This interceptor's InterceptsLocation targets the *original* call site.");
source.AppendLine($" // 8. The interceptor then calls the *new class's* GetDataAsync method.");
source.AppendLine($" // 9. In Program.cs, you'd register the new wrapper: builder.Services.AddSingleton();");
source.AppendLine($" // This example simplifies to show the basic InterceptsLocation usage.");
source.AppendLine($" // For a direct interceptor, we must get IMemoryCache via a static accessor or similar.");
source.AppendLine($" // Let's create a *static* cache for this demo, which is generally not recommended for production.");
source.AppendLine($" if (StaticCache.TryGet(cacheKey, out string? cachedResult))");
source.AppendLine($" {{");
source.AppendLine($" Console.WriteLine($\"[Interceptor] Cache HIT for ID: {{id}}\");");
source.AppendLine($" return cachedResult!;");
source.AppendLine($" }}");
source.AppendLine($" Console.WriteLine($\"[Interceptor] Cache MISS for ID: {{id}}. Calling original service...\");");
source.AppendLine($" var result = await service.GetDataAsync(id); // Call the original service (or the next in chain)");
source.AppendLine($" StaticCache.Set(cacheKey, result, TimeSpan.FromSeconds(10));");
source.AppendLine($" Console.WriteLine($\"[Interceptor] Data cached for ID: {{id}}.\");");
source.AppendLine($" return result;");
source.AppendLine($" }}");
source.AppendLine($" }}");
// Add a simple static cache for demonstration purposes
source.AppendLine($" internal static class StaticCache");
source.AppendLine($" {{");
source.AppendLine($" private static readonly System.Collections.Concurrent.ConcurrentDictionary _cache = new();");
source.AppendLine($" public static bool TryGet(string key, out string? value)");
source.AppendLine($" {{");
source.AppendLine($" if (_cache.TryGetValue(key, out var entry) && entry.expiry > DateTime.UtcNow)");
source.AppendLine($" {{");
source.AppendLine($" value = entry.value;");
source.AppendLine($" return true;");
source.AppendLine($" }}");
source.AppendLine($" value = default;");
source.AppendLine($" return false;");
source.AppendLine($" }}");
source.AppendLine($" public static void Set(string key, string value, TimeSpan duration)");
source.AppendLine($" {{");
source.AppendLine($" _cache[key] = (value, DateTime.UtcNow.Add(duration));");
source.AppendLine($" }}");
source.AppendLine($" }}");
source.AppendLine("}");
ctx.AddSource($"DataServiceInterceptor.g.cs", source.ToString());
});
}
}
CRITICAL NOTE: The [InterceptsLocation] attribute requires the absolute file path. In a real project, you would typically obtain this path dynamically during the build process, for example, by passing MSBuild properties to the source generator. For this example, you would need to manually update "C:\\Path\\To\\MyService.cs" in the generated code within the source generator to the actual absolute path of your MyCachingMicroservice/Controllers/DataController.cs file. A robust solution for determining the path is a complex topic beyond a simple tutorial, but usually involves AdditionalFiles or MSBuild properties.
Step 3: Reference the Source Generator in the Microservice Project
Now, add a project reference from MyCachingMicroservice to MyInterceptorGenerator. Make sure it's an analyzer reference.
Go back to the root of your solution and run:
dotnet add MyCachingMicroservice/MyCachingMicroservice.csproj reference MyInterceptorGenerator/MyInterceptorGenerator.csproj
Then, edit MyCachingMicroservice.csproj to ensure the generator is referenced correctly:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <!-- Assuming .NET 10 -->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>Preview</LangVersion> <!-- Enable C# 14 features -->
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\GeneratedFiles</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<ItemGroup>