MinimalEndpoints logoMinimalEndpoints
Machined metal modules linked by brass couplings on an engineer's workbench

1.0.0-preview.3 · net10.0 · MIT · Native AOT

A structured programming model for ASP.NET Core Minimal APIs.

One class per endpoint, carrying its route, its metadata, and its handling. Ordinary ASP.NET Core underneath, all the way down.

Getting startedView sourcedotnet add package MinimalEndpoints

1.0.0-preview.3 is on nuget.org, published through NuGet Trusted Publishing: no long-lived publishing credential exists in the repository or its organization secrets. The API is settling but no longer moving weekly; breaking changes before 1.0 are possible and are listed in the changelog.

An endpoint

That is the whole file.

The attribute declares the route. The namespace supplies the operation id (InvoicesGet), so nothing has to be named twice. The request record is bound from the route, and the response is serialized and documented.

Invoices/Get/Endpoint.cs
namespace Billing.Endpoints.Invoices.Get;

public sealed record GetInvoice(string InvoiceId);

[Get("invoices/{invoiceId}")]
public sealed class Endpoint(IInvoiceStore store) : ApiEndpoint<GetInvoice, InvoiceView>
{
    public override async Task<InvoiceView> HandleAsync(GetInvoice request, CancellationToken ct) =>
        InvoiceView.From(await store.GetAsync(request.InvoiceId, ct));
}
Program.cs — wire it up once
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMinimalEndpoints();

var app = builder.Build();
app.MapEndpointGroup().MapEndpointsFrom(typeof(Program).Assembly, routePrefix: "/api");
app.Run();

Why

Minimal APIs are a good runtime and an awkward organizing principle.

Past a few dozen routes you are choosing between a Program.cs nobody wants to open, a pile of extension methods that hide the route table, or a framework that replaces ASP.NET Core with its own parallel universe. MinimalEndpoints takes the middle path: it gives you a place to put an endpoint and takes nothing away.

01

Vertical slices, not layers

An operation is one folder: its contract, its handler, its permissions, its tests. Changing an endpoint means opening one directory.

02

Ordinary ASP.NET Core underneath

Routing, filters, results, CORS, rate limiting, output caching, authorization and OpenAPI stay ASP.NET Core's. Every escape hatch is an IEndpointConventionBuilder.

03

Nothing prescribed about handling

HandleAsync is a method. Call a service, query a store, dispatch to whatever you already use. The framework owns route, binding and metadata, and stops there.

04

Metadata correct by default

API Explorer needs a MethodInfo in metadata or your endpoint silently vanishes from the OpenAPI document. Handled once, for every endpoint.

Base types

Five shapes, and a handler that decides.

ApiEndpointWithResult covers operations whose status depends on what happened. The documented schema stays the response type; the wrapper never reaches the wire.

ApiEndpoint<TRequest, TResponse>A request in, a response body out
ApiEndpoint<TRequest>A request in, 204 No Content out
ApiEndpointWithoutRequest<TResponse>No contract, a response body out
ApiEndpointWithResult<TRequest, TResponse>The status code is decided by the handler
ApiEndpointWrite the response yourself, straight to HttpContext
Status decided by the handler
public override async Task<EndpointResult<InvoiceView>> HandleAsync(
    CreateInvoice cmd, CancellationToken ct)
{
    var (invoice, created) = await store.UpsertAsync(cmd, ct);
    return created
        ? EndpointResult.Status(StatusCodes.Status201Created, InvoiceView.From(invoice))
        : EndpointResult.Ok(InvoiceView.From(invoice));
}

Binding

Route, then body, then query.

Route wins over the body so a resource identifier in the URL cannot be contradicted by the payload. Built in: string, bool, int, long, Guid, enum, DateTimeOffset, anything implementing IParsable<T>, arrays and lists of those from the query string, plus headers and claims — and you can register your own parser.

Registering a parser
builder.Services.AddMinimalEndpoints(o => o.ValueBinders.Add<Money>(Money.TryParse));
NE0002 — a build warning, not a default
NE0002: Contract 'Transfer' has parameter 'amount' of unsupported type 'Money'.
        Implement IParsable<Money>, or register a parser with
        AddMinimalEndpoints(o => o.ValueBinders.Add<Money>(...)).

A narrower binder, on purpose

Anything unsupported throws, loudly, rather than binding silently to a default. Body handling is explicit per endpoint via options.BodyMode: None, Optional, Required, RequiredWithContentType, or OptionalWithContentType, the last rejecting a non-JSON content type with a bare 415 before the body is read.

This is a design position, not a gap: predictable binding you can hold in your head, and a loud failure instead of a quiet one.

Strict typed parsing

options.StrictTypedParsing rejects a typed route, query, header, or claim value that does not parse, with a 400 naming it, instead of falling back to the parameter's default. One rule across both binders: registered value binders, IParsable fallbacks, and collection elements are rejected the same way a scalar int is. Opt-in, because turning it on changes what an existing API returns.

Explicit nulls

The binder records which properties a request body actually contained, so a member sent as null stays null while an omitted one falls through to the query string.

Native AOT

Supported, and checked on every push.

The source generator ships inside the MinimalEndpoints package as an analyzer. dotnet add package MinimalEndpoints brings it along. Nothing to configure.

Generated registration — instead of the reflective scan
using MinimalEndpoints.Generated;

app.MapEndpointGroup().Map(routePrefix: "/api");
Program.cs — samples/Aot
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddMinimalEndpoints();

var app = builder.Build();

// A source-generated serializer context: the JSON half of the AOT story.
app.MapEndpointGroup("Aot", AotJson.Default).Map(routePrefix: "/api");
app.Run();

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(GetWidget))]
[JsonSerializable(typeof(WidgetView))]
internal partial class AotJson : JsonSerializerContext;

No reflection in the flow

It emits explicit registration naming every endpoint class, a binder per contract that reads each member by name, and an activator that news the endpoint up from request services. Together they remove every reflection path from the request flow.

11 MB, zero warnings

samples/Aot publishes as an 11 MB native binary with zero IL trim or AOT warnings, verified in CI on every push. That project treats IL2026, IL3050 and the IL207x/IL209x family as errors, so a reflective path creeping back into the generated flow breaks the build rather than the release.

The fallback tells you

Using the reflective mapper in a trimmed or AOT project reports IL2026 and IL3050 at the call site rather than failing after deployment.

Both paths produce identical endpoints, pinned by a test that maps the same assembly each way and compares. The reflective MapEndpointsFrom is not going away; it is the documented fallback for anyone who cannot run the generator.

Performance

At minimal-API parity, and measured.

The per-request pipeline resolves serializer metadata once per endpoint, memoizes a binding plan per contract, streams request bodies in one pass where it provably can, and does O(1) route and query lookups. The repository carries a BenchmarkDotNet suite so the claim is checkable rather than aspirational.

Four stacks, one truth

benchmarks/ compares raw minimal APIs (the baseline), MinimalEndpoints reflective, MinimalEndpoints generated, and FastEndpoints, in-process with identical semantics. A setup-time conformance pass asserts all four return the same JSON, so a misbinding stack aborts the run rather than winning it.

Where it lands

On the suite's reference run, both MinimalEndpoints paths sit at raw minimal-API parity or better on GET and POST, and the generated path's POST allocations come in slightly below the raw baseline. Results are machine-dependent by nature: run it yourself with dotnet run -c Release --project benchmarks/MinimalEndpoints.Benchmarks.

No observable behavior change

Every hot-path optimization is pinned by the reflective-vs-generated conformance suite and a 209-test suite asserting responses are byte-identical before and after.

View the suite

Diagnostics

What the build tells you.

NE0001Endpoint declares no route attribute, and the generator cannot see whether Configure supplies one
NE0002A contract parameter has a type the binder cannot produce from a request string
NE0003Configure reads constructor-injected state, which is null at map time
NE0004A contract has more than one public constructor, so the binder will throw when the route is first called
NE0005An endpoint derives ApiEndpointBase directly instead of one of the five mappable base types, so no mapper can dispatch it

NE0002 is reported only for GET and HEAD. Everywhere else a contract member may come from the JSON body, where any serializable type is fine.

A runtime-registered value binder is invisible to an analyzer, so you tell the build:

AssemblyInfo.cs
[assembly: EndpointValueBinder(typeof(Money))]

Errors

Mapping is domain knowledge, so it lives with the domain.

Domain exceptions become responses through translators you register, rather than a global filter every part of the application has to agree on. Problems are written as RFC 9457 ProblemDetails through IProblemDetailsService.

BillingExceptionTranslator.cs
public sealed class BillingExceptionTranslator : IEndpointExceptionTranslator
{
    public EndpointProblem? Translate(Exception exception) => exception switch
    {
        InvoiceNotFoundException  => EndpointProblem.General(404, "Invoice not found"),
        InvoiceLockedException e  => EndpointProblem.General(409, e.Message),
        _ => null
    };
}

Unload safety

And it unloads.

If you host plugins in collectible AssemblyLoadContexts, endpoint frameworks are usually where unloading goes to die. Process-global registries, static configuration and captured handler MethodInfo all root the assembly you are trying to release — and none of it is visible until you measure.

  • No process-global discovery and no static registry. Registration is generated per assembly.
  • No framework static holds a reference to your types. Caches are weak-keyed or scoped to the endpoint generation.
  • Handlers publish as bare RequestDelegate, keeping your async state machine out of retained metadata.
  • Endpoint metadata is validated as the final convention, fail-closed, rejecting any collectible type, member, delegate or JsonTypeInfo.
MinimalEndpoints.Testing
[Fact]
public void Endpoint_assemblies_are_collected()
{
    var evidence = CollectibleEndpointFixture.RunCycles(cycles: 3);
    UnloadEvidence.AssertAllCollected(evidence);
}

0 of 3

collectible contexts collected with FastEndpoints 7.2.0 in a harness that compiled three endpoint assemblies, served a request, disposed the host and forced repeated full collections. That isolates a composition-level retention problem; it is not a claim about FastEndpoints in any other respect. It is the reason this library exists.

The kit has no dependency on MinimalEndpoints itself, and can be asked to introduce a deliberate leak so you can confirm it still detects one.

Packages

Three packages, and what each one drags in.

MinimalEndpointsnone, beyond the ASP.NET Core shared framework
MinimalEndpoints.OpenApiMinimalEndpoints, Microsoft.AspNetCore.OpenApi
MinimalEndpoints.TestingMicrosoft.AspNetCore.TestHost, Microsoft.CodeAnalysis.CSharp
OpenAPI wire-up
builder.Services.AddOpenApi();
builder.Services.AddMinimalEndpointsOpenApi();

Route, query and header parameters appear in the OpenAPI document only with the MinimalEndpoints.OpenApi package.

Compared to FastEndpoints

Choose this when you want the endpoint-class shape and nothing else.

FastEndpoints is a mature, popular and genuinely good library, and it does considerably more than this one. If you want a batteries-included framework, use it.

MinimalEndpointsFastEndpoints
Endpoint classesYesYes
Underlying stackMinimal APIs, unmodifiedIts own layer over Minimal APIs
Escape hatchIEndpointConventionBuilderFramework-specific
RegistrationGenerated, or explicit local scanProcess-global discovery
Binding sourcesRoute, body, query, header, claimRoute, query, claim, form, body, header
Collectible unloadingVerified by a test you can runNot supported
Forms and file uploadNot supportedSupported
ValidationBring your ownFluentValidation, built in
Package dependenciesNoneSeveral
Target frameworksnet10.0Broad
LicenseMITApache 2.0

What it does not do

The narrow parts, stated plainly.

Forms and multipart

Not in 1.0. Use a plain MapPost beside your endpoints.

Validation

Bring FluentValidation, DataAnnotations, or hand-written guards.

Older frameworks

net10.0 only. A new library targeting .NET 8 would ship dead code.