ASP.NET Core Minimal API: Einstieg mit der ersten REST-API
8 Min. Lesezeit

Eine Minimal API ist der Weg, in ASP.NET Core HTTP-Endpunkte in einer einzigen Program.cs zu definieren, ganz ohne Controller-Klassen, Attribute oder Ordnerstruktur. Sie ist das erste Werkzeug der Wahl, wenn Sie einen kleinen Dienst für eine mobile App oder ein Frontend brauchen, wenn Sie lernen oder eine Idee schnell ausprobieren möchten. In diesem Beitrag starten wir mit einem leeren Projekt und schreiben eine REST-API, die Bücher auflistet, anlegt, ändert und löscht. Unterwegs sehen Sie Routenparameter, Model Binding, TypedResults, Dependency Injection und Validierung.
Das Projekt anlegen
Ist das .NET SDK installiert, genügen zwei Befehle im Terminal:
dotnet new web -o BookApi
cd BookApi
dotnet runDie Vorlage web ist das schlankste ASP.NET-Core-Projekt überhaupt. Die erzeugte Program.cs besteht aus wenigen Zeilen:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();Im builder-Teil werden Dienste und Einstellungen vorbereitet, im app-Teil werden Anfragen verarbeitet. Die Zeile Now listening on: http://localhost:... in der Ausgabe von dotnet run zeigt, auf welchem Port die Anwendung läuft. Der Port wird pro Projekt erzeugt; wo ich unten 5000 schreibe, setzen Sie Ihren eigenen ein.
Datenmodell und In-Memory-Dienst
Kehren wir zum Bibliotheksbeispiel aus dem Beitrag über Klassen und Objekte zurück und bringen die Bücher in eine API. Zuerst die Modelle. In Program.cs müssen Typdeklarationen unterhalb des Top-Level-Codes stehen, fügen Sie sie also am Ende der Datei ein:
public record Book(int Id, string Title, string Author, int Year);
public record BookRequest(string? Title, string? Author, int Year);Zwei Typen gibt es aus gutem Grund: Die Id vergibt der Server, nicht der Client. Eingehende Daten sind ein BookRequest, ausgehende ein Book.
Datenbanken sind hier nicht das Thema, deshalb schreiben wir einen einfachen Dienst, der die Daten im Speicher hält. Liegt er hinter einem Interface, ändern sich die Endpunkte nicht, wenn Sie später auf eine echte Datenbank umsteigen. Die Überlegung hinter dieser Trennung habe ich in Interface vs. abstrakte Klasse erklärt.
public interface IBookStore
{
IReadOnlyList<Book> GetAll(string? author);
Book? Find(int id);
Book Add(BookRequest request);
bool Update(int id, BookRequest request);
bool Remove(int id);
}
public class InMemoryBookStore : IBookStore
{
private readonly ConcurrentDictionary<int, Book> _books = new();
private int _lastId;
public IReadOnlyList<Book> GetAll(string? author) =>
_books.Values
.Where(b => author is null ||
b.Author.Contains(author, StringComparison.OrdinalIgnoreCase))
.OrderBy(b => b.Id)
.ToList();
public Book? Find(int id) => _books.GetValueOrDefault(id);
public Book Add(BookRequest request)
{
int id = Interlocked.Increment(ref _lastId);
var book = new Book(id, request.Title!.Trim(), request.Author!.Trim(), request.Year);
_books[id] = book;
return book;
}
public bool Update(int id, BookRequest request)
{
if (!_books.TryGetValue(id, out var old))
return false;
var updated = old with
{
Title = request.Title!.Trim(),
Author = request.Author!.Trim(),
Year = request.Year
};
return _books.TryUpdate(id, updated, old);
}
public bool Remove(int id) => _books.TryRemove(id, out _);
}Warum ein ConcurrentDictionary und keine List<Book>? Ein Webserver bearbeitet mehrere Anfragen gleichzeitig auf verschiedenen Threads. Anfragen, die sich ein Store-Objekt teilen, können eine gewöhnliche Liste beschädigen. Aus demselben Grund steht dort Interlocked.Increment: Zwei gleichzeitige POST-Anfragen dürfen nicht dieselbe Id erhalten.
Die Endpunkte: MapGet, MapPost, MapPut, MapDelete
Nun der obere Teil von Program.cs. Ergänzen Sie am Dateianfang zwei using-Zeilen und registrieren Sie den Dienst:
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);
// Eine Store-Instanz lebt für die gesamte Anwendung
builder.Services.AddSingleton<IBookStore, InMemoryBookStore>();
var app = builder.Build();
app.MapGet("/", () => "Book API läuft");
var books = app.MapGroup("/books");
// GET /books und GET /books?author=orwell
books.MapGet("/", (string? author, IBookStore store) =>
TypedResults.Ok(store.GetAll(author)));
// GET /books/3
books.MapGet("/{id:int}", Results<Ok<Book>, NotFound> (int id, IBookStore store) =>
store.Find(id) is { } book
? TypedResults.Ok(book)
: TypedResults.NotFound());
// POST /books
books.MapPost("/", Results<Created<Book>, ValidationProblem> (BookRequest request, IBookStore store) =>
{
var errors = Validate(request);
if (errors.Count > 0)
return TypedResults.ValidationProblem(errors);
var book = store.Add(request);
return TypedResults.Created($"/books/{book.Id}", book);
});
// PUT /books/3
books.MapPut("/{id:int}", Results<NoContent, NotFound, ValidationProblem> (int id, BookRequest request, IBookStore store) =>
{
var errors = Validate(request);
if (errors.Count > 0)
return TypedResults.ValidationProblem(errors);
return store.Update(id, request)
? TypedResults.NoContent()
: TypedResults.NotFound();
});
// DELETE /books/3
books.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id, IBookStore store) =>
store.Remove(id)
? TypedResults.NoContent()
: TypedResults.NotFound());
app.Run();MapGroup("/books") hält das gemeinsame Pfadpräfix an einer Stelle. Jede Map...-Methode entspricht einem HTTP-Verb: GET liest, POST legt an, PUT ändert, DELETE löscht.
Routenparameter und Model Binding
Die Parameter des Lambdas füllt das Framework für Sie; das nennt sich Model Binding. Der Code oben nutzt vier verschiedene Quellen:
int idstammt aus dem Segment{id:int}der Routenvorlage. Die Namen müssen exakt übereinstimmen.:intist eine Routeneinschränkung; eine Anfrage an/books/abcerhält 404, ohne den Endpunkt je zu erreichen.string? authorsteht nicht in der Route und wird deshalb aus dem Query-String gelesen (?author=orwell). Weil der Typ nullable ist, ist der Parameter optional.BookRequest requestist ein komplexer Typ und wird aus dem JSON im Anfragerumpf gelesen.IBookStore storeist als Dienst registriert und kommt aus der Dependency Injection.
Möchten Sie die Quelle ausdrücklich angeben, gibt es die Attribute [FromRoute], [FromQuery], [FromHeader], [FromBody] und [FromServices]. Auf der JSON-Seite gilt standardmäßig camelCase: Die Property Title geht als title hinaus, beim Lesen wird die Groß-/Kleinschreibung ignoriert.
Results und TypedResults
Ein Endpunkt kann ein einfaches Objekt zurückgeben; das Framework macht daraus JSON mit Status 200. Wenn Sie den Statuscode selbst bestimmen müssen, gibt es zwei Hilfsklassen. Methoden wie Results.Ok(...) und Results.NotFound() liefern IResult. TypedResults bietet dieselben Methoden, liefert aber konkrete Typen (Ok<Book>, NotFound). Das hat zwei Vorteile: Die möglichen Antworten eines Endpunkts stehen in der Signatur und das OpenAPI-Dokument übernimmt sie automatisch, und in Unit-Tests können Sie den zurückgegebenen Typ direkt prüfen.
Kann ein Endpunkt mehr als einen Typ zurückgeben, schreiben Sie den Rückgabetyp des Lambdas ausdrücklich als Results<Ok<Book>, NotFound>. Das ist der ungewohnt aussehende Ausdruck vor den Lambdas im Beispiel.
Dependency Injection
Die Zeile builder.Services.AddSingleton<IBookStore, InMemoryBookStore>() bedeutet: „Wer ein IBookStore verlangt, bekommt einen InMemoryBookStore, und zwar für die ganze Anwendung dasselbe Objekt.“ Die Endpunkte kennen die konkrete Klasse nie. Schreiben Sie morgen einen SqlBookStore auf Basis von Entity Framework, ändert sich nur diese eine Zeile.
Es gibt drei Lebensdauern: AddSingleton (ein Objekt für die Anwendung), AddScoped (ein Objekt pro HTTP-Anfrage) und AddTransient (bei jeder Anforderung ein neues Objekt). Unser Dienst hält Daten im Speicher, also ist Singleton Pflicht. Anfragebezogene Ressourcen wie ein Datenbankkontext sind üblicherweise scoped. Die Dienstklassen selbst erhalten ihre Abhängigkeiten über denselben Mechanismus, nämlich über Konstruktor-Parameter.
Grundlagen der Validierung
Vertrauen Sie Daten vom Client nicht. Der einfachste Ansatz, der zudem in jeder Version funktioniert: von Hand prüfen und ein ValidationProblem zurückgeben. Schreiben Sie diese lokale Funktion hinter die Zeile app.Run(); und vor die Typdeklarationen:
static Dictionary<string, string[]> Validate(BookRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.Title))
errors["title"] = ["Der Titel darf nicht leer sein."];
if (string.IsNullOrWhiteSpace(request.Author))
errors["author"] = ["Der Autor darf nicht leer sein."];
if (request.Year < 1450 || request.Year > DateTime.Now.Year)
errors["year"] = ["Das Jahr muss zwischen 1450 und dem aktuellen Jahr liegen."];
return errors;
}ValidationProblem erzeugt einen standardisierten Fehlerrumpf mit Status 400 und dem Inhaltstyp application/problem+json; der Client liest aus dem Objekt errors, welches Feld warum abgelehnt wurde. Ob DataAnnotations-Attribute wie [Required] und [Range] in Minimal APIs automatisch ausgewertet werden, hängt von Ihrer .NET-Version ab: Neuere Versionen bringen eingebaute Unterstützung mit, ältere brauchen einen Endpoint-Filter oder eine Bibliothek wie FluentValidation. Schauen Sie in die Dokumentation der Version, auf die Sie abzielen.
Das OpenAPI-Dokument
Für alle, die Ihre API nutzen werden, können Sie ein OpenAPI-Dokument (früher Swagger) erzeugen, das Endpunkte, Parameter und Antworttypen beschreibt. Ein einzelnes „richtiges Paket“ nenne ich hier bewusst nicht, denn die eingebauten Werkzeuge haben sich zwischen den .NET-Versionen geändert: Eine Zeit lang enthielten die Projektvorlagen eine Swagger-Bibliothek eines Drittanbieters, spätere Versionen rückten Microsofts eigene OpenAPI-Dokumentgenerierung in den Vordergrund und überließen die Oberfläche einer separaten Entscheidung. Am zuverlässigsten ist es, mit Ihrem installierten SDK dotnet new webapi auszuführen, nachzusehen, was die Vorlage wie verdrahtet, und der offiziellen Dokumentation dieser Version zu folgen. Egal welches Werkzeug Sie wählen: TypedResults und ausdrückliche Rückgabetypen sorgen dafür, dass das Dokument stimmt.
Mini-Szenario: die API mit curl und einer .http-Datei testen
Öffnen Sie bei laufender Anwendung ein zweites Terminal. Legen wir ein Buch an:
curl -i -X POST http://localhost:5000/books \
-H "Content-Type: application/json" \
-d '{"title":"1984","author":"George Orwell","year":1949}'Die Antwort lautet 201 Created mit dem Header Location: /books/1, und im Rumpf steht das angelegte Buch:
{"id":1,"title":"1984","author":"George Orwell","year":1949}Die übrigen Anfragen:
curl "http://localhost:5000/books?author=orwell"
curl -i http://localhost:5000/books/99 # 404 Not Found
curl -i -X DELETE http://localhost:5000/books/1 # 204 No ContentSenden Sie ungültige Daten ("title":"", "year":3000), erhalten Sie Status 400 mit diesem Rumpf:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"title": ["Der Titel darf nicht leer sein."],
"year": ["Das Jahr muss zwischen 1450 und dem aktuellen Jahr liegen."]
}
}Statt Befehle immer wieder zu tippen, können Sie dem Projekt eine Datei books.http hinzufügen. Visual Studio, Rider und VS Code (mit der Erweiterung REST Client) senden die Anfragen darin per Klick:
@host = http://localhost:5000
GET {{host}}/books
###
POST {{host}}/books
Content-Type: application/json
{
"title": "Farm der Tiere",
"author": "George Orwell",
"year": 1945
}
###
PUT {{host}}/books/1
Content-Type: application/json
{
"title": "Neunzehnhundertvierundachtzig",
"author": "George Orwell",
"year": 1949
}Liegt diese Datei im Repository, findet jede Person, die das Projekt öffnet, einen fertigen Weg, die API auszuprobieren.
Wann verwenden – und wann nicht?
Minimal APIs eignen sich für Dienste mit wenigen Endpunkten, Microservices, Backends für mobile Apps, Prototypen und die Phase, in der Sie ASP.NET Core lernen. Sie bekommen weniger Code, weniger Konzepte und einen schnellen Start.
Die Alternative ist eine controllerbasierte Web-API (die Controller-Variante der Vorlage webapi oder MVC). In großen Projekten mit Dutzenden Endpunkten, Filtern, eigenen Model Bindern und eingespielten Teamgewohnheiten bringt die Controller-Struktur von selbst Ordnung. Auch mit Minimal APIs lässt sich ein großes Projekt schreiben, nur müssen Sie die Ordnung dann selbst schaffen: Teilen Sie Endpunkte mit MapGroup auf und verlagern Sie jede Gruppe in eine Erweiterungsmethode in einer eigenen Datei. Alles in Program.cs zu stapeln ist in einem kleinen Projekt bequem und in einem großen ein Problem.
Häufige Fehler
1. Den In-Memory-Dienst mit AddScoped registrieren
Symptom: Der POST liefert 201, das anschließende GET aber eine leere Liste. Ursache: Jede Anfrage erzeugt ein neues Store-Objekt, und die Daten verschwinden mit ihm. Lösung: Verwenden Sie AddSingleton für einen Dienst, der Zustand im Speicher hält.
2. Den Lambda-Parameter anders nennen als den Routenparameter
books.MapGet("/{id:int}", (int bookId) => bookId); // Falsch
books.MapGet("/{id:int}", (int id) => id); // RichtigSymptom: Jede Anfrage liefert 400 Bad Request. Das Framework findet bookId nicht in der Route, sucht im Query-String, findet es auch dort nicht und lehnt die Anfrage ab; in den Logs der Entwicklungsumgebung sehen Sie eine Meldung, dass ein erforderlicher Parameter im Query-String fehlt.
3. Beim POST den Header Content-Type vergessen
Symptom: 415 Unsupported Media Type. curl behandelt mit -d gesendete Daten standardmäßig als Formulardaten. Lösung: Ergänzen Sie -H "Content-Type: application/json".
4. Verschiedene TypedResults-Typen ohne deklarierten Rückgabetyp zurückgeben
// Kompiliert nicht
books.MapGet("/{id:int}", (int id, IBookStore store) =>
store.Find(id) is { } book ? TypedResults.Ok(book) : TypedResults.NotFound());Das Symptom führt leicht in die Irre: CS1661: Cannot convert lambda expression to type 'RequestDelegate'.... Die eigentliche Ursache: Ok<Book> und NotFound haben keinen gemeinsamen Typ, also kann der Compiler dem Lambda keinen Typ geben. Lösung: Deklarieren Sie den Rückgabetyp als Results<Ok<Book>, NotFound> oder verwenden Sie Results.Ok / Results.NotFound.
Häufig gestellte Fragen
Werden Minimal APIs in echten Projekten eingesetzt oder nur für Demos?
Sie werden in echten Projekten eingesetzt; Routing, Dependency Injection, Authentifizierung und die Middleware-Pipeline sind dieselben wie bei Controllern. Der Unterschied liegt in der Organisation des Codes, und diese Verantwortung liegt bei Minimal APIs bei Ihnen.
Soll ich Results oder TypedResults wählen?
Für neuen Code empfehle ich TypedResults: Rückgabetypen stehen in der Signatur, das OpenAPI-Dokument wird genauer und Tests lassen sich leichter schreiben. Results ist praktisch für schnelle Experimente, bei denen Sie den Rückgabetyp nicht ausschreiben möchten.
Warum sind meine Daten nach einem Neustart weg?
Weil wir sie nur im Speicher halten. Für dauerhafte Speicherung implementieren Sie das Interface IBookStore in einer neuen Klasse mit Datenbankzugriff und ändern die Registrierungszeile; die Endpunkte bleiben gleich.
Kann ich in einer Minimal API asynchrone Endpunkte schreiben?
Ja. Machen Sie das Lambda async und geben Sie ein Task<...> zurück; für Endpunkte, die eine Datenbank oder einen anderen HTTP-Dienst aufrufen, ist das der richtige Weg.
Verwandte Artikel
C# Vererbung und Polymorphie: virtual, override und new
Leitfaden zu C# Vererbung und Polymorphie: base, Konstruktorverkettung, virtual/override vs. new, sealed, is/as und die Klausurfrage zur Ausgabe.
C# Interface vs. abstrakte Klasse: Unterschiede und Einsatz
C# Interface vs. abstrakte Klasse: was beide enthalten dürfen, mehrere Interfaces, Entscheidungshilfe, Zahlungsbeispiel und typische Prüfungsfragen.
C# Zugriffsmodifizierer: Türen abschließen oder offen lassen?
Was bedeuten public, private, protected und internal? Wie Getter- und Setter-Methoden Daten sichern, am Beispiel eines Bankkontos.