Wird geladen...

Standardisiertes API-Response-Format in ASP.NET Core (Result<T>)

Standardisieren Sie Erfolgs- und Fehlerantworten in ASP.NET Core mit einem Result<T>-Wrapper (message, code, data, errors) für eine vorhersagbare Client-Verarbeitung.

Viele APIs liefern je nach Endpoint unterschiedliche Response-Strukturen zurück: manchmal rohe Daten, manchmal { error: "..." }, manchmal sogar Plain-Text. Das macht die Client-Seite (Frontend/Mobile) unnötig kompliziert, weil für jedes Endpoint ein eigenes Parsing/Handling nötig wird.

Eine einfache Lösung ist, Responses mit einem Result<T>-Wrapper zu standardisieren. Der Client erhält eine vorhersehbare Struktur (message, code, data, errors), während die API weiterhin sinnvolle HTTP-Statuscodes verwendet (200/201/400/404/409/500).

Ein wichtiges Detail: errors: [] bei Erfolg oder data: null bei Fehlern bläht Payloads unnötig auf. In diesem Beispiel wird JsonIgnore mit WhenWritingNull verwendet, damit null-Properties nicht serialisiert werden.


Ziel


Ziel-Response-Format

Bei Erfolg wird nur data ohne unnötige Felder zurückgegeben:


{
  "data": {
    "id": 42,
    "code": "RSV-2025-00042",
    "guestName": "Alex",
    "date": "2026-02-21"
  },
  "message": "Reservation loaded.",
  "code": "RESERVATION_LOADED"
}

Bei Fehlern wird nur errors (ohne data) zurückgegeben:


{
  "message": "Validation failed.",
  "code": "VALIDATION_ERROR",
  "errors": [
    { "field": "id", "message": "Id must be greater than zero." }
  ]
}

Result-Modelle erstellen (kompaktes JSON mit JsonIgnore)

Definieren Sie ein generisches Result<T> sowie ein kleines Error-DTO. null-Properties werden nicht in die JSON-Ausgabe geschrieben.


using System.Text.Json.Serialization;

public sealed record ErrorItem(string Field, string Message);

public sealed class Result<T>
{
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public T? Data { get; init; }

    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public List<ErrorItem>? Errors { get; init; }

    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public string? Message { get; init; }

    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public string? Code { get; init; }

    public static Result<T> Ok(T data, string? message = null, string? code = null)
        => new()
        {
            Data = data,
            Message = message,
            Code = code
        };

    public static Result<T> Fail(string message, string code, List<ErrorItem>? errors = null)
        => new()
        {
            Message = message,
            Code = code,
            Errors = errors
        };
}

In dieser Version gibt es bewusst kein success-Feld. Erfolg/Fehler wird bereits durch den HTTP-Statuscode klar ausgedrückt, und der Payload bleibt kleiner.


Reservation-Modell + Service

Um das Beispiel näher an die Praxis zu bringen, wird ein kleines Modell und ein Service verwendet:


public sealed record ReservationDto(int Id, string Code, string GuestName, DateOnly Date);

public sealed class ReservationService
{
    // In einer echten Anwendung würde hier ein Datenbankzugriff stattfinden.
    public Task<ReservationDto?> GetByIdAsync(int id, CancellationToken ct)
    {
        if (id == 42)
        {
            return Task.FromResult<ReservationDto?>(
                new ReservationDto(42, "RSV-2025-00042", "Alex", new DateOnly(2026, 2, 21))
            );
        }

        return Task.FromResult<ReservationDto?>(null);
    }
}

Verwendung im Controller (mit Service)

Der Controller standardisiert den Response-Body mit Result<T> und nutzt gleichzeitig sinnvolle HTTP-Statuscodes.


using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
    private readonly ReservationService _service;

    public ReservationsController(ReservationService service)
    {
        _service = service;
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id, CancellationToken ct)
    {
        if (id <= 0)
        {
            return BadRequest(Result<object>.Fail(
                message: "Validation failed.",
                code: "VALIDATION_ERROR",
                errors: new List<ErrorItem>
                {
                    new("id", "Id must be greater than zero.")
                }
            ));
        }

        var reservation = await _service.GetByIdAsync(id, ct);

        if (reservation is null)
        {
            return NotFound(Result<object>.Fail(
                message: "Reservation not found.",
                code: "RESERVATION_NOT_FOUND"
            ));
        }

        return Ok(Result<ReservationDto>.Ok(
            data: reservation,
            message: "Reservation loaded.",
            code: "RESERVATION_LOADED"
        ));
    }
}

Optional: Helper für Validation-Fehler

Für Validation-Szenarien halten kleine Helper den Controller-Code sauber:


public static class ErrorList
{
    public static List<ErrorItem> One(string field, string message)
        => new() { new ErrorItem(field, message) };

    public static List<ErrorItem> Many(params (string field, string message)[] items)
        => items.Select(x => new ErrorItem(x.field, x.message)).ToList();
}

Häufige Verbesserungen


TL;DR

  • Result<T> standardisiert Responses mit message, code, data, errors.
  • Mit [JsonIgnore(Condition = WhenWritingNull)] werden null-Properties ausgelassen und der Payload bleibt klein.
  • HTTP-Statuscodes sinnvoll nutzen und gleichzeitig vorhersehbare JSON-Bodies zurückgeben.
  • Interne Details serverseitig loggen und sichere Nachrichten an den Client zurückgeben.