API-Fehlerbehandlung in ASP.NET Core ohne IActionResult und Exceptions

API-Fehlerbehandlung in ASP.NET Core ohne IActionResult und Exceptions

18 Feb 2025 - Matthias Voigt

In vielen ASP.NET Core-Projekten 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.

In diesem Beitrag zeige ich einen besseren Weg: Eine strukturierte Rückgabe mit einem generischen Result<T>-Wrapper, der die Vorteile beider Ansätze kombiniert.


Die Probleme mit IActionResult und Exceptions

1. 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 IActionResult verwenden und sich explizit um Fehler kümmern.
  • Folge: Viel Boilerplate-Code, schwerer zu testen, inkonsistente Fehlerbehandlung.

2. 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: NotFound oder Gone sind 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.

1. 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.


2. 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.


3. 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 IActionResult mehr 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.