CodeGym /Cursos /Spring REST & MVC /Códigos y mensajes en errores de API

Códigos y mensajes en errores de API

Spring REST & MVC
Nivel 18, Lección 2
Disponible

1. Roles de los códigos y mensajes

Cuando haces una API, de repente te conviertes en proveedor de «idioma» para otros programas. Y los programas son criaturas nerviosas: no toleran bien que el significado de la respuesta esté incrustado en un texto como El campo title es obligatorio. Una persona lo entenderá, pero el código del lado del cliente comenzará a sufrir: analizar la cadena, buscar subcadenas, comparar texto completo. Es como determinar el clima por la frase del vecino algo fresco hoy — funciona hasta el primer vecino al que le gusta el drama.

path ya responde a la pregunta «dónde exactamente se rompió la entrada». Ahora necesitamos separar dos preguntas más: en qué puede apoyarse de forma segura el código del cliente y qué mostramos a los ojos de la persona.

Desde un punto de vista técnico, ya tenemos el estado HTTP 400 Bad Request. Pero el estado es demasiado amplio: dice «el cliente envió algo incorrecto», pero no dice qué exactamente. Por lo tanto, necesitamos semántica adicional dentro del cuerpo de la respuesta. Aquí es donde aparecen los códigos: un código de alto nivel «clase del problema» y un código de detalle «qué regla se violó». Y message sigue siendo texto para la persona, no para la lógica.

Para ver la diferencia claramente, es útil tener en mente la siguiente «matriz de roles»:

Elemento de respuesta ¿A qué pregunta responde? Ejemplo Para quién principalmente ¿Debe ser estable?
Estado HTTP «¿Es éxito o error, y de qué clase?» 400 para infraestructura, cliente, depuración sí
Código de alto nivel code «¿Qué clase de problema en general?» INVALID_INPUT para la lógica del cliente y análisis sí
path «¿Dónde exactamente está el problema?» tags[1] para la UI y visualización precisa sí
Código de detalle code «¿Qué regla se violó?» REQUIRED para la lógica del cliente sí
message «¿Cómo explicárselo a una persona?» «El campo title es obligatorio» para la persona no (puede cambiar)

Y sí, esto no es burocracia. Es una forma de hacer que la respuesta sea utilizable tanto por personas como por programas, y para que su contrato no se rompa si mañana decide reescribir el texto del mensaje de forma más amigable.

2. Código de alto nivel de la respuesta

El código de alto nivel es como una etiqueta en una caja durante una mudanza: «COCINA», «OFICINA», «FRÁGIL». Dentro de la caja pueden haber cosas diferentes, pero la etiqueta es necesaria para entender de inmediato la categoría. Para errores de validación, normalmente nos basta con una etiqueta honesta: INVALID_INPUT.

Porque 400 significa «en general algo anda mal con la solicitud», mientras que INVALID_INPUT significa «no pasó la validación de los datos de entrada». Dentro de 400 pueden existir otros problemas, pero ahora estamos diseñando específicamente escenarios de validación y no los mezclamos con otros.

Para nuestro Task Tracker API, esto significa una regla simple: todos los fallos de validación, independientemente de si provienen de body, query o path, reciben un único código de alto nivel INVALID_INPUT. Los detalles serán diferentes: source, path, código de detalle y message.

Aquí hay una idea sutil pero muy importante: el código de alto nivel no debe proliferar «por cada endpoint». Una opción como INVALID_TASK_CREATE_REQUEST o INVALID_TASK_SEARCH_CRITERIA parece conveniente exactamente los primeros dos días, pero luego se convierte en un diccionario de cien páginas. El cliente no necesita un código separado para cada endpoint; necesita la clase del problema, después de lo cual mirará en errors[] qué es exactamente lo que falló.

En el proyecto, es conveniente fijar los códigos de alto nivel como constantes para no generar errores tipográficos como INVALD_INPUT (eso también ocurre — y sí, es un tipo de dolor aparte).

package com.example.tasktracker.api.dto.error;

public final class ApiErrorCodes {

    // Códigos de alto nivel estables — parte del contrato de la API.
    // No se pueden «renombrar silenciosamente», como el texto de message.
    public static final String INVALID_INPUT = "INVALID_INPUT";

    // Clase de utilidad: solo constantes, sin creación de instancias.
    private ApiErrorCodes() {
    }
}

Es un pequeño paso, pero disciplina: el código se convierte en parte del contrato, no en una cadena aleatoria «como se escribió».

3. Código de detalle

Si el código de alto nivel es la «etiqueta de la caja», entonces el código de detalle es el «tipo de objeto dentro de la caja»: «PLATO», «TAZA», «CUCHILLO». Responde a la pregunta: qué regla concreta se violó. Y es en el código de detalle en lo que el cliente se apoyará en su lógica: resaltar el campo, mostrar el tipo de error adecuado, posiblemente traducir el mensaje a otro idioma o reemplazarlo con nuestro texto «humano».

Aquí hay tres reglas que salvan a la API del caos.

Primero: el código de detalle debe ser legible por máquina, corto y estable. Normalmente es UPPER_SNAKE_CASE o simplemente UPPER_CASE para códigos cortos como REQUIRED. Sin localización dentro del código. Si hace que el código sea OBYAZATELNO, ganará el dudoso concurso de «la API más rusa», pero perderá todo lo demás: clientes, documentación, uniformidad e integraciones.

Segundo: el código de detalle no debe duplicar path. Si path = title, entonces el código TITLE_REQUIRED es redundante. Esto hace que el diccionario de códigos sea 10 veces más grande sin beneficio real. Es mucho más simple: title + REQUIRED ya define completamente el significado.

Tercero: el código de detalle no debe filtrar mecanismos internos. Es muy fácil tomar lo que proporciona Bean Validation o Spring y «filtrarlo hacia afuera». Por ejemplo, NotBlank, Size, Pattern. Pero esos son nombres internos de sus restricciones y de la tecnología específica. Hoy es Bean Validation, mañana cambia la anotación o el proveedor, y el contrato del cliente de repente se «renombra». El cliente no necesita saber que internamente usa @Size(max = 80) — el cliente necesita entender que el campo es «demasiado largo».

Por lo tanto, diseñamos nuestros propios códigos de detalle. Para nuestro curso y proyecto, es conveniente mantener un diccionario pequeño. Un diccionario de códigos demasiado grande es casi siempre un síntoma de que se intenta hacer una «clasificación perfecta de todos los errores del mundo», lo que normalmente termina en filosofía y discusiones interminables en PR.

Aquí hay una tabla práctica de mapeo «qué problema → qué código de detalle», que funciona bien para APIs REST comunes:

Tipo de violación Código de detalle Ejemplo de message Ejemplo de dónde aparece
Campo obligatorio REQUIRED «El campo title es obligatorio» @NotNull, @NotBlank
Demasiado corto TOO_SHORT «title debe tener al menos 3 caracteres» @Size(min=...)
Demasiado largo TOO_LONG «assigneeName no debe tener más de 80 caracteres» @Size(max=...)
Demasiado pequeño TOO_SMALL «size debe ser al menos 1» @Min
Demasiado grande TOO_LARGE «size no debe ser mayor que 100» @Max
Formato inválido INVALID_FORMAT «El email tiene un formato inválido» @Pattern
Valor no permitido INVALID_VALUE «priority tiene un valor no permitido» restricción personalizada
Duplicado en la colección DUPLICATE «Los tags deben ser únicos» regla de colección personalizada
Rango incorrecto INVALID_RANGE «dueAfter debe ser anterior a dueBefore» regla entre campos

Tenga en cuenta: este es un «diccionario de significados», no un «diccionario de anotaciones». Al cliente le importa el significado.

En el proyecto, es conveniente mantener dicho diccionario como un solo enum ValidationDetailCode, y exponer name(). Así los códigos no se dispersan en literales de cadena y es más fácil mantenerlos estables de endpoint a endpoint.

Sí, aquí hay pocos códigos — a propósito. Para un proyecto didáctico, esto es una ventaja: aprenderá más rápido a mantener la disciplina del contrato, en lugar de coleccionar categorías raras.

Y ahora la parte importante sobre «por qué esto es útil para el cliente». El código del cliente puede reaccionar al código de detalle sin analizar el texto. Incluso si no es un frontend, sino pruebas, SDK o alguna capa de integración.

// Ejemplo: elegir la reacción de la UI/cliente según el código, no según el texto de message.
String uiAction = switch (detail.code()) {
    case "REQUIRED", "INVALID_FORMAT" -> "show-field-error";
    case "INVALID_RANGE" -> "show-group-error";
    default -> "show-generic-error";
};

Parece simple, pero aquí ocurre la magia de la madurez de ingeniería: la lógica no depende del idioma de los mensajes y no se rompe porque haya reescrito message.

4. Message para la persona

El mensaje (message) es una «explicación para la persona». Es importante porque las personas leen los errores con los ojos, no con JSONPath. Pero el mensaje no debe convertirse en un ancla del que se agarre el código del cliente. Si alguna vez ha visto un frontend que hace if(message.contains("obligatorio")), sabrá que en algún lugar del mundo acaba de llorar un arquitecto.

El rol ideal de message en nuestro contrato es: es un buen fallback que se puede mostrar al usuario «tal cual», si el cliente no quiso o no pudo localizar el error. Pero la clasificación debe hacerse a través de code.

¿Qué significa «buen mensaje»? Debe ser corto, concreto y no filtrar detalles internos. El mensaje Validation failed es demasiado general. El mensaje javax.validation.ConstraintViolationException at ... ya no es un mensaje, es un grito de desesperación y una fuga de detalles de implementación. El mensaje debe responder a una simple pregunta humana: «¿Qué debo corregir?».

Hay otro matiz: message puede contener números y parámetros (80, 100, 3..120). Esto es normal. Al contrario, es útil: la persona entiende de inmediato el límite. Pero precisamente por eso message no es adecuado para el procesamiento programático. Hoy 80, mañana cambia la regla a 100, y la «lógica basada en texto» se rompe. El código sigue siendo el mismo (TOO_LONG), mientras que message cambia — y esto es correcto.

Ejemplo de nuestro dominio:

  • path: assigneeName
  • code: TOO_LONG
  • message: assigneeName no debe tener más de 80 caracteres

Si mañana el negocio dice vamos hasta 120, solo cambia message, y el código del cliente sigue entendiendo: error de clase TOO_LONG.

5. Contrato JSON de Task Tracker API

Cuando juntamos todo, obtenemos una respuesta que al mismo tiempo «se lee a simple vista» y «se procesa con código». A continuación, un ejemplo al estilo de nuestro proyecto. Observe: el code superior es el mismo, y dentro de errors se encuentra información realmente útil.

Para el escenario de creación de una tarea, donde title está vacío y los tags se repiten:

{
  "status": 400,
  "code": "INVALID_INPUT",
  "summary": "La validación de datos de entrada no pasó",
  "errors": [
    {
      "source": "body",
      "path": "title",
      "code": "REQUIRED",
      "message": "El campo title es obligatorio"
    },
    {
      "source": "body",
      "path": "tags[1]",
      "code": "DUPLICATE",
      "message": "Los tags deben ser únicos"
    }
  ]
}

Para el escenario de búsqueda, donde size es demasiado grande y el rango de fechas es incorrecto:

{
  "status": 400,
  "code": "INVALID_INPUT",
  "summary": "La validación de datos de entrada no pasó",
  "errors": [
    {
      "source": "query",
      "path": "size",
      "code": "TOO_LARGE",
      "message": "size no debe ser mayor que 100"
    },
    {
      "source": "query",
      "path": "dateRange",
      "code": "INVALID_RANGE",
      "message": "dueAfter debe ser anterior a dueBefore"
    }
  ]
}

Si le gustan los diagramas, puede recordar la relación de los elementos así:

flowchart TD
    A["HTTP 400"] --> B["code = INVALID_INPUT"]
    B --> C["errors[]"]
    C --> D["source + path (dónde)"]
    C --> E["detail code (qué)"]
    C --> F["message (cómo explicar a la persona)"]

Este diagrama es útil porque no tiene magia: cada pieza responde a una pregunta y no se mete en el rol ajeno.

6. Implementación en Java: códigos y mapeo

Los DTO de respuesta y el código de alto nivel INVALID_INPUT ya los tenemos fijados. Aquí lo más importante es otra cosa: cómo traducir las señales internas de Spring/Bean Validation a nuestro diccionario de códigos y no filtrar hacia afuera NotBlank, Size o Pattern como parte del contrato público.

Supongamos que el diccionario ya está implementado como un enum ValidationDetailCode. A continuación, el mapeo es intencionadamente compacto: lo importante es el principio «código técnico del framework → nuestro código estable». Para restricciones como Size, en un proyecto real normalmente se verifica si chocamos con min o max.

import org.springframework.validation.FieldError;

static ValidationDetailCode toDetailCode(FieldError error) {
    // Mapeamos códigos internos/técnicos de Spring/Bean Validation a códigos estables de la API.
    // Importante: no exponemos hacia afuera "Size"/"NotBlank" — son detalles de implementación, no contrato.
    return switch (error.getCode()) {
        case "NotNull", "NotBlank" -> ValidationDetailCode.REQUIRED;
        case "Size" -> ValidationDetailCode.TOO_LONG;
        case "Min", "Positive", "PositiveOrZero" -> ValidationDetailCode.TOO_SMALL;
        case "Max" -> ValidationDetailCode.TOO_LARGE;
        case "Pattern", "Email" -> ValidationDetailCode.INVALID_FORMAT;
        default -> ValidationDetailCode.INVALID_VALUE;
    };
}

Montar un detalle a partir de FieldError también se puede hacer de forma muy compacta:

import org.springframework.validation.FieldError;

static ValidationErrorDetail toDetail(FieldError error) {
    // Convertimos el error interno de binding en un detalle de nuestro contrato público.
    return new ValidationErrorDetail(
            "body",
            error.getField(),
            toDetailCode(error).name(),
            error.getDefaultMessage()
    );
}

Aquí hay otro matiz importante. error.getField() devuelve el nombre del campo desde el punto de vista de Java/binding, mientras que nuestra API prometió que path vive en la lógica de nombres públicos. Si en su DTO hay alias o renombres de campos, debe asegurarse de que path corresponda al nombre externo. En un proyecto didáctico, lo más sencillo es mantener los nombres consistentes para no complicar esta capa antes de tiempo.

Y la pieza final: el ensamblaje de la respuesta. Incluso si por ahora lo ensambla manualmente en algún lugar, el código debe verse predecible:

import java.util.List;

// Ejemplo: construimos una respuesta 400 con el código superior INVALID_INPUT y un error detallado.
ValidationErrorResponse response = new ValidationErrorResponse(
        400,
        ApiErrorCodes.INVALID_INPUT,
        "La validación de datos de entrada no pasó",
        List.of(new ValidationErrorDetail(
                "body",
                "title",
                ValidationDetailCode.REQUIRED.name(),
                "El campo title es obligatorio"
        ))
);

Aquí está lo principal: INVALID_INPUT es un código superior estable, y REQUIRED es un código de detalle estable. El mensaje sigue siendo texto «vivo».

7. Errores típicos al diseñar errores

Error n.º 1: hacer que code sea igual a message.
Es una trampa muy común: parece que «ya se entiende». Pero entonces code deja de ser un identificador de máquina y se convierte en un segundo message. El cliente empieza a depender de las formulaciones, y cualquier corrección del texto se convierte en un posible breaking change, aunque el significado no haya cambiado.

Error n.º 2: obligar al cliente a distinguir errores por el texto del mensaje.
En cuanto se sorprenda pensando «el cliente puede comprobar que el mensaje contiene la palabra "obligatorio"», deténgase y aléjese lentamente del teclado. El texto cambia, se localiza, se refina, a veces simplemente se corrigen erratas. El código de detalle es justamente para que la lógica del cliente sea estable.

Error n.º 3: exponer en el contrato externo los nombres internos de anotaciones y validadores.
Exponer hacia afuera NotBlank, Size, UniqueTagsValidator significa atar al cliente a su cocina interna. Hoy reescribe la validación y de repente «el contrato cambió». Es mucho más seguro tener un pequeño diccionario de significados: REQUIRED, TOO_LONG, DUPLICATE, etc.

Error n.º 4: inflar los códigos de detalle con combinaciones “campo+error”.
Códigos como TITLE_REQUIRED, ASSIGNEE_NAME_TOO_LONG parecen convenientes hasta que se vuelven 50, luego 150, y luego ya no está seguro de cuáles se usan realmente. path ya dice dónde está el problema, por lo que el código de detalle solo debe indicar qué regla se violó.

Error n.º 5: cambiar el código de alto nivel de un endpoint a otro.
Si en un lugar es INVALID_INPUT, en otro TASK_INVALID, en un tercero BAD_REQUEST_FIELDS, el cliente tiene que mantener un «zoológico» de códigos sin beneficio real. Para la validación, casi siempre es suficiente un único código superior, y mantener toda la concreción en la lista errors.

1
Tarea
Spring REST & MVC, nivel 18, lección 2
Bloqueada
Separación del código de alto nivel y los códigos de detalle
Separación del código de alto nivel y los códigos de detalle
1
Tarea
Spring REST & MVC, nivel 18, lección 2
Bloqueada
Mapeo de Bean Validation a códigos detail estables
Mapeo de Bean Validation a códigos detail estables
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION