A colleague hit a snag today that I think I’ve hit a couple times before but never really bothered to investigate much ‘til now. I like ASP minimal APIs plenty, more than the traditional controller format, but there’s sure some powerful magic going on that can lead to a couple head scratchers (not to imply normal MVC isn’t full of magic in places also). The main areas of magic that keep on snagging seem to be in parameter injection (and service resolution), model binding and delegate mapping, where I find it’s very easy to go right with this stuff but if you go wrong (which isn’t particularly hard to stumble into) it becomes incredibly opaque as to what is actually going wrong and what the resolution is.

So, this issue today is with delegate mapping. The issue was that a particular new endpoint in development was returning a 200 OK response with no content, but our tests were failing because we were trying to deserialize an actual body. Debugging the test was even more baffling - the handler would execute and successfully return Results.Ok(responseContent) and this wasn’t a big complex application with a long middleware pipeline that could fall over, so what gives?

What seems to be the problem?

Let’s demonstrate this issue. If I create these handlers…

public static class Handler
  public static IResult GetSync(HttpContext context)
    return Results.Ok(new ResponseContent());

  public static Task<IResult> GetAsync(HttpContext context)
    return Task.FromResult(Results.Ok(new ResponseContent()));

public class ResponseContent
  public string Artifact { get; } = Guid.NewGuid().ToString("N");

And I create a very simple web app…

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/sync", Handler.GetSync);
app.MapGet("/async", Handler.GetAsync);


What do you suppose happens here? Well, /sync returns 200 OK with a content body as expected:


but /async returns 200 OK with nothing! The obvious difference is the Task and I’ll admit I’m doing a very unrealistic example here just returning Task.FromResult but sure enough if you have a real example that really calls into and awaits async code before returning a response, you’ll have the same issue. Rather bizzarely, this problem goes away if you change the handler signature ever so slightly. Here’s three that work:

public static Task<IResult> GetAsync2(HttpContext context, CancellationToken cancellationToken)
  return Task.FromResult(Results.Ok(new ResponseContent()));

public static Task<IResult> GetAsync3(HttpContext context, string? queryParameter)
  return Task.FromResult(Results.Ok(new ResponseContent()));

public static Task<IResult> GetAsync4(HttpContext context, SomeService someService)
  // Of course, you'll need to register `SomeService` in the builder.Services
  return Task.FromResult(Results.Ok(new ResponseContent()));

And that’s precisely how this bites me ever so slightly - the norm for the majority of the code I write, if asynchronous, will utilise cancellation tokens, and it’s very likely to involve other features which require service injection or query string parameters or whatever else. I’m naturally inclined to dodge this issue.

What went wrong?

It all happens due to some overly-keen delegate type inference with method overloads. In nearly every method above, the MapGet function will infer the delegate as type System.Delegate (the most generic (as in general, boring, plain, not special, not as in the language construct “Generics”) type possible here) whereas the single GetAsync method actually perfectly matches an overload of MapGet which matches the same signature except for that parameter, which is a Microsoft.AspNetCore.Http.RequestDelegate. Let’s look at the definition:

public delegate Task RequestDelegate(HttpContext context);

That type is a very basic structure: “Handles a http request” as per the docs, and curiously there the Task specifically does not match a type with generics - it explicitly doesn’t return anything other than an awaitable function so under the hood it’s not even going to expect a response, just something to await. This is an overload for MapPost, MapPut and MapDelete also, so it’s not just GET requests that can fall foul of this!

All the other solutions work because they don’t match that signature at all - while the type system is a bit loose on accepting Task<IResult> as Task there is simply no getting around changing the method signature from a simple single HttpContext parameter. Adding anything else will force the mapping method to accept it as Delegate and go figure it out from there. In fact, asp has to do a ton of heavy lifting to do just that - because the basic Delegate is so open, it requires a lot of manual unpicking to understand if it is synchronous or asynchronous, what type it is returning and what to do with that type with regards to the http response, and what parameters it requires and where to retrieve them from (service collection, query string, request body binding etc).

All four of the following mapped endpoints are Delegate types but affect HTTP response differently:

app.MapGet("/1", () => "Hello, World!"); // Default 200 OK with plain text response content
app.MapGet("/2", () => new {Message = "Hello, World!"}); // Default 200 OK with JSON response content
app.MapGet("/3", () => Results.Accepted("Hello, World!")); // Explicit 202 Accepted with plain text response content
app.MapGet("/4", () => Results.Accepted(new {Message = "Hello, World!"})); // Explicit 202 Accepted with JSON response content

Which is just an example of how much work the framework must be doing to figure out the most sensible way to execute your function and return an HTTP response as the plan old Delegate gives no hints as to what’s really going on here.

If you really wanted to, you could let the framework explicitly know which overload to use here to get around this problem by casting to Delegate manually:

app.MapGet("/async", (Delegate)Handler.GetAsync);

What’s the point in RequestDelegate then?

There’s many ways to skin (progam) a cat (http api), and the RequestDelegate was designed for cases where you want to manipulate the HttpContext to achieve everything you desire. From the HttpContext we can access request-scoped services manually with context.RequestServices, and we can manipulate the response with context.Response. To fix the broken GetAsync method in my example at the top, we could change it to be a request delegate that works as intended:

public static Task GetAsync(HttpContext context)
  context.Response.StatusCode = 200;
  return context.Response.WriteAsJsonAsync(new ResponseContent());

RequestDelegate is a powerful and meaningful abstraction used throughout asp for much, much more than this (you might’ve come across it if you’ve built custom middleware for example), whereas Delegate really has no meaning, its as plain as plain gets. Not that that matters to you, you’re not the one writing the framework that has to go figure out the delegate and actually make it useful.

The future is bright

A little interesting bonus point here is that I’m using dotnet 6 for all of this because it’s the latest version available as a native AWS lambda runtime until they release support for dotnet 8. dotnet 8 actually recognises this issue and they’ve built a code analyser rule to warn you about it if it detects that you’re returning a non-generic Task under the assumption you’ve done that accidentally. The warning reads something like:

ASP0016: The method used to create a RequestDelegate returns Task<Microsoft.AspNetCore.Http.IResult>. RequestDelegate discards this value. If this isn’t intended then change the return type to non-generic Task or, if the delegate is a route handler, cast it to Delegate so the return value is written to the response.

May this never trip me up again.