API-Fehlerbehandlung in ASP.NET Core

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);
}

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;
}

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:

Dieser Ansatz macht APIs konsistenter, testbarer und einfacher zu warten.