API-Fehlerbehandlung in ASP.NET Core
- Matt
In vielen ASP.NET Core-Projekten, egal ob klassisch oder über die Minimal Hosting API, werden API-Antworten mit IActionResult gestaltet, um Fehler wie NotFound(), BadRequest() oder Forbidden() zurückzugeben. Alternativ setzen einige Entwickler auf Exceptions, die dann von einer Middleware abgefangen werden. Beide Ansätze haben jedoch Nachteile.
Ich gehe deshalb einen dritten Weg: eine strukturierte Rückgabe mit einem generischen Result<T>-Wrapper, der die Vorteile beider Ansätze kombiniert.
Die Probleme mit IActionResult und Exceptions
IActionResult führt zu aufgeblähtem Code
Ein typischer Controller mit IActionResult sieht oft so aus:
[HttpGet("{contactId:guid}")]
public async Task<IActionResult> GetContact(Guid contactId)
{
var userId = _context.Principal.GetUserId();
var contact = await _contactService.GetContact(userId, contactId);
if (contact == null)
return NotFound(new { message = "Contact not found" });
if (contact.IsDeleted)
return StatusCode(StatusCodes.Status410Gone, new { message = "Contact has been deleted" });
if (!contact.IsAccessibleBy(userId))
return Forbid();
return Ok(contact);
}
- Problem: Jeder API-Endpoint muss
IActionResultverwenden und sich explizit um Fehler kümmern. - Folge: Viel Boilerplate-Code, schwerer zu testen, inkonsistente Fehlerbehandlung.
Exceptions für reguläre Fehler sind keine gute Lösung
Einige setzen auf Exceptions, um Fehler zentral über eine Middleware zu handhaben:
[HttpGet("{contactId:guid}")]
public async Task<ContactDto> GetContact(Guid contactId)
{
var userId = _context.Principal.GetUserId();
var contact = await _contactService.GetContact(userId, contactId);
if (contact == null)
throw new KeyNotFoundException("Contact not found");
if (contact.IsDeleted)
throw new InvalidOperationException("Contact has been deleted");
if (!contact.IsAccessibleBy(userId))
throw new UnauthorizedAccessException("Access denied");
return contact;
}
- Problem: Exceptions sind für unerwartete Fehler gedacht, nicht für reguläre Zustände wie
NotFound. - Semantisches Problem:
NotFoundoderGonesind keine Fehler, sondern valide API-Zustände. - Performance-Problem: Exceptions sind teuer, da sie Stacktraces erzeugen und teure Operationen im .NET-Runtime-Handling auslösen.
Daher ist die Lösung, reguläre API-Zustände nicht als Exception zu behandeln, sondern strukturiert zurückzugeben.
Die bessere Lösung: Ein generischer Result<T>-Wrapper
Anstatt IActionResult oder Exceptions verwenden wir eine strukturiere Rückgabe, die neben dem eigentlichen Ergebnis auch einen HTTP-Status und eine Fehlermeldung enthalten kann.
Result<T>-Klasse definieren
public class Result<T>
{
public T? Value { get; }
public int StatusCode { get; }
public string? ErrorMessage { get; }
private Result(T? value, int statusCode, string? errorMessage = null)
{
Value = value;
StatusCode = statusCode;
ErrorMessage = errorMessage;
}
public static Result<T> Success(T value) => new(value, StatusCodes.Status200OK);
public static Result<T> NotFound(string message = "Resource not found") => new(default, StatusCodes.Status404NotFound, message);
public static Result<T> Gone(string message = "Resource no longer available") => new(default, StatusCodes.Status410Gone, message);
public static Result<T> Forbidden(string message = "Access denied") => new(default, StatusCodes.Status403Forbidden, message);
public static Result<T> Unauthorized(string message = "Authentication required") => new(default, StatusCodes.Status401Unauthorized, message);
}
Jetzt haben wir eine standardisierte Möglichkeit, API-Ergebnisse mit Statuscodes zurückzugeben.
Controller gibt Result<T> zurück
[HttpGet("{contactId:guid}")]
public async Task<Result<ContactDto>> GetContact(Guid contactId)
{
var userId = _context.Principal.GetUserId();
var contact = await _contactService.GetContact(userId, contactId);
if (contact == null)
return Result<ContactDto>.NotFound();
if (contact.IsDeleted)
return Result<ContactDto>.Gone();
if (!contact.IsAccessibleBy(userId))
return Result<ContactDto>.Forbidden();
return Result<ContactDto>.Success(contact);
}
Jetzt sind unsere Controller schlank und geben nur das Ergebnis zurück. Kein IActionResult, keine unnötigen Exceptions.
Middleware wandelt Result<T> in HTTP-Antworten um
public class ResultMiddleware
{
private readonly RequestDelegate _next;
public ResultMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
await _next(context);
if (context.Items.TryGetValue("Result", out var resultObj) && resultObj is Result<object> result)
{
context.Response.StatusCode = result.StatusCode;
if (result.Value != null)
{
await context.Response.WriteAsJsonAsync(result.Value);
}
else if (!string.IsNullOrEmpty(result.ErrorMessage))
{
await context.Response.WriteAsJsonAsync(new { message = result.ErrorMessage });
}
}
}
}
Dann in Program.cs registrieren:
app.UseMiddleware<ResultMiddleware>();
Middleware setzt automatisch den richtigen HTTP-Status. Die API bleibt REST-konform und einfach zu testen.
Fazit
Der Result<T>-Ansatz bietet eine strukturierte, saubere und performante API-Fehlerbehandlung:
- Saubere Controller → Kein
IActionResultmehr nötig - Bessere Performance → Keine Exceptions für reguläre API-Zustände
- Mehr Kontrolle → Klare Trennung zwischen Fehlern und regulären Antworten
- Zentrale Fehlerbehandlung → Einheitliche Middleware statt individueller
NotFound()-Rückgaben
Dieser Ansatz macht APIs konsistenter, testbarer und einfacher zu warten.