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
- Renvoyer une structure de réponse cohérente en cas de succès et d’erreur.
- Garder le payload compact en omettant les propriétés
null. - Rendre le traitement côté client prévisible (notamment pour l’UI et la validation).
- Standardiser le body tout en conservant des codes HTTP pertinents.
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
-
Utiliser des codes stables et documentés (ex.
RESERVATION_NOT_FOUND) et rester cohérent entre services. -
Ne pas exposer de détails d’exception dans
Message. Logger les détails côté serveur. -
Si vous voulez suivre RFC 7807 plus strictement : utiliser
ProblemDetailspour les erreurs etResult<T>pour les succès. -
Ajouter un
traceId(ou le renvoyer en header) pour corréler les erreurs avec les logs.
TL;DR
Result<T>standardise les réponses avecmessage,code,data,errors.[JsonIgnore(Condition = WhenWritingNull)]omet les propriétésnullpour 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.