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

Pre-release · net10.0 · MIT

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 NativeEndpoints

The API is still settling and will move before 1.0. Prereleases are published to GitHub Packages; nuget.org opens once the documentation is complete and the API has held still across two consecutive milestones.

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.AddNativeEndpoints();

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. NativeEndpoints 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
ApiEndpointBaseWrite the response yourself
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.

NE0004 — a build error, not a default
NE0004: Contract 'Transfer' has parameter 'amount' of unsupported type 'Money'.
        Register a value binder or use a supported type.

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, or RequiredWithContentType, 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.

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.
NativeEndpoints.Testing
[Fact]
public async Task Module_unloads()
{
    var evidence = await CollectibleEndpointFixture.RunCyclesAsync(cycles: 3);
    UnloadEvidence.Verify(evidence, gcRounds: 32);
}

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.

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.

NativeEndpointsFastEndpoints
Underlying stackMinimal APIs, unmodifiedIts own layer over Minimal APIs
Escape hatchIEndpointConventionBuilderFramework-specific
RegistrationGenerated, or explicit local scanProcess-global discovery
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.