Chargement...

Format de réponse API standard en ASP.NET Core (Result<T>)

Standardisez les réponses de succès et d’erreur en ASP.NET Core avec un wrapper Result<T> (message, code, data, errors) pour un comportement client prévisible.

Beaucoup d’API renvoient des structures de réponse différentes selon l’endpoint : parfois des données brutes, parfois { error: "..." }, parfois du plain text. Côté client (frontend/mobile), cela complique inutilement les choses, car chaque endpoint exige son propre parsing/handling.

Une solution simple consiste à standardiser les réponses avec un wrapper Result<T>. Le client reçoit une structure prévisible (message, code, data, errors), tout en conservant des codes HTTP pertinents (200/201/400/404/409/500).

Détail important : renvoyer errors: [] en cas de succès ou data: null en cas d’erreur gonfle inutilement le payload. Dans cet exemple, on utilise JsonIgnore avec WhenWritingNull pour omettre les propriétés null dans la sortie JSON.


Objectif


Structure de réponse cible

En cas de succès, renvoyer data sans champs inutiles :


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

En cas d’erreur, renvoyer errors sans data :


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

Créer les modèles Result (JSON compact avec JsonIgnore)

Définissez un Result<T> générique ainsi qu’un petit DTO d’erreur. Les propriétés null ne sont pas écrites dans la sortie JSON.


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

Cette version n’inclut volontairement pas de champ success. Le succès/échec est déjà exprimé clairement par le code HTTP, et le payload reste plus léger.


Modèle Reservation + Service

Pour rapprocher l’exemple d’un cas réel, utilisons un petit modèle et un service :


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

public sealed class ReservationService
{
    // Dans une vraie application, il y aurait ici un accès à la base de données.
    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);
    }
}

Utilisation dans un Controller (avec Service)

Le controller standardise le body avec Result<T> tout en gardant des codes HTTP cohérents.


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

Optionnel : helper pour les erreurs de validation

Pour la validation, de petits helpers permettent de garder un code controller propre :


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

Améliorations courantes


TL;DR

  • Result<T> standardise les réponses avec message, code, data, errors.
  • [JsonIgnore(Condition = WhenWritingNull)] omet les propriétés null pour garder un payload léger.
  • Conserver des codes HTTP pertinents tout en renvoyant des bodies JSON prévisibles.
  • Logger les détails côté serveur et renvoyer des messages sûrs au client.