The Endpoint That Pretends to Be a Result
Every FastEndpoints route is mapped as a Minimal API whose handler returns the same singleton IResult - and that one weird trick is the door into the whole per-request pipeline. Part 3 of FastEndpoints in Depth.
When I first profiled a FastEndpoints service I expected to find custom middleware doing the dispatch. There isn't any. Every endpoint you write is registered as a perfectly ordinary Minimal API route — and the handler lambda is the same three tokens for every endpoint in your application:
// Src/Library/Main/MainExtensions.cs (FastEndpoints, MIT)
var hb = app.MapMethods(finalRoute, [verb], () => FeRequestHandler.Instance);
That is the entire integration with ASP.NET Core routing. This part of the series unpacks why that line is clever, and then follows a request through everything that happens behind it — because the same file that contains the trick contains the whole per-request execution order, and knowing that order is the difference between using the library and fighting it.
Why return a singleton?
Minimal APIs are fast partly because of what happens around your lambda: RequestDelegateFactory inspects its parameters and return type at startup and compiles a specialized delegate — parameter binding, IResult execution, JSON serialization. That machinery is wonderful when you want it. FastEndpoints doesn't: it has its own binder, its own serializer configuration, its own response path.
So it hands routing the most trivial delegate possible: a closed lambda, no parameters, returning an existing object. Nothing to bind, nothing to allocate, nothing to compile beyond "call ExecuteAsync on the returned IResult". And that is the door — because IResult.ExecuteAsync(HttpContext) is a framework-sanctioned callback with the full HttpContext:
// Src/Library/Main/FeRequestHandler.cs (FastEndpoints, MIT)
internal sealed class FeRequestHandler : IResult
{
internal static FeRequestHandler Instance { get; } = new();
public Task ExecuteAsync(HttpContext ctx)
{
var endpoint = ((IEndpointFeature)ctx.Features[Types.IEndpointFeature]!).Endpoint!;
var epDef = endpoint.Metadata.GetMetadata<EndpointDefinition>()!;
...
The “result” isn't a result at all; it's the dispatcher. It recovers which endpoint matched from the routing feature, pulls the EndpointDefinition that Part 2 built at startup out of the endpoint metadata, and takes over. You get Minimal API route matching, route constraints, and endpoint metadata (auth, CORS, output caching all key off it) — while skipping Minimal API binding and serialization entirely. Composition, not wrapping.
Worth pausing on: because the mapped delegate is metadata-identical for every endpoint, everything ASP.NET Core knows about your endpoint — accepts metadata, produces metadata, authorization data — comes from what FastEndpoints attached at map time, with the EndpointDefinition itself as the first metadata item. Third-party middleware that inspects endpoint metadata works unmodified.
Before the endpoint exists: the cheap checks
FeRequestHandler.ExecuteAsync does two things before it spends a single allocation on your endpoint class.
Throttling. If the endpoint declared Throttle(...), a per-endpoint HitCounter is consulted using a client-identifying header (default X-Forwarded-For, falling back to the remote IP — and answering 403 if neither exists). Limit hit: a 429 goes out and we never touch DI. The counter itself is a fixed-window, in-memory design I'll dissect in Part 16.
Content-type sanity. If the request has no Content-Type header but the matched endpoint declares accepts metadata that doesn't include */*, it's a 415, immediately. The code comments out the reasoning: mismatched (as opposed to missing) content types are already rejected by routing's accepts matching, so only the missing-header case needs handling here — and it must be read from the matched endpoint's own metadata rather than the shared definition, because one endpoint class can back multiple routes with different accepts requirements.
Only after both gates does the factory run:
var epInstance = EndpointBootstrap.CreateEndpoint(ctx, epDef);
A new endpoint instance per request
Unlike the startup instance from Part 2 (created once to run Configure()), a fresh endpoint instance is created for every request — constructor-injected from HttpContext.RequestServices, then property-injected for any public service-typed properties (keyed services included, [DontInject] to opt out). Endpoint classes are stateful by design — ValidationFailures, the response property, HttpContext — and per-request instantiation is what makes that state safe.
If your endpoint implements IDisposable/IAsyncDisposable, it is registered for disposal with the response (ctx.Response.RegisterForDispose), not disposed inline — a detail that matters if you hold onto anything that must survive until the body is written. Response-cache headers are applied, and then control enters the endpoint's ExecAsync — the method that defines the canonical request lifecycle.
The canonical order
Condensed from Endpoint.cs (the real method is ~100 lines), the order is:
- Feature flags — any registered
IFeatureFlagthat reports disabled short-circuits with a 404. - Bind — the request DTO is materialized from up to eight sources (Part 4). Binding failures throw and skip to step 9.
OnBeforeValidate/OnAfterValidatehooks around validation (Part 5).- Pre-processors — note: after validation, not before. They can inspect failures, add their own, or write a response and short-circuit (Part 6).
- Response-started check — if a pre-processor already replied, stop. (The source checks its own flag rather than
Response.HasStarted, with a comment that earned my respect://HttpContext.Response.HasStarted doesn't work in AWS lambda!!!Someone paid for that knowledge.) OnBeforeHandlehook, then your handler —HandleAsync, orExecuteAsyncwhose returned value (including Minimal APIResults<...>unions) is auto-sent.- Auto-send — if you never called a
Send.*method and auto-send isn't disabled, anullresponse DTO becomes 204, anything else becomes 200 JSON. OnAfterHandlehook.- Error paths —
JsonException(malformed body) andValidationFailureExceptionget formatted error responses; any other exception is captured, not thrown. - Post-processors — always, in a
finallyblock, receiving request, response, failures, and the captured exception. If no post-processor marks the exception handled, it is rethrown with its original stack trace preserved.
Two design choices deserve highlighting.
Pre-processors run after validation. This surprises most people (it surprised me). If you need code before binding — say, tenant resolution from a header — a pre-processor is too late for the binder to see its effects; you want middleware, or a custom binder. Pre-processors are "before the handler", not "before the pipeline". The upside: a pre-processor gets a fully bound, validated DTO plus the failure list, which makes it an excellent place for cross-cutting checks that need real request data.
Exceptions wait for post-processors. The captured-exception design (ExceptionDispatchInfo.Capture in the catch, conditional edi.Throw() in the finally) means post-processors are a first-class exception-handling surface: they can log with the full DTO in hand, translate a domain exception into a response, and mark it handled — or stay silent and let your global exception middleware see the original stack. That mechanism gets a full treatment in Part 6.
Why this design is fast
Add it up: route matching is the framework's own (fast), dispatch is a metadata lookup plus a factory call, binding is compiled-setter-based rather than reflection-per-request, and there is no filter pipeline, no action descriptor, no ModelState unless work actually requires it. The per-request overhead a benchmark sees over raw Minimal APIs is essentially “instantiate one class from DI and run a small state machine”. That is why the project's benchmarks land where they do — and the whole story hinges on a fake IResult opening the door.
Next up is the part of that state machine with the most moving parts: how eight different sources of request data become one DTO, in a strict order, with compiled setters — Part 4: Eight Sources, One DTO.