Cargando...

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


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


TL;DR

  • Result<T> estandariza respuestas con message, code, data, errors.
  • [JsonIgnore(Condition = WhenWritingNull)] omite propiedades null para 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.