CodeGym /Cursos /Java Server /Cuando JSON no coincide con DTO

Cuando JSON no coincide con DTO

Java Server
Nivel 16, Lección 3
Disponible

1. Que JSON no coincida con DTO es algo normal

Si esperas que el JSON externo encaje perfectamente con tu record, tengo dos noticias para ti. La buena: eres optimista. La mala: la red pronto te quita el optimismo. El proveedor externo vive su propia vida: añade campos, los renombra, a veces devuelve null, a veces simplemente “no devuelve el campo en absoluto” y otras veces devuelve un tipo incorrecto. Y esto no es un “caso excepcional raro”, sino una rutina típica de integración.

Es importante aceptar una idea simple: Jackson no es “magia que lo arreglará todo”, sino un traductor estricto. Lee el contrato (JSON) e intenta trasladarlo a tu modelo (DTO). Si el modelo está descrito honestamente, todo va bien. Si el modelo es “más o menos parecido”, obtienes o bien valores null/por defecto, o bien una excepción. Ambas opciones son peligrosas a su manera: la excepción es ruidosa pero útil (el problema se ve de inmediato), mientras que un null silencioso puede llegar hasta un NullPointerException tardío, y entonces ya no se parece en absoluto a un “error de mapeo”, aunque todo empezó precisamente allí.

Para mantener la cabeza en orden, es útil ver toda la cadena como un pipeline independiente. No es enorme, pero contiene un punto explícito donde debemos “detener el mundo” si el contrato no coincide:

flowchart TD
    %% (8) Diagrama del flujo: primero verificamos el estado, luego mapeamos JSON a DTO
    A["HttpResponse<String> status + body"] --> B{"status 2xx?"}
    B -- no --> C[procesar error del proveedor no intentamos leer el DTO de éxito]
    B -- sí --> D[ObjectMapper.readValue]
    D --> E[Provider DTO]
    E --> F[el código siguiente ya trabaja con tipos]
    D --> G["JsonProcessingException (el contrato no coincide)"]

Observa un detalle: el mapeo debe ocurrir después de verificar el status, de lo contrario, fácilmente intentarás leer un “JSON de error” como un “DTO de éxito” y obtendrás una mezcla sin sentido de excepciones. Esto no es porque Jackson sea malo, sino porque intentamos traducir al ruso un texto que en realidad estaba en chino.

2. Nombres de campos y snake_case

Los primeros problemas suelen comenzar no con los “tipos”, sino con los “nombres”. En Java estamos acostumbrados a camelCase, pero en el JSON externo es muy común encontrar snake_case. Y si simplemente nombras el componente de tu record a tu manera, Jackson no tiene por qué adivinar que authorNames es lo mismo que author_name. No es telépata, es una biblioteca.

La forma más comprensible para un principiante es la vinculación explícita del nombre del campo mediante @JsonProperty. Sí, son unas letras más en el código, pero así estás indicando literalmente: “este campo del JSON se corresponde con este componente de Java”. Es un buen intercambio: unas pocas letras a cambio de previsibilidad.

Imaginemos un fragmento de la respuesta de búsqueda al estilo de Open Library (aproximadamente, sin pretender ser una copia exacta):

{
  "docs": [
    {
      "key": "OL1M",
      "title": "Clean Code",
      "author_name": ["Robert C. Martin"]
    }
  ]
}

Si queremos nombrar el componente de forma legible (authorNames), lo hacemos así:

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

public record OpenLibraryDocDto(
        String key,
        String title,
        // Vinculamos explícitamente el nombre del campo en JSON (snake_case) con el nombre del componente en Java (camelCase)
        @JsonProperty("author_name") List<String> authorNames
) {}

Aquí es importante que @JsonProperty("author_name") no es “por estética”. Es una instrucción a Jackson: “cuando veas author_name, colócalo en authorNames”.

Y ahora la deserialización se verá como “trabajo normal”, no como “juego de adivinanzas”:

import tools.jackson.databind.ObjectMapper;

public class Demo {
    public static void main(String[] args) throws Exception {
        // El mismo JSON que llegó del proveedor
        String json = """
            {"key":"OL1M","title":"Clean Code","author_name":["Robert C. Martin"]}
            """;

        // ObjectMapper es el punto donde JSON se convierte en un DTO tipado
        ObjectMapper mapper = new ObjectMapper();
        OpenLibraryDocDto dto = mapper.readValue(json, OpenLibraryDocDto.class);

        // Gracias a @JsonProperty, la lista de autores se rellena realmente
        System.out.println(dto.authorNames().get(0)); // Robert C. Martin
    }
}

A veces el proveedor es aún más divertido: el campo podría haber sido renombrado, o diferentes endpoints llaman a lo mismo de maneras distintas. Para esos casos existe @JsonAlias, donde enumeras nombres alternativos. No abusaría de ello, pero como “cinturón de seguridad” es útil: fijas explícitamente que en el JSON podrían encontrarse variantes y las aceptas.

La conclusión clave aquí es simple: el nombre del componente del record es parte del contrato desde el lado de Java, y @JsonProperty es el “puente” hacia el contrato del proveedor. Cuanto más explícito sea el puente, menos sorpresas.

3. Campos faltantes y null

En este punto suele ocurrir una típica “tragedia de junior” en tres actos. Primer acto: el campo no llegó, Jackson puso null. Segundo acto: llamas a .isEmpty en la lista. Tercer acto: NullPointerException, y sospechas que el problema está “en Java”, cuando en realidad el problema está “en el contrato”.

Hay que distinguir al menos tres estados que externamente se ven similares, pero su significado es diferente:

Estado en JSON Ejemplo Qué habrá en el DTO (generalmente) Por qué es importante
El campo no está { "title": "Clean Code" } null (para tipos de referencia) Esto es “no se transmitió el valor en absoluto”
El campo existe y es igual a null { "first_publish_year": null } null Esto es “el valor existe pero está vacío/es desconocido”
El campo existe y la colección está vacía { "author_name": [] } List de tamaño 0 Esto es “no hay valores, pero la lista como entidad existe”

Si el tipo en el DTO se elige de forma desafortunada, te estás añadiendo problemas a ti mismo. El ejemplo más frecuente es usar primitivos donde el campo puede faltar.

Mira lo insidioso que es int:

import com.fasterxml.jackson.annotation.JsonProperty;

public record DetailsDto(
        String title,
        // El primitivo int no puede ser null: si el campo no llega, será 0 (y es fácil confundirlo con datos reales)
        @JsonProperty("first_publish_year") int firstPublishYear
) {}

Si el JSON llega sin first_publish_year, Jackson (en la mayoría de los casos) pondrá 0. Y ahora tu aplicación tiene un “libro del año cero”. Felicidades, acabas de inventar la literatura antigua del futuro.

Es mucho más honesto usar Integer, para que la ausencia de valor se exprese como null:

import com.fasterxml.jackson.annotation.JsonProperty;

public record DetailsDto(
        String title,
        // El tipo wrapper permite null: así la ausencia del campo no se disfraza como "0"
        @JsonProperty("first_publish_year") Integer firstPublishYear
) {}

Y ahora el comportamiento es más transparente:

import tools.jackson.databind.ObjectMapper;

public class Demo {
    public static void main(String[] args) throws Exception {
        // El proveedor no envió first_publish_year
        String json = """
            {"title":"Clean Code"}
            """;

        ObjectMapper mapper = new ObjectMapper();
        DetailsDto dto = mapper.readValue(json, DetailsDto.class);

        // Y esto se ve honestamente en el DTO: null, no "0"
        System.out.println(dto.firstPublishYear()); // null
    }
}

Un dolor particular son las listas. Si el proveedor a veces no devuelve author_name, Jackson pondrá null, y tu código de normalización (en la siguiente lección) puede fallar fácilmente. Aquí se puede hacer un truco pequeño y muy práctico: añadir un constructor compacto en el record y reemplazar null por una lista vacía. Esto no es “arquitectura compleja”, es simplemente una protección contra la realidad.

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

public record OpenLibraryDocDto(
        String key,
        String title,
        @JsonProperty("author_name") List<String> authorNames
) {
    public OpenLibraryDocDto {
        // En el límite JSON → DTO normalizamos null a lista vacía para no tener NPE después
        if (authorNames == null) authorNames = List.of();
    }
}

Ahora se puede llamar a authorNames.isEmpty con tranquilidad, sin riesgo de obtener un NPE.

El sentido de toda la sección: cuando un campo puede faltar, debe ser visible por el tipo (Integer, List, no int). Y cuando null rompe la lógica posterior, es mejor “limpiarlo” una vez cuidadosamente en el límite del DTO que después estar atrapando NPE aleatorios por todo el código.

4. Campos desconocidos y extensión del DTO

El siguiente clásico del género: describiste un DTO con tres campos, y el proveedor envió veinte. No sabes nada de esos veinte y no quieres saberlo. Pero Jackson puede decir: “Alto. En tu modelo no existe ese campo, por lo tanto, el contrato no coincide”.

Para un principiante, esto parece “Jackson está siendo malo”. Para un desarrollador backend, parece “una cuestión de disciplina: ¿queremos ser estrictos o tolerantes con la extensión del contrato?”

Imaginemos un JSON donde el proveedor ha añadido el campo edition_count y nosotros no lo hemos descrito:

{
  "key": "OL1M",
  "title": "Clean Code",
  "edition_count": 42
}

Y un DTO sin ese campo:

public record BookDto(String key, String title) {}

Si Jackson está configurado de forma estricta, obtendrás una excepción del tipo “campo desconocido”. Esto es útil cuando quieres detectar cualquier cambio en el contrato. Pero para un proveedor externo, a veces es demasiado nervioso: hoy añadieron un campo y todo tu cliente se cae, aunque ni siquiera lees ese campo.

El compromiso más simple y muy extendido para un provider DTO es ignorar los campos desconocidos. Así el proveedor puede añadir nuevos campos y tu cliente no se caerá sin motivo.

Esto se hace ya sea con una anotación en el DTO:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@JsonIgnoreProperties(ignoreUnknown = true)
public record BookDto(String key, String title) {}

O configurando el ObjectMapper (pero eso ya está más cerca de la “configuración del mapper”, y hemos acordado no meternos en configuración profunda). La anotación es buena porque la decisión de “ignorar lo superfluo” está justo al lado del DTO y no se convierte en “magia global”.

Pero aquí es importante no caer en el extremo opuesto. Si ignoras campos desconocidos siempre y en todas partes, puedes pasar por alto tu propio error. Por ejemplo, esperabas author_name, pero en la anotación escribiste @JsonProperty("autor_name") (perdiste la letra h). En modo estricto, Jackson podría haberte avisado: “oye, en el JSON existe el campo author_name, pero no lo estás mapeando”. En modo “ignorar todo”, obtendrás simplemente un null y estarás depurando eso mucho más tiempo.

Por lo tanto, ten en cuenta el equilibrio: ignorar campos desconocidos es útil, pero debe hacerse de forma consciente, normalmente solo en provider DTO, donde el contrato es “ajeno” y puede ampliarse sin previo aviso. En cambio, lo estrictos que seamos con nuestro modelo interno es una disciplina aparte. Por ahora nos basta con aprender a no caernos por “campos extra”, pero sin perder la capacidad de detectar errores reales de mapeo.

5. Errores de tipo durante la deserialización

La categoría de problemas más desagradable es cuando el JSON es sintácticamente correcto, el status parece exitoso, pero el mapeo falla porque los tipos no coinciden. Y esto es realmente molesto: miras el JSON y piensas “pues está bien”, pero Jackson dice “no, no está bien”.

Ejemplos típicos de discrepancias de tipos:

  • el campo llega como cadena "2008", pero esperas un número 2008;
  • el campo llega como número, pero esperas una cadena;
  • el campo llega como objeto {...}, pero esperas un array [...] (o viceversa);
  • el campo llega a veces como null, pero en tu DTO tienes un primitivo (int, boolean) y luego no entiendes de dónde salió un 0 o false.

Aquí tienes un ejemplo mínimo donde el año de publicación llega como cadena (y esperamos Integer):

import tools.jackson.databind.ObjectMapper;

public class Demo {
    public static void main(String[] args) {
        // JSON sintácticamente válido, pero el campo no es del tipo esperado (cadena en lugar de número)
        String json = """
            {"title":"Clean Code","first_publish_year":"two thousand eight"}
            """;

        ObjectMapper mapper = new ObjectMapper();

        try {
            // El error surgirá precisamente aquí: en la etapa JSON → DTO
            mapper.readValue(json, DetailsDto.class);
        } catch (Exception e) {
            // En código real es mejor registrar/envolver con contexto, pero para el ejemplo basta con un marcador
            System.out.println("JSON no coincide con DTO"); // JSON no coincide con DTO
        }
    }
}

Desde el punto de vista de la aplicación, son importantes dos cosas.

Primero: el error de mapeo debe capturarse allí mismo donde haces readValue, no “en algún lugar después”. Si dejas que la excepción suba sin contexto, en main se convertirá en “algo sobre Jackson”, y el estudiante (y tu yo futuro) no entenderá en qué etapa se rompió todo: ¿la red? ¿el status? ¿el JSON? ¿los tipos?

Segundo: es útil separar los “errores del proveedor” de los “errores de nuestro mapeo”. Para eso no hace falta construir una jerarquía de 12 clases de excepción (no estamos en un curso de “ceremonia empresarial”), basta con envolver la excepción en un mensaje comprensible.

Por ejemplo, un simple helper en el límite del código del cliente:

import tools.jackson.core.JsonProcessingException;
import tools.jackson.databind.ObjectMapper;

public class ProviderJsonReader {
    // Un solo mapper por instancia: aquí se concentra toda la lógica de lectura del JSON del proveedor
    private final ObjectMapper mapper = new ObjectMapper();

    public <T> T read(String json, Class<T> type) {
        try {
            // Límite: convertimos el JSON en cadena a un DTO concreto
            return mapper.readValue(json, type);
        } catch (JsonProcessingException e) {
            // Envolvemos en una excepción comprensible con contexto: en qué DTO falló
            throw new IllegalStateException("El JSON del proveedor no es compatible con el DTO: " + type.getSimpleName(), e);
        }
    }
}

Es breve pero muy práctico: ahora cualquier error de deserialización se convierte en un mensaje “el JSON del proveedor no es compatible con el DTO de tal tipo”, y no simplemente “JsonMappingException en algún lugar de las entrañas”. Y ves de inmediato en qué modelo falló.

Observa: no hacemos una “reparación automática inteligente” de tipos. Si el proveedor envió una cadena en lugar de un número, no es “para tragar en silencio”, es una señal: o bien has descrito mal el DTO (lo más probable), o bien el proveedor ha violado su propio contrato (también ocurre). En cualquier caso, hay que notarlo explícitamente.

6. Errores típicos al mapear JSON a DTO

Error nº 1: Esperar que los nombres de los campos coincidan “de alguna manera”.
Una trampa muy común es llamar al componente authorNames y esperar que se rellene desde author_name sin ninguna indicación. El resultado es que el DTO llega “vacío” y empiezas a sospechar del HttpClient, la red, los timeouts e incluso de la fase lunar. En la práctica, casi siempre es más fácil y honesto poner @JsonProperty y mostrar la correspondencia de inmediato.

Error nº 2: Usar primitivos donde el campo puede faltar.
int y boolean son tipos cómodos, pero no pueden expresar “no hay valor”. Si el campo no llega, obtienes 0 o false, y luego intentas interpretar eso como datos reales. Para el JSON externo es especialmente peligroso. Integer y otros tipos wrapper suelen dar un comportamiento más honesto, porque null te obliga a tomar una decisión consciente.

Error nº 3: No distinguir entre “el campo no está”, “el campo es null” y “colección vacía”.
Estos tres estados son fáciles de confundir, especialmente si acabas de pasar de un JSON en cadena a un DTO tipado. Pero significan cosas diferentes y rompen el código de maneras distintas. Si la lista de autores puede faltar, es mejor convertir null a lista vacía en el constructor compacto del record que estar atrapando NPE en la normalización.

Error nº 4: Caerse por campos desconocidos del proveedor y pensar que es un “error de Jackson”.
A los proveedores les gusta añadir campos. No siempre es un cambio de contrato “en mal sentido”, a veces es solo una ampliación. Si tu DTO describe solo lo que necesitas y no quieres caerte por cada campo extra, usa @JsonIgnoreProperties(ignoreUnknown = true) en el DTO del proveedor. Pero hazlo de forma consciente, entendiendo que la tolerancia excesiva puede ocultar tus propios errores tipográficos.

Error nº 5: Capturar errores de deserialización demasiado lejos del límite JSON → DTO.
Cuando la excepción sale disparada muy arriba, pierde contexto y se convierte en “algo sobre Jackson”. Es mucho más útil que el error se registre junto al readValue: así entiendes que la red y el status no tienen nada que ver, el problema está en la falta de coincidencia entre el contrato y el modelo. Un pequeño envoltorio alrededor de ObjectMapper suele dar más beneficios que cien println en lugares aleatorios.

1
Tarea
Java Server, nivel 16, lección 3
Bloqueada
Vinculación de `author_name` a `authorNames` mediante `@JsonProperty`
Vinculación de `author_name` a `authorNames` mediante `@JsonProperty`
1
Tarea
Java Server, nivel 16, lección 3
Bloqueada
Mensaje de error al no coincidir el tipo del campo
Mensaje de error al no coincidir el tipo del campo
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION