Formato estándar de respuesta API en ASP.NET Core (Result<T>)
Estandariza las respuestas de éxito y error en ASP.NET Core con un wrapper Result<T> (message, code, data, errors) para un manejo consistente en el cliente.
Muchas APIs devuelven estructuras de respuesta diferentes según el endpoint:
a veces datos “crudos”, a veces { error: "..." }, a veces texto plano.
Esto complica innecesariamente el lado del cliente (frontend/mobile), porque cada endpoint requiere su propio parsing/handling.
Una solución simple es estandarizar las respuestas con un wrapper Result<T>.
El cliente recibe una estructura predecible (message, code, data, errors),
mientras la API sigue usando códigos HTTP con sentido (200/201/400/404/409/500).
Un detalle importante: devolver errors: [] en éxito o data: null en error infla el payload sin necesidad.
En este ejemplo se usa JsonIgnore con WhenWritingNull para omitir propiedades null en la salida JSON.
Objetivo
- Devolver una estructura de respuesta consistente para éxito y error.
- Mantener el payload compacto omitiendo propiedades
null. - Hacer predecible el manejo del lado del cliente (especialmente UI y validación).
- Estandarizar el body sin perder el significado de los códigos HTTP.
Estructura de respuesta objetivo
En éxito, devolver data sin campos innecesarios:
{
"data": {
"id": 42,
"code": "RSV-2025-00042",
"guestName": "Alex",
"date": "2026-02-21"
},
"message": "Reservation loaded.",
"code": "RESERVATION_LOADED"
}
En error, devolver errors sin data:
{
"message": "Validation failed.",
"code": "VALIDATION_ERROR",
"errors": [
{ "field": "id", "message": "Id must be greater than zero." }
]
}
Crear los modelos Result (JSON compacto con JsonIgnore)
Define un Result<T> genérico y un pequeño DTO de error.
Las propiedades null no se escriben en la salida 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
};
}
Esta versión no incluye intencionalmente un campo success.
El éxito/fracaso ya queda claro con el código HTTP y el payload se mantiene más pequeño.
Modelo Reservation + Service
Para acercar el ejemplo a un caso real, usemos un modelo pequeño y un servicio:
public sealed record ReservationDto(int Id, string Code, string GuestName, DateOnly Date);
public sealed class ReservationService
{
// En una app real, aquí habría acceso a base de datos.
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);
}
}
Uso en un Controller (con Service)
El controller estandariza el body con Result<T> y mantiene códigos HTTP con sentido.
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"
));
}
}
Opcional: helper para errores de validación
Para escenarios de validación, pequeños helpers mantienen el código del controller limpio:
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();
}
Mejoras comunes
-
Usar códigos estables y documentados (por ejemplo
RESERVATION_NOT_FOUND) y mantener consistencia entre servicios. -
No exponer detalles de excepciones en
Message. Loguear los detalles del lado del servidor. -
Si quieres seguir RFC 7807 más estrictamente: usar
ProblemDetailspara errores yResult<T>para éxitos. -
Agregar un
traceId(o devolverlo como header) para correlacionar errores con logs.
TL;DR
Result<T>estandariza respuestas conmessage,code,data,errors.[JsonIgnore(Condition = WhenWritingNull)]omite propiedadesnullpara mantener el payload ligero.- Mantén códigos HTTP con sentido y devuelve bodies JSON predecibles.
- Loguea detalles internamente y devuelve mensajes seguros al cliente.