Skip to content
Ian Griffiths By Ian Griffiths Technical Fellow I
ASP.NET Core 8.0 uses C# 12.0's experimental interceptors to enable AOT

Many new C# language features first appeared as experimental features that can be enabled by adding settings to your project. The compiler team makes these available to give developers a chance to try out proposed new features and provide feedback before they are permanently integrated into the language. Generic math was previewed in this way, for example. (Sometimes preview features are later completely removed, as sadly happened with the !! syntax for parameter null checking) The current (C# 12.0) compiler offers one such preview feature called interceptors. These effectively enable code generators to replace existing code instead of just augmenting it.

Even though interceptors are experimental, ASP.NET Core 8.0 turns out to be using them already. In general you should avoid relying on preview features. Presumably the ASP.NET Core team talked to the C# team and came to some arrangement to ensure that even if the feature changes, they will still be able to achieve the same effect once it is no longer in preview. You might not be able to exert quite as much influence over the C# team as the ASP.NET Core team can, so caution is advised.

A limitation of source generators.

For several years, C# has supported source generators, components that generate code that gets added to your project at compile time. The .NET SDK has built-in generators for regular expressions and JSON serialization that can improve runtime performance by performing additional work at compile time. And in the JSON serialization case, these code generators can enable use of Native AOT by removing any runtime use of reflection.

Source generators have always been purely additive: all they can do is add new source code to the target project. This enables them to generate new types, and, if you use the partial keyword, they can add new members to existing types. But source generators have never been able to modify existing source code.

This limitation means that in some scenarios where people had hoped to use source generators, they have been disappointed. Interceptors change that.

C# interceptors

An interceptor is essentially some code that replaces code in another file. When you applying an [InterceptsLocation] attribute to some code, the attribute's properties indicate which code is being replaced. When I first saw one of these, I was shocked by how crude it looked:

public static class Interceptor
{
    [InterceptsLocation(@"C:\dev\CsInterceptors\BasicInterception\Program.cs", 1, 13)]
    public static void InterceptsTargetMethod(int input)
    {
        Console.WriteLine($"Interceptor called: {input}");
    }
}

That attribute contains the full path to a source file, and the line and column number of the method call to be rewritten. Here's the Program.cs file it refers to:

Intercepted.TargetMethod(42);

public static class Intercepted
{
    public static void TargetMethod(int input)
    {
        Console.WriteLine("This code won't run");
    }
}

Since the line and column number are 1 and 13 respectively in this example, that InterceptsLocationAttribute refers to the TargetMethod text on the first line.

The thing that surprised me is how very fragile this seems—it depends on the code being in a specific place on your filesystem, and the use of line and column numbers means this attribute might need to be updated any time the target source file changes, even it it were just a layout change, or the addition or removal of a comment.

In practice this doesn't really matter because interceptors aren't supposed to be written by humans. The idea is that interceptors will be produced by source generators. Since source generators run from scratch every time you compile, they can freely generate attributes like this that embed the full path of where the code happens to be right now. The output of a source generator doesn't get put under source control, so a generated [InterceptsLocation] attribute will always be ephemeral. If you build on a different machine and with a different source file location, the source generator will just use the new location when it emits code with this attribute.

The way interceptors work is pretty straightforward: instead of invoking the method the developer asked to invoke, the compiler will instead generate code that invokes the method annotated with the InterceptsLocation attribute. So although the example looks like it should display the message This code won't run, the presence of the interceptor means that it will instead display Interceptor called: 42.

There are a few conditions. First, the interceptor method needs to have the same shape as the one being intercepted—in this example, they both take a single int and return nothing. Second, you need to add a property to your project file to enable the interceptor:

<PropertyGroup>
  <InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);MyInterceptors</InterceptorsPreviewNamespaces>
</PropertyGroup>

If you apply the InterceptsLocationAttribute to a type defined in a namespace not listed in InterceptorsPreviewNamespaces, the only effect will be to produce a compiler warning.

Finally, since this is a preview feature, the .NET runtime libraries don't actually include a definition for InterceptsLocationAttribute, so you are obliged to supply your own:

namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
internal sealed class InterceptsLocationAttribute : Attribute
{
    public InterceptsLocationAttribute(string filePath, int line, int column)
    {
    }
}

If this feature ships in something like its current form, presumably a suitable attribute will be added to the runtime libraries, but for now, code generators wishing to use this feature emit a definition of the attribute. (As with most compiler-recognized attributes, such as the nullable types annotations, the compiler doesn't care where an attribute is defined. As long as it has the expected name and constructor arguments, the compiler will happily use it. This is how you can add nullability annotations to netstandard2.0 components despite .NET Standard 2.0 not defining the relevant attributes.)

ASP.NET Core 8.0, interceptors, and Native AOT

ASP.NET Core 8.0 uses interceptors to enable us to write this sort of code when using Native AOT:

app.MapGet("/", () => "Hello World!");

ASP.NET Core has, for most of its history, relied on reflection to discover the shape of our handler methods. That's true for MVC, and it's also the case for most uses of the newer minimal APIs. But that causes problems for some modern .NET build techniques such as trimming and Native AOT. Reflection makes it harder for trimming to be effective, and can prevent the use of Native AOT entirely.

To run on Native AOT, where reflection is, for the most part, unavailable, ASP.NET Core needs all of the information it would normally obtain through reflection to be made available through other means. Code generators are an ideal mechanism for this. (The JSON serializer code generator operates on exactly this principle.) However, making the use of code generators explicit would run counter to the design ethos behind minimal handlers. The whole idea there is that you can write very simple handlers with minimal ceremony, writing just enough to describe what you want. As your scenarios get more advanced, the code gets more complex, e.g.:

app.MapGet("/{id}", ([FromRoute] int id, 
                     [FromQuery(Name = "p")] int page, 
                     [FromServices] Service service, 
                     [FromHeader(Name = "Content-Type")] string contentType) => { });

But the basic principle is one of pay-for-play. Simplicity is the default. If you choose to use an optional feature, there should be a minimal increment in complexity. But ASP.NET Core has always used reflection to enable this flexibility. How can it continue to do that in a Native AOT world where reflection is not practical?

ASP.NET Core 8.0 uses interceptors to deal with this. The .NET SDK includes a RequestDelegateGenerator source generator. It's disabled by default, but you can enable it by setting the EnableRequestDelegateGenerator build property to true. (This happens automatically if your project file sets either PublishTrimmed or PublishAot to true.)

When enabled, this source generator creates interceptors that effectively rewrite calls to MapGet. Any use of MapGet that would normally rely on runtime use of reflection (i.e., most overloads of MapGet) gets intercepted. The interceptor is a generated method that calls an alternative mapping mechanism that does not rely on reflection. Instead, it passes in a data structure containing all of the information that ASP.NET Core would otherwise have retrieved from the reflection API.

The gory details

The generator creates quite a lot of code, and it's not really necessary to understand it in detail to understand the principle of operation of interceptors. But if you're curious, here's what the source generator creates for the very simple Hello world endpoint shown above:

[InterceptsLocation(@"C:\dev\CsInterceptors\Net80AspNetCoreInterception\Program.cs", 4, 5)]
internal static RouteHandlerBuilder MapGet0(
    this IEndpointRouteBuilder endpoints,
    [StringSyntax("Route")] string pattern,
    Delegate handler)
{
    MetadataPopulator populateMetadata = (methodInfo, options) =>
    {
        Debug.Assert(options != null, "RequestDelegateFactoryOptions not found.");
        Debug.Assert(options.EndpointBuilder != null, "EndpointBuilder not found.");
        options.EndpointBuilder.Metadata.Add(new System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.RequestDelegateGenerator, Version=8.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", "8.0.0.0"));
        options.EndpointBuilder.Metadata.Add(new ProducesResponseTypeMetadata(statusCode: StatusCodes.Status200OK, contentTypes: GeneratedMetadataConstants.PlaintextContentType));
        return new RequestDelegateMetadataResult { EndpointMetadata = options.EndpointBuilder.Metadata.AsReadOnly() };
    };
    RequestDelegateFactoryFunc createRequestDelegate = (del, options, inferredMetadataResult) =>
    {
        Debug.Assert(options != null, "RequestDelegateFactoryOptions not found.");
        Debug.Assert(options.EndpointBuilder != null, "EndpointBuilder not found.");
        Debug.Assert(options.EndpointBuilder.ApplicationServices != null, "ApplicationServices not found.");
        Debug.Assert(options.EndpointBuilder.FilterFactories != null, "FilterFactories not found.");
        var handler = Cast(del, global::System.String () => throw null!);
        EndpointFilterDelegate? filteredInvocation = null;
        var serviceProvider = options.ServiceProvider ?? options.EndpointBuilder.ApplicationServices;
        var jsonOptions = serviceProvider?.GetService<IOptions<JsonOptions>>()?.Value ?? FallbackJsonOptions;
        var jsonSerializerOptions = jsonOptions.SerializerOptions;
        jsonSerializerOptions.MakeReadOnly();
        var objectJsonTypeInfo = (JsonTypeInfo<object?>)jsonSerializerOptions.GetTypeInfo(typeof(object));

        if (options.EndpointBuilder.FilterFactories.Count > 0)
        {
            filteredInvocation = GeneratedRouteBuilderExtensionsCore.BuildFilterDelegate(ic =>
            {
                if (ic.HttpContext.Response.StatusCode == 400)
                {
                    return ValueTask.FromResult<object?>(Results.Empty);
                }
                return ValueTask.FromResult<object?>(handler());
            },
            options.EndpointBuilder,
            handler.Method);
        }

        Task RequestHandler(HttpContext httpContext)
        {
            var wasParamCheckFailure = false;
            if (wasParamCheckFailure)
            {
                httpContext.Response.StatusCode = 400;
                return Task.CompletedTask;
            }
            var result = handler();
            if (result is string)
            {
                httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
            }
            else
            {
                httpContext.Response.ContentType ??= "application/json; charset=utf-8";
            }
            return httpContext.Response.WriteAsync(result);
        }

        async Task RequestHandlerFiltered(HttpContext httpContext)
        {
            var wasParamCheckFailure = false;
            if (wasParamCheckFailure)
            {
                httpContext.Response.StatusCode = 400;
            }
            var result = await filteredInvocation(EndpointFilterInvocationContext.Create(httpContext));
            if (result is not null)
            {
                await GeneratedRouteBuilderExtensionsCore.ExecuteReturnAsync(result, httpContext, objectJsonTypeInfo);
            }
        }

        RequestDelegate targetDelegate = filteredInvocation is null ? RequestHandler : RequestHandlerFiltered;
        var metadata = inferredMetadataResult?.EndpointMetadata ?? ReadOnlyCollection<object>.Empty;
        return new RequestDelegateResult(targetDelegate, metadata);
    };
    return MapCore(
        endpoints,
        pattern,
        handler,
        GetVerb,
        populateMetadata,
        createRequestDelegate);
}



internal static RouteHandlerBuilder MapCore(
    this IEndpointRouteBuilder routes,
    string pattern,
    Delegate handler,
    IEnumerable<string>? httpMethods,
    MetadataPopulator populateMetadata,
    RequestDelegateFactoryFunc createRequestDelegate)
{
    return RouteHandlerServices.Map(routes, pattern, handler, httpMethods, populateMetadata, createRequestDelegate);
}

That might look like a lot of code, and it may seem odd that this would speed anything up. But this is all work that ASP.NET Core 8.0 would otherwise be doing at runtime. This just makes it easier to see how much work it has to do to enable us to write these simple handlers. With this build-time code generation, some of the necessary work can be done at compile time, so this can enable faster startup, partly by avoiding the use of reflection, and partly by leaving less work until runtime.

What will happen if the interceptor feature changes?

One of the risks of relying on a preview feature is that the feature might go away in future releases. Interceptors are a preview feature, so is our code at risk of breaking in the future if we rely on this ASP.NET Core 8.0 feature today? The answer is no. Use of MapGet in combination with Native AOT is fully supported by ASP.NET Core 8.0, so if you use this today, your code won't break if the C# compiler changes how interceptors work.

But it's not obvious why it's OK for the ASP.NET Core team to rely on a preview feature. In this particular scenario, it's OK because of the following facts:

  1. interception happens at build-time.
  2. ASP.NET Core is a critically important .NET feature, so the ASP.NET Core team will have secured some sort of agreement with the C# compiler team that even if the interceptor feature changes, it will still be possible to achieve the same effect.
  3. This code generator is built into the SDK, and so is the compiler, so even if this preview feature changes, you'll always be using a version of the code generator that is aligned with a version of the compiler that it will work with (unless you deliberately substitute a different compiler version).

The first point means that there's no chance of future runtime changes breaking code that has already been built using this feature. (Some other C# features that first appeared as previews did depend on runtime behaviour, notably default interface implementation, and generic math. It would not have been possible for any team at Microsoft to offer a fully supported feature that relied on those capabilities while they were still in preview.)

The second point means that the C# team aren't going to remove the capability entirely. They might completely change how it works, but one way or another, it's going to be possible to achieve the same end result.

The third point means that applications relying on this ASP.NET Core feature don't need to worry about problems if the underlying C# compiler feature changes in the future. In normal use, the compiler version we get is determined by the SDK version we use. This means that ASP.NET Core is effectively released in lock-step with C# compiler versions. If the compiler changes in a way that means ASP.NET Core also needs to change, both those changes will ship in the same new SDK version.

That's why it's OK for the ASP.NET Core team to use this preview feature. I would advise anyone who doesn't ship their code as part of the .NET SDK to be cautious about depending on interceptors for as long as they are in preview.

Conclusion

Interceptors are a quite basic but very powerful mechanism, enabling source code generators to replace any method invocation with a call to some other code. They can make it possible to push more work out of runtime and into the build, which can improve runtime performance, and may also be able to completely avoid any need to use reflection. This in turn can unlock benefits of trimming and AOT compilation. ASP.NET Core 8.0 is already using this, but projects outside of the .NET SDK should exercise caution, because interceptors are still a preview feature, and may well change in future compiler versions.

Ian Griffiths

Technical Fellow I

Ian Griffiths

Ian has worked in various aspects of computing, including computer networking, embedded real-time systems, broadcast television systems, medical imaging, and all forms of cloud computing. Ian is a Technical Fellow at endjin, and Microsoft MVP in Developer Technologies. He is the author of O'Reilly's Programming C# 10.0, and has written Pluralsight courses on WPF (and here) and the TPL. He's a maintainer of Reactive Extensions for .NET, Reaqtor, and endjin's 50+ open source projects. Technology brings him joy.