blog post image
Andrew Lock avatar

Andrew Lock

~17 min read

Exploring the new minimal API source generator

Exploring the .NET 8 preview - Part 4

In this series I look at some of the new features coming in the .NET 8 previews. In this post, I look at the new minimal API source generator introduced to support AOT workloads.

As these posts are all using the preview builds, some of the features may change (or be removed) before .NET 8 finally ships in November 2023!

How do minimal APIs work?

Minimal APIs were introduced in .NET 6 as part of an effort to provide a more "minimal" getting-started experience for ASP.NET Core. Prior to .NET 6, with a basic API template, you typically had at least 3 classes—Program.cs, Startup.cs, and an API controller— multiple conventions to understand. Minimal APIs were part of the solution to that issue (in conjunction with WebApplicationBuilder).

With minimal APIS, a simple HelloWorld solution looks something like this:

WebApplicationBuilder builder = new WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

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

app.Run();

This all looks relatively simple (that's the point!) but behind the scenes, that Map() function is doing quite a lot!

It's doing so much in fact, that I wrote a whole 8 part series on what it's doing!

Behind the scenes, the RequestDelegateFactory inspects the delegate passed as the endpoint handler (() => "Hello World" in this case). RequestDelegateFactory then uses the Reflection.Emit APIs to generate a RequestDelegate that can be invoked in response to a request. This generated RequestDelegate does a lot of work:

It's a lot of work, but the design ensures that the resulting generated code is as close to what "hand-rolled" code would look like. That means it's about as efficient as it can be, and much more efficient than the MVC framework, where you pay for all the abstractions (like filters) whether or not you use them.

The problem with the default minimal API design for .NET 8 is that it's incompatible with AOT.

Why do we need a source generator?

Ahead-of-time (AOT) compilation is one of the headline features being worked on for .NET 8. In AOT .NET applications there is no Just-in-time (JIT) compiler; all the compilation occurs at build time, not runtime. As I described in a previous post, that can be beneficial for startup times, but it has drawbacks. No JIT means no "plugin architecture" applications, and, importantly for minimal APIs: no Reflection.Emit!

To make minimal APIs compatible with AOT in .NET 8 a different approach was necessary. And that approach, required source generators. As David Fowler said in a document exploring the feasibility of AOT for minimal APIs:

The only way for something like ASP.NET Core, [and] likely lots of other frameworks to get trimmer clean, is to build source generators.

OK, so we want source generators, but what does that actually mean in practice? Minimal APIs already exist, and already call existing methods and extension methods:

app.MapGet("/", () => "Hello world!");
app.MapGet("/{name}", (string name) => "Hello {name}!");

How do source generators help here?

As you'll see shortly, the overall design of source generators that target AOT is to replace the method you think you're calling with a different one that's more amenable to AOT. This is the approach used by the configuration source generator that I described in a previous post. The configuration source generator provides overloads in the global:: namespace that are preferred by the compiler. The generated, AOT-friendly, methods are preferentially bound and invoked instead of the "normal" reflection-based APIs.

.NET 8 preview 6 shipped a new experimental feature as part of C#12: interceptors. This feature aims to do the "replacement" of methods in a much more direct way, instead of relying on namespace tricks to have generated methods invoked instead. The minimal API generator was recently updated to support interceptors, which I expect to drop in preview 7!

Coming back to the minimal API generator, the goal is to create overloads for all the MapGet() and MapPost() methods, such that the generator methods are selected, instead of the "normal" extension methods. As described in the original GitHub issue for this feature:

For the purposes of the request delegate generator, we want to be able to invoke the logic in the RouteEndpointDataSource but provide a custom implementation of a `Map`` method that will allow the user to pass custom delegates for inferring metadata and producing a request delegate. The source generator will call this overload with implementations generated at compile time to circumvent calling APIs that use runtime code generation.

That may all sound a bit confusing (and if you've read any of my "behind the scenes of minimal APIs" series you'll know why - because it is confusing😅 To understand, I like to look through the actual code to see how things work, so that's what we'll do. We'll start with a very simple minimal API application, enable the minimal API source generator, and see how it works!

Enabling the minimal API source generator

The main purpose of the minimal API source generator is to be AOT-friendly, so the source generator is automatically enabled if you enable AOT publishing in your application. As I described in a previous post, the new api template introduced in .NET 8 includes an --aot option that sets <PublishAot>true</PublishAot> (among other things). If you're using this template, or you've set PublishAot=true in your project, you're already good to go with the source generator.

AOT is obviously the main focus of the source generator, but you may see start time improvements even when not using AOT. Instead of minimal APIs dynamically generating the code for each handler's RequestDelegate at runtime, the source generator can do this all ahead of time. Less work on startup might equate to faster startup/first request times, but I don't think it's a major goal for the generator, just a nice potential benefit.

I ran a small test using TimeitSharp, that starts a minimal API app, sends a single request (to trigger the runtime code generation), and then exits. I ran the test without AOT, both with the source-generator enabled and with it disabled. On average (100 runs), the source-generator shaved ~50ms off the total run time, but without the generator there was a big long-tail to the startup time(~450ms vs 500ms). So a modest improvement, but nothing compared to the improvement from full AOT.

Nevertheless, let's say you want to try the source generator out without AOT publishing. Create a new empty .NET 8 project using

dotnet new web

By default, .NET uses the most recent SDK installed on your machine. You can control which SDK is used by placing a global.json file in your directory. For example, I typically put a global.json in my repository root directory that defaults to the latest stable release (i.e. .NET 7), and then add a global.json to explicity use the preview SDK on a per-sub folder basis, by setting allowPrerelease: true.

You can enable the minimal API source generator by adding EnableRequestDelegateGenerator to your project file:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- 👇 Add this line -->
    <EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
  </PropertyGroup>

</Project>

And that's it! If all went well, you probably won't notice any differences in your app. But if you hit F12 (Go to Definition) on the app.MapGet() call in Program.cs in your IDE you should be taken to some source generated code! In the next section we'll dig into this code to see how it works, but first we'll add some simple APIs to our app, just to show how the generator handles multiple APIs in an application:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");
app.MapGet("/ping", () => "Pong!");
app.MapGet("/{name}", (string name) => "Hello {name}!");

app.Run();

So we now have:

  • 2 endpoints that simply return a string
  • 1 endpoint that binds to a string route parameter then returns a string

Now let's dig through the code to see how it works!

Intercepting method calls using source generators

When you first hit F12, you'll be taken to one of the source generated MapGet() extension methods. This is really the magic in the approach—instead of binding to the normal EndpointRouteBuilderExtensions.MapGet() method, the compiler binds to your generated methods instead!

The code in this post corresponds to the code generated as of .NET 8 preview 6. I expect the details of this code to change before .NET 8 ships in November. After all, it's already changed!

If we look at the original extension, the signature looks something like this:

public static RouteHandlerBuilder MapGet(
    this IEndpointRouteBuilder endpoints,
    string pattern,
    Delegate handler);

In contrast, the generated methods look something like this:

internal static RouteHandlerBuilder MapGet(
    this IEndpointRouteBuilder endpoints,
    string pattern,
    Func<string> handler,
    [CallerFilePath] string filePath = "",
    [CallerLineNumber]int lineNumber = 0)

Both of these methods are extensions on IEndpointRouteBuilder, and both take a string as the second parameter. However, the third parameter in the original method is a Delegate, whereas in the generated MapGet(), the third parameter is a Func<string>. This is the magic: the compiler favours the Func<string> method as it's "more specific" than the Delegate method.

The generated method also has two optional caller information parameters (which we'll come back to later).

The exact form of the Func<> handler parameter is adapted to match the endpoint handler's you've defined in your application. In our test example we have just two different forms: Func<string> for the handlers that just return a string, and Func<string, string> for the hander that also accepts a string as a route parameter.

In the generated code, a separate MapGet() overload is created for each of these formats. The following shows the MapGet() overloads created for our app: the first MapGet() that uses a Func<string> matches the / and /ping endpoints; the second MapGet() that uses a Func<string, string> matches the /{name} endpoint:

// This class needs to be internal so that the compiled application
// has access to the strongly-typed endpoint definitions that are
// generated by the compiler so that they will be favored by
// overload resolution and opt the runtime in to the code generated
// implementation produced here.
internal static class GenerateRouteBuilderEndpoints
{
    private static readonly string[] GetVerb = new[] { HttpMethods.Get };

    internal static RouteHandlerBuilder MapGet(
        this IEndpointRouteBuilder endpoints,
        string pattern,
        Func<string> handler, // 👈 Supports the / and /ping endpoints
        [CallerFilePath] string filePath = "",
        [CallerLineNumber]int lineNumber = 0)
    {
        return GeneratedRouteBuilderExtensionsCore.MapCore(
            endpoints, pattern, handler, GetVerb, filePath, lineNumber);
    }

    internal static RouteHandlerBuilder MapGet(
            this IEndpointRouteBuilder endpoints,
            string pattern,
            Func<string, string> handler, // 👈 Supports the /{name} endpoints
            [CallerFilePath] string filePath = "",
            [CallerLineNumber]int lineNumber = 0)
        {
            return GeneratedRouteBuilderExtensionsCore.MapCore(
                endpoints, pattern, handler, GetVerb, filePath, lineNumber);
        }
}

Note that I've simplified the samples above compared to the actual generated code, by removing the fully qualified namespaces and some attributes. It's a good practice to always specify the full namespace for all types in source generated code (for example using global::System.Func<global::System.String> instead of Func<string>), as it avoids type resolution issues.

Both of the new MapGet() overloads call into the MapCore() method, which is another generated extension method:

file static class GeneratedRouteBuilderExtensionsCore
{
    internal static RouteHandlerBuilder MapCore(
        this IEndpointRouteBuilder routes,
        string pattern,
        Delegate handler,
        IEnumerable<string>? httpMethods,
        string filePath,
        int lineNumber)
    {
        // Use the filePath and lineNumber as an index into the map dictionary
        var (populateMetadata, createRequestDelegate) = map[(filePath, lineNumber)];
        // Pass the functions to the minimal API internals
        return RouteHandlerServices.Map(routes, pattern, handler, httpMethods, populateMetadata, createRequestDelegate);
    }
}

This is where things get interesting. MapCore calls into a static Dictionary which is indexed on a (string, int) tuple, and which returns a tuple of two Func<>s. These Func<>s are the equivalent of methods in RequestDelegateFactory that are used for compiling the minimal API endpoints in .NET 6 and .NET 7

using MetadataPopulator = Func<MethodInfo, RequestDelegateFactoryOptions?, RequestDelegateMetadataResult>;
using RequestDelegateFactoryFunc = Func<Delegate, RequestDelegateFactoryOptions, RequestDelegateMetadataResult?, RequestDelegateResult>;
  • MetadataPopulator has the same method signature as RequestDelegateFactory.InferMetadata(), and does the same job: it infers metadata about the endpoint's parameters, as I described in a previous post.
  • RequestDelegateFactoryFunc has the same signature as RequestDelegateFactory.Create(), which is what typically creates the executable RequestDelegate, as described in a previous post.

These Func<>s are compile-time drop-in replacements for the runtime generated versions used in .NET 6 and .NET 7. Keeping the signature the same makes the replacement reliable and relatively frictionless.

The GitHub issue describing this approach is, 'Add Map implementation with overload for InferMetadata and CreateRequestDelegate', which was implemented in this PR. The PR added the RouteHandlerServices.Map() method called by MapCore() that takes these functions as parameters, instead of always using the runtime-generation version.

What I find particularly interesting is the map dictionary. The GenerateRouteBuilderEndpoints.MapGet() overloads are what ensure the source generator methods get called (Func<string> is selected over Delegate by the compiler), but map is how the source generator differentiates between two different endpoints that have the same signature, like () => "Hello World!" and () => "Pong!" in our example.

The new "Interceptors" features removes the need for this dictionary, which simplifies things somewhat. ~~However, as interceptors are going to remain experimental until .NET 9, the minimal API source generator will use the dictionary approach by default~~. It turns out I was wrong about that! See my next post for details. 🙂

You can see in the generated code below that the (string, int) key to the dictionary comes from the [CallerFilePath] and [CallerLineNumber] arguments passed in the MapGet() overloads, This tuple is guaranteed to be unique, so it works perfectly as a dictionary key, where the (Func<>, Func<>) value defines the minimal API generated RequestDelegate functions (I've elided the Func<> in the code below, as they're so big!)

file static class GeneratedRouteBuilderExtensionsCore
{
    private static readonly Dictionary<(string, int), (MetadataPopulator, RequestDelegateFactoryFunc)> map = new()
    {
        [(@"C:\repos\temp\temp25\Program.cs", 4)] = // () => "Hello World!"
        (
            (methodInfo, options) => { /*   */ }, // MetadataPopulator
            (del, options, inferredMetadataResult) => { /*   */ }, // RequestDelegateFactoryFunc
        ),
        [(@"C:\repos\temp\temp25\Program.cs", 5)] = // () => "Pong!"
        (
            (methodInfo, options) => { /*   */ }, // MetadataPopulator
            (del, options, inferredMetadataResult) => { /*   */ }, // RequestDelegateFactoryFunc
        ),
        [(@"C:\repos\temp\temp25\Program.cs", 6)] = // (string name) => "Hello {name}!"
        (
            (methodInfo, options) => { /*   */ }, // MetadataPopulator
            (del, options, inferredMetadataResult) => { /*   */ }, // RequestDelegateFactoryFunc
        ),
    }
}

Before we look at the generated RequestDelegateFunc and MetadataPopulator functions themselves, we'll take a quick look at the new RouteHandlerServices.Map() overload, which is called by the source generated MapCore() function:

public static class RouteHandlerServices
{
    /// <summary>
    /// Registers an endpoint with custom functions for constructing
    /// a request delegate for its handler and populating metadata for
    /// the endpoint. Intended for consumption in the RequestDelegateGenerator.
    /// </summary>
    public static RouteHandlerBuilder Map(
            IEndpointRouteBuilder endpoints,
            string pattern,
            Delegate handler,
            IEnumerable<string>? httpMethods,
            Func<MethodInfo, RequestDelegateFactoryOptions?, RequestDelegateMetadataResult> populateMetadata,
            Func<Delegate, RequestDelegateFactoryOptions, RequestDelegateMetadataResult?, RequestDelegateResult> createRequestDelegate)
    {
        return endpoints
              .GetOrAddRouteEndpointDataSource()
              .AddRouteHandler(RoutePatternFactory.Parse(pattern),
                               handler,
                               httpMethods,
                               isFallback: false,
                               populateMetadata,
                               createRequestDelegate);
    }
}

As you can see, this is a pretty simple method, it first gets the RouteEndpointDataSource, and then calls AddRouteHandler() on it, (just as the "normal" MapGet() function does). The only difference here is that this approach uses a new AddRouteHandler method which allows passing in the Func<> to populate metadata and the Func<> to generate the RequestDelegate, whereas typically these functions are generated at runtime.

That pretty much covers how the generated code plumbs in the generated RequestDelegate; it's now time to look at the RequestDelegateFunc and MetadataPopulator functions.

Looking at the generated RequestDelegateFunc

In this section I'll look at the MetadataPopulator and RequestDelegateFunc for a single endpoint:

app.MapGet("/{name}", (string name) => "Hello {name}!");

In my previous minimal API series, I went into minute detail about how model binding works behind the scenes, and how minimal APIs handle different parameters and return types, so I'm not going to cover that all here. Instead, consider this a "representative example" for a simple endpoint. The good news is that the source generator code will (hopefully) look pretty much identical to the runtime-generated code, so the same general principles and approaches from that series apply here too.

Remember that these functions are defined as two parts of a tuple, in the map dictionary:

file static class GeneratedRouteBuilderExtensionsCore
{
    private static readonly Dictionary<(string, int), (MetadataPopulator, RequestDelegateFactoryFunc)> map = new()
    {
        [(@"C:\repos\temp\temp25\Program.cs", 6)] = // (string name) => "Hello {name}!"
        (
            (methodInfo, options) => { /*   */ }, // MetadataPopulator
            (del, options, inferredMetadataResult) => { /*   */ }, // RequestDelegateFactoryFunc
        ),
    }
}

We'll start by looking at MetadataPopulator implementation. Where possible, I've removed assertions and namespaces for brevity:

(methodInfo, options) =>
{
    options.EndpointBuilder.Metadata.Add(new SourceKey(@"C:\repos\temp\temp25\Program.cs", 6));
    options.EndpointBuilder.Metadata.Add(new GeneratedProducesResponseTypeMetadata(type: null, statusCode: StatusCodes.Status200OK, contentTypes: GeneratedMetadataConstants.PlaintextContentType));
    return new RequestDelegateMetadataResult { EndpointMetadata = options.EndpointBuilder.Metadata.AsReadOnly() };
},

As you can see, this simple endpoint doesn't have much metadata to add. It adds a SourceKey (which is a simple record-like type), though I couldn't find anywhere that's actually used—maybe in filters? The only other metadata added is the response type, which is documented as always being 200 OK and text/plain.

Interestingly, in the RequestDelegateFactoryFunc below, you'll see that this endpoint can actually also return a null JSON object, so there may be a bug here somewhere 🤔 I haven't dug into it properly to confirm one way or the other yet!

Right, it's time to brace yourself and dive in to the RequestDelegateFunc! This is the code that actually runs when your endpoint is invoked, so it includes all the model binding and response serialization code. I've tidied it up a little below to make it easier to read and added a few comments, but otherwise it's essentially unchanged. So don't blame me if it's a little hard to follow😉

Some of the code in this example is common across all of the RequestDelegateFunc implementations, while some of it is specific to the endpoint. But the real beauty is that you can set breakpoints in this code for your endpoints in your minimal API apps, and step-through when debugging. That could be invaluable for people who don't want to read my 8 part series on what normally happens behind the scenes in minimal APIs😅

(Delegate del, RequestDelegateFactoryOptions options, RequestDelegateMetadataResult? inferredMetadataResult) =>
{
    var handler = (Func<string, string>)del; // The endpoint delegate cast to its native type
    EndpointFilterDelegate? filteredInvocation = null; // if the endpoint has any filters, this will be non-null later
    var serviceProvider = options.ServiceProvider ?? options.EndpointBuilder.ApplicationServices;

    // 👇 A helper type that is used to handle when the model binding is invalid, 
    // e.g. if a required argument is missing, or the body of the Request is 
    // not the right type. Either throws an exception or logs the error, depending
    // on your minimal API configuration
    var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options);

    // The JSON configuration to use when serializing to and from JSON
    var jsonOptions = serviceProvider?.GetService<IOptions<JsonOptions>>()?.Value ?? new JsonOptions();
    var objectJsonTypeInfo = (JsonTypeInfo<object?>)jsonOptions.SerializerOptions.GetTypeInfo(typeof(object));

    // Helper function that tries to fetch a named argument value. Tries the route parameters
    // first, and then the querystring
    Func<HttpContext, StringValues> name_RouteOrQueryResolver = 
        GeneratedRouteBuilderExtensionsCore.ResolveFromRouteOrQuery("name", options.RouteParameterNames);

    // If the endpoint has any filters, this builds and applies them, using more generated code
    // that I don't show in this post. Very similar to the non-source generated version.
    // See my previous blog post for details:
    // https://andrewlock.net/behind-the-scenes-of-minimal-apis-8-customising-the-request-delegate-with-filters/
    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(ic.GetArgument<string>(0)!));
        },
        options.EndpointBuilder,
        handler.Method);
    }

    // This is the RequestDelegate implementation that runs when the endpoint
    // does NOT have any filters. If the endpoint does have filters, a different method is
    // used, RequestHandlerFiltered, shown below.
    Task RequestHandler(HttpContext httpContext)
    {
        // Try to resolve the `name` argument
        var wasParamCheckFailure = false;
        // Endpoint Parameter: name (Type = string, IsOptional = False, IsParsable = False, IsArray = False, Source = RouteOrQuery)
        var name_raw = name_RouteOrQueryResolver(httpContext);
        if (name_raw is StringValues { Count: 0 })
        {
            wasParamCheckFailure = true;
            logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "name", "route or query string");
        }

        // This is where any conversion to an int etc would happen, 
        // which is why this seems a bit odd and superfluous in this example!
        var name_temp = (string?)name_raw;
        string name_local = name_temp!;

        // If there was a binding failure, nothing more to do.
        if (wasParamCheckFailure)
        {
            httpContext.Response.StatusCode = 400;
            return Task.CompletedTask;
        }

        // Model binding was successful, so execute the handler. 
        var result = handler(name_local!);
        // Render the response (must be either a `string` or `null``)
        if (result is string)
        {
            httpContext.Response.ContentType ??= "text/plain; charset=utf-8";
        }
        else
        {
            // Note the JSON response here 🤔
            httpContext.Response.ContentType ??= "application/json; charset=utf-8";
        }
        return httpContext.Response.WriteAsync(result);
    }

    // This is the RequestDelegate implementation that runs when the endpoint
    // DOES have filters. The model binding is identical to `RequestHandler`, 
    // the difference is that after binding it calls the filter pipeline, and 
    // renders the response (whatever that may be)
    async Task RequestHandlerFiltered(HttpContext httpContext)
    {
        var wasParamCheckFailure = false;
        // Endpoint Parameter: name (Type = string, IsOptional = False, IsParsable = False, IsArray = False, Source = RouteOrQuery)
        var name_raw = name_RouteOrQueryResolver(httpContext);
        if (name_raw is StringValues { Count: 0 })
        {
            wasParamCheckFailure = true;
            logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "name", "route or query string");
        }
        var name_temp = (string?)name_raw;
        string name_local = name_temp!;

        if (wasParamCheckFailure)
        {
            httpContext.Response.StatusCode = 400;
        }

        // Run the filter pipeline
        var result = await filteredInvocation(EndpointFilterInvocationContext.Create<string>(httpContext, name_local!));

        // Render the response of the filter pipeline
        if (result is not null)
        {
            await GeneratedRouteBuilderExtensionsCore.ExecuteReturnAsync(result, httpContext, objectJsonTypeInfo);
        }
    }

    // If there were any filters, use RequestHandlerFiltered, otherwise use RequestHandler
    RequestDelegate targetDelegate = filteredInvocation is null 
                                        ? RequestHandler 
                                        : RequestHandlerFiltered;
    var metadata = inferredMetadataResult?.EndpointMetadata ?? ReadOnlyCollection<object>.Empty;
    return new RequestDelegateResult(targetDelegate, metadata);
})

There's a lot of extra source-generated methods we could look at here, such as BuildFilterDelegate() or ExecuteReturnAsync(), but given that these are easy to see in your own project (by navigating to source) and are easy to read (compared to the Reflection.Emit used in normal minimal APIs), I don't think there's a lot of value in going over them here. Still, I hoped you enjoyed this brief dive into what's happening behind the scenes to make your minimal API apps AOT-friendly!

Summary

In this post I looked at the minimal API source generator introduced in .NET 8 for use with AOT compilation. Prior to .NET 8, minimal APIs use Reflection.Emit to generate optimised code for model binding and serialization, but this approach isn't supported by AOT. Instead, a new source-generator was introduced that generates code similar to the runtime-code used in pre-.NET 8 minimal APIs. The source generator produces code at compilation time instead of runtime, which can then be statically analysed by the AOT linker, so the app can be correctly trimmed. You can even enable the source-generator without AOT, which means your app has less work to do at runtime on the first request. This won't make a big difference, but it could be useful for serverless apps for example, in cases where you can't go full-AOT!

Andrew Lock | .Net Escapades
Want an email when
there's new posts?