.NET Minimal APIs: Leichtgewichtige und schnelle Endpoints
# .NET Minimal APIs: Schlanke und schnelle Endpunkte
Minimal APIs wurden mit .NET 6 als radikal vereinfachter Weg eingefuhrt, HTTP-Endpunkte in ASP.NET Core zu erstellen. Statt Controller-Klassen, Attributen und zeremoniellen Klassenhierarchien definieren Sie Routen direkt mit Lambda-Ausdrucken. Unter dieser Einfachheit steht Ihnen jedoch die gesamte ASP.NET-Core-Plattform zur Verfugung: Dependency Injection, Middleware, Authentifizierung, Autorisierung und OpenAPI-Tooling funktionieren genau wie erwartet.
In Microservices, die ich mit Minimal APIs gebaut habe, sank die Startzeit spuerbar, und der kognitive Aufwand fuer neue Teammitglieder wurde deutlich geringer. Ein Entwickler kann `Program.cs` oeffnen, jede Route im Service sehen und innerhalb von Minuten produktiv mitarbeiten.
Wo Minimal APIs glaenzen
Grundlagen: Der Einstieg
Eine vollstaendige Minimal API kann in einer einzigen Datei leben:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductRepository, ProductRepository>();
var app = builder.Build();
app.MapGet(class="code-string">"/products", async (IProductRepository repo) =>
{
var products = await repo.GetAllAsync();
return Results.Ok(products);
});
app.MapGet(class="code-string">"/products/{id:int}", async (int id, IProductRepository repo) =>
{
var product = await repo.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
});
app.MapPost(class="code-string">"/products", async (CreateProductRequest request, IProductRepository repo) =>
{
var product = await repo.CreateAsync(request);
return Results.Created($class="code-string">"/products/{product.Id}", product);
});
app.Run();Beachten Sie, wie Dependency Injection ueber Parameter-Binding funktioniert. Das Framework untersucht die Handler-Parameter und loest Services, Route-Werte, Query-Strings und Body-Inhalte automatisch auf.
Parameter-Binding im Detail
Minimal APIs verwenden ein konventionsbasiertes Binding-System, das sowohl maechtig als auch intuitiv ist:
class=class="code-string">"code-comment">// Route-Parameter
app.MapGet(class="code-string">"/users/{id:guid}", (Guid id) => ...);
class=class="code-string">"code-comment">// Query-String-Parameter
app.MapGet(class="code-string">"/search", (string? query, int page = class="code-number">1, int pageSize = class="code-number">20) => ...);
class=class="code-string">"code-comment">// Header-Binding
app.MapGet(class="code-string">"/protected", ([FromHeader(Name = class="code-string">"X-Api-Key")] string apiKey) => ...);
class=class="code-string">"code-comment">// Body-Binding (automatisch fuer komplexe Typen bei POST/PUT)
app.MapPost(class="code-string">"/orders", (CreateOrderRequest request) => ...);
class=class="code-string">"code-comment">// Service-Injection
app.MapGet(class="code-string">"/stats", (IAnalyticsService analytics, ILogger<Program> logger) => ...);
class=class="code-string">"code-comment">// HttpContext-Zugriff bei Bedarf an voller Kontrolle
app.MapGet(class="code-string">"/custom", (HttpContext context) => ...);
class=class="code-string">"code-comment">// AsParameters zum Gruppieren mehrerer Bindings
app.MapGet(class="code-string">"/products", async ([AsParameters] ProductFilterRequest filter) => ...);Das `[AsParameters]`-Attribut ist besonders nuetzlich fuer GET-Endpunkte mit vielen Query-Parametern:
public record ProductFilterRequest(
string? Category,
decimal? MinPrice,
decimal? MaxPrice,
int Page = class="code-number">1,
int PageSize = class="code-number">20,
[FromServices] IProductRepository Repository = default!
);Typed Results fuer klare Vertraege
Eine der groessten Verbesserungen seit .NET 7 ist `TypedResults`, das Ihre Endpunkt-Vertraege explizit macht und direkt in die OpenAPI-Dokumentation einfliesst:
app.MapGet(class="code-string">"/orders/{id}", async Task<Results<Ok<Order>, NotFound>> (int id, IOrderService service) =>
{
var order = await service.GetByIdAsync(id);
return order is not null
? TypedResults.Ok(order)
: TypedResults.NotFound();
});
app.MapPost(class="code-string">"/orders", async Task<Results<Created<Order>, ValidationProblem>> (
CreateOrderRequest request, IOrderService service, IValidator<CreateOrderRequest> validator) =>
{
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
return TypedResults.ValidationProblem(validation.ToDictionary());
var order = await service.CreateAsync(request);
return TypedResults.Created($class="code-string">"/orders/{order.Id}", order);
});Der `Results
Route Groups fuer Feature-basierte Organisation
Route Groups ermoeglichen es, Konfiguration zwischen verwandten Endpunkten zu teilen und so Wiederholungen zu reduzieren:
var api = app.MapGroup(class="code-string">"/api/v1");
var products = api.MapGroup(class="code-string">"/products")
.WithTags(class="code-string">"Products")
.RequireAuthorization(class="code-string">"ApiScope");
products.MapGet(class="code-string">"/", GetAllProducts);
products.MapGet(class="code-string">"/{id:int}", GetProductById);
products.MapPost(class="code-string">"/", CreateProduct).RequireAuthorization(class="code-string">"Admin");
products.MapPut(class="code-string">"/{id:int}", UpdateProduct).RequireAuthorization(class="code-string">"Admin");
products.MapDelete(class="code-string">"/{id:int}", DeleteProduct).RequireAuthorization(class="code-string">"Admin");
var orders = api.MapGroup(class="code-string">"/orders")
.WithTags(class="code-string">"Orders")
.RequireAuthorization();
orders.MapGet(class="code-string">"/", GetUserOrders);
orders.MapGet(class="code-string">"/{id:int}", GetOrderById);
orders.MapPost(class="code-string">"/", PlaceOrder);Groups unterstuetzen verschachtelte Komposition, gemeinsame Filter und Autorisierungsrichtlinien. Sie sind der primaere Baustein zur Organisation von Minimal APIs jenseits trivialer Beispiele.
Endpoint Filters: Querschnittslogik
Endpoint-Filter sind das Minimal-API-Aequivalent zu Action-Filtern in MVC. Sie fangen Anfragen vor und nach der Handler-Ausfuehrung ab:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var request = context.Arguments.OfType<T>().FirstOrDefault();
if (request is null)
return TypedResults.BadRequest(class="code-string">"Request-Body ist erforderlich.");
var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
if (validator is not null)
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
return TypedResults.ValidationProblem(result.ToDictionary());
}
return await next(context);
}
}
class=class="code-string">"code-comment">// Verwendung
products.MapPost(class="code-string">"/", CreateProduct)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();Fuer einfachere Szenarien koennen Sie auch Inline-Filter definieren:
app.MapGet(class="code-string">"/items/{id:int}", (int id) => ...)
.AddEndpointFilter(async (context, next) =>
{
var id = context.GetArgument<int>(class="code-number">0);
if (id <= class="code-number">0)
return TypedResults.BadRequest(class="code-string">"ID muss positiv sein.");
return await next(context);
});OpenAPI-Integration
Seit .NET 9 verfuegen Minimal APIs ueber erstklassige, ins Framework integrierte OpenAPI-Unterstuetzung:
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
app.MapGet(class="code-string">"/products/{id}", async (int id, IProductRepository repo) =>
{
var product = await repo.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
})
.WithName(class="code-string">"GetProductById")
.WithDescription(class="code-string">"Ruft ein einzelnes Produkt anhand seiner eindeutigen Kennung ab.")
.Produces<Product>(class="code-number">200)
.Produces(class="code-number">404)
.WithTags(class="code-string">"Products");Fuer aeltere .NET-Versionen integrieren sich Swashbuckle oder NSwag nahtlos. Entscheidend ist, dass `TypedResults` automatisch korrekte Schemas generiert, sodass Sie ohne manuelle Annotationen zuverlaessige Dokumentation erhalten.
Minimal APIs im grossen Massstab strukturieren
Der Einzeldatei-Ansatz funktioniert fuer kleine Services, aber alles mit mehr als einer Handvoll Endpunkten braucht Struktur. Das Pattern, das sich in meiner Erfahrung am besten bewaehrt hat, ist die Organisation von Endpunkten in feature-basierten statischen Klassen:
class=class="code-string">"code-comment">// Features/Products/ProductEndpoints.cs
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(class="code-string">"/products").WithTags(class="code-string">"Products");
group.MapGet(class="code-string">"/", GetAll);
group.MapGet(class="code-string">"/{id:int}", GetById);
group.MapPost(class="code-string">"/", Create).RequireAuthorization(class="code-string">"Admin");
group.MapPut(class="code-string">"/{id:int}", Update).RequireAuthorization(class="code-string">"Admin");
group.MapDelete(class="code-string">"/{id:int}", Delete).RequireAuthorization(class="code-string">"Admin");
return group;
}
private static async Task<Ok<List<ProductDto>>> GetAll(
IProductRepository repo, CancellationToken ct)
{
var products = await repo.GetAllAsync(ct);
return TypedResults.Ok(products);
}
private static async Task<Results<Ok<ProductDto>, NotFound>> GetById(
int id, IProductRepository repo, CancellationToken ct)
{
var product = await repo.GetByIdAsync(id, ct);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
}
class=class="code-string">"code-comment">// ... weitere Handler
}
class=class="code-string">"code-comment">// Program.cs bleibt sauber
var app = builder.Build();
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapUserEndpoints();
app.Run();Dieser Ansatz bietet die Klarheit, alle Routen pro Feature an einem Ort zu sehen, waehrend `Program.cs` als Composition Root sauber bleibt. Jede Feature-Datei besitzt ihre eigenen Routen, Handler und Autorisierungsanforderungen.
Auth, Validierung und Fehlerbehandlung
Authentifizierung und Autorisierung
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
.AddPolicy(class="code-string">"Admin", policy => policy.RequireRole(class="code-string">"admin"))
.AddPolicy(class="code-string">"ApiScope", policy => policy.RequireClaim(class="code-string">"scope", class="code-string">"api"));
var app = builder.Build();
class=class="code-string">"code-comment">// Global auf eine Route Group anwenden
var api = app.MapGroup(class="code-string">"/api").RequireAuthorization(class="code-string">"ApiScope");
class=class="code-string">"code-comment">// Fuer bestimmte Endpunkte ueberschreiben
api.MapGet(class="code-string">"/public/health", () => Results.Ok(class="code-string">"Healthy")).AllowAnonymous();
api.MapDelete(class="code-string">"/admin/cache", (ICacheService cache) => cache.Clear()).RequireAuthorization(class="code-string">"Admin");Zentrale Validierung mit FluentValidation
class=class="code-string">"code-comment">// Alle Validatoren aus der Assembly registrieren
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
class=class="code-string">"code-comment">// Generischer Validierungsfilter pro Route Group
public static RouteGroupBuilder WithValidation<T>(this RouteGroupBuilder group) where T : class
{
return group.AddEndpointFilter<ValidationFilter<T>>();
}Globale Fehlerbehandlung
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exception, class="code-string">"Unbehandelte Ausnahme");
context.Response.StatusCode = exception switch
{
NotFoundException => class="code-number">404,
ValidationException => class="code-number">400,
UnauthorizedAccessException => class="code-number">403,
_ => class="code-number">500
};
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = context.Response.StatusCode,
Title = exception switch
{
NotFoundException => class="code-string">"Ressource nicht gefunden",
ValidationException => class="code-string">"Validierung fehlgeschlagen",
_ => class="code-string">"Ein Fehler ist aufgetreten"
}
});
});
});In Microservices, die ich mit Minimal APIs gebaut habe, deckt die Kombination aus globalem Exception-Handler und Endpoint-Filtern fuer die Validierung die grosse Mehrheit der Fehlerszenarien sauber ab. Der Handler faengt Unerwartetes auf, und die Filter behandeln die vorhersehbaren Faelle nah am Endpunkt.
Minimal APIs vs. Controller: Entscheidungsrahmen
| Kriterium | Minimal APIs | Controller |
|---|---|---|
| Boilerplate | Sehr gering, Lambda-basiert | Moderat, Klasse + Attribute |
| Startperformance | Schneller, kein Reflection-Overhead | Etwas langsamer |
| Auffindbarkeit | Alle Routen in einer Datei oder Feature-Modul sichtbar | Ueber Controller-Klassen verteilt |
| Model-Binding | Konventionsbasiert, einfacher | Umfangreicher, konfigurierbarer |
| Filter/Middleware | Endpoint-Filter (neuer, einfacher) | Action-Filter (ausgereift, voll ausgestattet) |
| OpenAPI-Support | Eingebaut mit TypedResults | Attribut-getrieben, etabliert |
| Testbarkeit | Direkte Funktionsaufrufe | Controller-Instanziierung erforderlich |
| Team-Vertrautheit | Neueres Paradigma | Bekanntes MVC-Pattern |
| Grosse API-Oberflaechen | Erfordert Disziplin bei der Organisation | Natuerlich durch Klassen strukturiert |
| Content Negotiation | Manuelles Setup | Eingebaut |
Waehlen Sie Minimal APIs, wenn: Sie fokussierte Microservices, BFFs oder jeden Service bauen, bei dem Einfachheit und Startgeschwindigkeit wichtiger sind als MVC-Konventionen. Wenn Ihr Service weniger als 30-40 Endpunkte hat, werden Minimal APIs wahrscheinlich die sauberere Loesung sein.
Waehlen Sie Controller, wenn: Sie eine grosse API-Oberflaeche mit komplexem Model-Binding, Content Negotiation oder ein Team haben, das tief in MVC-Patterns investiert ist. An Controllern ist nichts auszusetzen -- sie sind kampferprobt und gut verstanden.
Der hybride Ansatz funktioniert ebenfalls: Verwenden Sie Minimal APIs fuer einfache CRUD- und Webhook-Endpunkte, waehrend Sie Controller fuer komplexe Bereiche beibehalten, die von MVC-Konventionen profitieren. ASP.NET Core unterstuetzt beides im selben Projekt.
Haeufige Minimal-API-Fehler
1. Geschaeftslogik in Handlern platzieren
class=class="code-string">"code-comment">// Schlecht: Handler macht alles
app.MapPost(class="code-string">"/orders", async (CreateOrderRequest req, AppDbContext db) =>
{
class=class="code-string">"code-comment">// class="code-number">30 Zeilen Validierung, Geschaeftsregeln und Persistenz
class=class="code-string">"code-comment">// Das wird schnell unwartbar
});
class=class="code-string">"code-comment">// Gut: Handler delegiert an einen Service
app.MapPost(class="code-string">"/orders", async (CreateOrderRequest req, IOrderService service) =>
{
var result = await service.PlaceOrderAsync(req);
return result.IsSuccess
? TypedResults.Created($class="code-string">"/orders/{result.Value.Id}", result.Value)
: TypedResults.BadRequest(result.Error);
});2. CancellationToken ignorieren
class=class="code-string">"code-comment">// Schlecht: keine Abbruch-Unterstuetzung
app.MapGet(class="code-string">"/reports", async (IReportService service) =>
await service.GenerateAsync());
class=class="code-string">"code-comment">// Gut: Abbruch weitergeben
app.MapGet(class="code-string">"/reports", async (IReportService service, CancellationToken ct) =>
await service.GenerateAsync(ct));3. Route Groups nicht verwenden
Jede Route auf oberster Ebene mit wiederholten `.RequireAuthorization()`- und `.WithTags()`-Aufrufen zu definieren, fuehrt zu laermigem, schwer wartbarem Code. Route Groups existieren genau zur Loesung dieses Problems.
4. Fuer alles `Results.Ok()` verwenden
class=class="code-string">"code-comment">// Schlecht: gibt immer class="code-number">200 zurueck, auch bei Erstellung
app.MapPost(class="code-string">"/items", async (Item item, IItemService service) =>
Results.Ok(await service.CreateAsync(item)));
class=class="code-string">"code-comment">// Gut: semantisch korrekte Statuscodes verwenden
app.MapPost(class="code-string">"/items", async (Item item, IItemService service) =>
{
var created = await service.CreateAsync(item);
return TypedResults.Created($class="code-string">"/items/{created.Id}", created);
});5. Strukturiertes Logging ueberspringen
Minimal APIs haben kein eingebautes Action-Logging wie MVC. Fuegen Sie Request-/Response-Logging explizit ueber Middleware oder Endpoint-Filter hinzu, sonst debuggen Sie in der Produktion blind.
Fazit
Minimal APIs sind kein Spielzeug und keine Abkuerzung -- sie sind ein ausgereifter, produktionsreifer Ansatz zum Erstellen von HTTP-Services in .NET. Der Unterschied zu Controllern liegt in der Syntax und der Zeremonie, nicht in den Engineering-Standards. Saubere Architektur, ordentliche Validierung, strukturierte Fehlerbehandlung und durchdachte Endpunkt-Organisation bleiben unverzichtbar.
Die zentrale Erkenntnis ist, dass Minimal APIs Reibung beseitigen, ohne Faehigkeiten zu reduzieren. Jedes Feature, auf das Sie im Controller-basierten ASP.NET Core vertrauen -- DI, Auth, OpenAPI, Filter und Middleware -- ist vollstaendig verfuegbar. Was Sie gewinnen, ist Klarheit: Die gesamte Oberflaeche Ihres Services ist sichtbar, komponierbar und leicht nachvollziehbar.
Ich helfe gern bei der Entscheidung zwischen Minimal APIs und Controller-Ansatz und beim Entwurf einer Endpunkt-Struktur, die sauber skaliert.
Verwandte Artikel
Was ist .NET? Ein moderner Backend-Entwicklungsleitfaden
Erfahren Sie, was .NET ist, wie es funktioniert und warum es für Enterprise-Backend gewählt wird.
RESTful APIs mit ASP.NET Core entwickeln
Lernen Sie die Grundlagen für produktionsreife REST-APIs mit ASP.NET Core. Controller, Routing und Best Practices.
Microservices-Architektur mit .NET: Design und Umsetzung
Entwerfen Sie Microservices-Architektur mit .NET. Service-Kommunikation und Orchestrierung.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen