1. Elección: readValue(...) o readTree(...)
Si miramos el JSON con ojos de principiante, parece «una única forma» de datos: aquí hay una cadena, aquí está ObjectMapper, y bueno, vamos a «de alguna manera» convertirlo en Java. Pero en realidad Jackson tiene dos estrategias populares para leer un mismo JSON: o bien convertirlo directamente en un typed DTO a través de readValue(...), o bien leerlo primero en un árbol de nodos (JsonNode) mediante readTree(...) y luego extraer las partes necesarias manualmente. Esta elección es similar a cómo lees un libro: o por el índice y los capítulos (estructurado), o extrayendo citas mediante búsqueda (flexible, pero puede convertirse rápidamente en un caos).
En el contexto de ReadLater Starter esto es importante porque el catálogo externo de libros no es un JSON «nuestro». Es ajeno. Puede ser grande, a veces extraño, y ocasionalmente cambiante. Y necesitamos elegir un estilo de trabajo con él de modo que, más adelante, cuando construyamos el cliente como un artefacto completo, el código siga siendo legible y predecible.
Ya sabemos cómo convertir un JSON sin procesar en un objeto Java mediante Jackson. Ahora debemos resolver una cuestión más práctica: qué estilo de lectura haremos principal para todo el cliente, y dónde dejarnos solo una flexibilidad puntual. De esto depende dónde se fijará el contrato del proveedor, cómo se verá el mapper y con qué tranquilidad el proyecto soportará los cambios en la API externa.
Primero, definamos brevemente ambos enfoques en términos humanos, y luego los compararemos con ejemplos.
2. Typed DTO: modelo rígido sin burocracia
Cuando lees JSON en un typed DTO, describes de antemano la forma de los datos en una clase Java (en nuestro curso, a menudo es un record). Es como si dijeras: «Espero que la respuesta se vea así: aquí hay una lista de docs, cada uno con key, title, authors...». Y Jackson hace la mayor parte del trabajo por ti: empareja campos, verifica tipos, construye objetos. Como resultado, tu código se vuelve más corto y los errores aparecen más cerca de la fuente del problema (en la etapa de mapeo), no en algún NullPointerException inesperado tres métodos después.
Un punto psicológico importante: typed DTO no es «sobreingeniería». No es «arquitectura por la arquitectura». Para la integración con una API externa, un typed DTO es una forma de fijar el contrato que realmente usas. Y no «en la cabeza», sino en el código. Tienes un tipo concreto, y el IDE/compilador te ayudan a no olvidar qué es exactamente lo que esperas.
Mini-ejemplo de typed DTO para búsqueda
Imaginemos (de forma simplificada) que el catálogo devuelve este JSON:
{
"docs": [
{"key": "OL1M", "title": "Clean Code", "authors": ["Robert C. Martin"]}
]
}
Para esto se puede crear un DTO del proveedor. Es un DTO del proveedor, por lo que los nombres de los campos pueden ser «como en JSON», incluso si no son ideales para el estilo Java. La normalización la haremos después — eso ya es el modelo interno del proyecto.
import java.util.List;
// Provider DTO: «forma de datos del proveedor», nombramos los campos igual que en JSON.
record ProviderSearchResponse(List<ProviderBookDoc> docs) {}
// Elemento interno del array docs: lo que realmente llega desde la API externa.
record ProviderBookDoc(String key, String title, List<String> authors) {}
Ahora la lectura:
import com.fasterxml.jackson.databind.ObjectMapper;
String json = """
{"docs":[{"key":"OL1M","title":"Clean Code","authors":["Robert C. Martin"]}]}
""";
ObjectMapper mapper = new ObjectMapper(); // Herramienta principal de Jackson para mapear JSON -> objetos
ProviderSearchResponse response = mapper.readValue(json, ProviderSearchResponse.class); // Typed DTO
// A partir de aquí trabajamos tipificadamente: sin cadenas mágicas "docs"/"title" en el código de negocio
System.out.println(response.docs().get(0).title()); // Clean Code
Observa algo agradable: cuando escribes response.docs().get().title(), no tienes «cadenas mágicas» "docs" y "title" por todo el código. Todo vive en un solo lugar: en el DTO. Sí, hay que escribir el DTO. Pero ese es un precio que casi siempre se amortiza si el campo no es de un solo uso.
Typed DTO — el camino principal para ReadLater Starter
En nuestro proyecto, la respuesta del catálogo no es «para mirar». Se necesita para:
1) extraer campos estables (key, title, autor),
2) convertirlos en un DTO normalizado del proyecto (por ejemplo, CatalogBookSearchItem),
3) mostrar al usuario el resultado en un formato comprensible.
Y esta es precisamente la situación de «contrato de uso repetido». Por lo tanto, typed DTO es tu estilo principal.
3. Árbol JSON: flexibilidad y precio
Ahora sobre el árbol JSON. JsonNode es una representación universal de JSON como un conjunto de nodos: objeto, array, número, cadena, null. Lees todo el JSON en un «árbol» y luego lo recorres: root.get("docs").get(0).get("title")... Es similar a trabajar con el DOM en un navegador: no describes tipos de antemano, sino que «extraes los elementos necesarios por el camino».
Este enfoque es excelente cuando necesitas:
- extraer rápidamente un campo que no merece la pena convertir en un DTO;
- procesar «datos auxiliares» que no participan en el modelo normalizado del proyecto;
- hacer una pequeña «sonda» en una respuesta que puede cambiar (pero no quieres vincularla a un modelo tipificado).
Pero el árbol tiene un precio: empiezas a escribir código con montones de nombres de campo en forma de cadenas y cadenas de .get(...). Y si no tienes cuidado, tu proyecto se convertirá en un «viaje por la jungla de JsonNode», donde cualquier error tipográfico en "numFound" se convierte en un bug, y el IDE no ayuda en absoluto.
Mini-ejemplo: leer «un campo auxiliar»
Supongamos que en la respuesta de búsqueda hay un campo numFound (número de registros encontrados), y quieres simplemente mostrar «Encontrados en total: N», pero sin crear un DTO separado solo para un número.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
String json = """
{"docs":[{"key":"OL1M","title":"Clean Code"}],"numFound":1}
""";
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json); // Leemos JSON como árbol de nodos
// path(...) es más seguro que get(...): si el campo no existe, no caemos con NPE
int found = root.path("numFound").asInt(0); // 0 — valor por defecto si el campo no existe o no es un número
System.out.println("found = " + found); // found = 1
Observa que estoy usando path(...) en lugar de get(...). Es un truco pequeño pero muy importante: path(...) es más seguro porque devuelve un «missing node», y puedes establecer un valor por defecto mediante asInt(0). Si usas get("numFound").asInt() y el campo no existe, fácilmente obtendrás un NullPointerException en un lugar inesperado. Y hoy estamos aprendiendo precisamente a escribir código que no se cae por un solo campo faltante, como si fuera un dominó.
Cuándo el árbol es una mala idea
En cuanto te sorprendas pensando: «Necesito extraer este campo… y este… y también este…», el árbol casi siempre empieza a perder frente al typed DTO. Porque ya no has extraído «un trocito», sino que estás montando manualmente un modelo de datos, solo que sin tipos y sin la ayuda del compilador.
Y aquí está lo más importante: el árbol JSON no es un estilo de proyecto, sino una herramienta local. En ReadLater Starter, el árbol puede ser útil de forma puntual, pero no debería convertirse en «la forma principal de leer la API externa».
4. Comparación con el ejemplo de catalog search
Cuando se compara «DTO vs JsonNode», a menudo se discute como en una guerra religiosa: «¡DTO es rigor! ¡JsonNode es flexibilidad!». Aterricemos: tenemos un caso de uso concreto, catalog search. Necesitamos obtener una lista de libros y mostrar al usuario varios campos.
Imaginemos una respuesta simplificada:
{
"docs": [
{"key": "OL1M", "title": "Clean Code", "authors": ["Robert C. Martin"]}
],
"numFound": 123
}
Para la aplicación, sin duda necesitamos docs[*].key, docs[*].title, docs[*].authors[0]. Esta es la parte estable del resultado. En cuanto a numFound, es útil, pero no obligatorio para la normalización posterior (es más bien «información para el usuario»).
En tal caso, lo más frecuente es que gane un híbrido: typed DTO para el «núcleo» de la respuesta y árbol para la «guinda del pastel». Pero es importante mantener el híbrido dentro de unos límites; de lo contrario, obtendrás dos formas de leer cualquier cosa.
Pequeña tabla comparativa
| Criterio | Typed DTO (readValue(...)) | Árbol (readTree(...), JsonNode) |
|---|---|---|
| Legibilidad del código principal | Generalmente alta: acceso a campos mediante métodos | A menudo baja con rutas largas root.path(...).path(...)... |
| Soporte del IDE/compilador | Excelente: tipos, autocompletado, errores en compilación | Casi nulo: cadenas de campos, errores en tiempo de ejecución |
| Flexibilidad ante «campos sobrantes» en JSON | Requiere configuración de ObjectMapper / anotaciones | Alta: tomas solo lo que necesitas |
| Cuándo está justificado | Cuando los campos se usan realmente y se repiten | Cuando se necesitan 1–2 campos auxiliares «in situ» |
Y ahora la conclusión clave para nuestro proyecto: para docs y los campos del libro — typed DTO; para numFound (si lo usamos solo para un mensaje) — el árbol es aceptable.
5. Regla simple de elección para ReadLater Starter
Ahora viene la regla que dan ganas de imprimir y colgar al lado del monitor. No porque sea «sagrada», sino porque ahorra horas en futuras refactorizaciones.
Si un campo del JSON externo participa en el DTO normalizado del proyecto o en la salida al usuario de forma regular, es candidato para un typed DTO. Lo describes una vez en un DTO del proveedor y luego accedes a él de forma tipificada.
Si el campo se necesita una sola vez, como «información auxiliar», y no se convierte en parte del modelo normalizado (por ejemplo, «total encontrados 123»), entonces es aceptable leerlo con cuidado desde el árbol JSON. Pero es importante que esa lectura sea corta, esté cerca del lugar de mapeo y no arrastre nombres de campo del proveedor por todo el proyecto.
Se puede decir aún más simple: si ves que con JsonNode estás construyendo una ruta larga de 4–6 pasos y empiezas a escribir path("docs").path(0).path("authors").path(0), haz una pausa y pregúntate: «¿No estoy describiendo ahora un DTO manualmente, solo que peor?». Normalmente la respuesta será «sí».
Mini-ejemplo: «señal» de que es hora de pasar a DTO
Este código parece «voy a extraer el autor rápidamente»… pero ya es incómodamente frágil:
// Ruta larga con índices y nombres de campo — señal frecuente de que es momento de describir un DTO.
String author = root.path("docs").path(0).path("authors").path(0).asText("Unknown");
// El valor por defecto "Unknown" salva de arrays vacíos/campos faltantes, pero la legibilidad sigue siendo mala.
System.out.println(author); // Robert C. Martin
Si es solo uno, es tolerable. Pero si tienes diez lugares así, empiezas a vivir en un mundo de cadenas mágicas y «¿estoy seguro de que aquí es authors y no author_name?». Y entonces el typed DTO deja de ser burocracia y se convierte en un salvavidas.
6. Mini-patrones para un uso cuidadoso de JsonNode
El árbol JSON solo es útil cuando lo usas como un bisturí, no como una motosierra. Hay algunos trucos simples que ayudan a mantener el árbol «local y seguro», y encajan bien con nuestro nivel actual.
El primer truco: elegir path(...) en lugar de get(...) casi siempre, si estás leyendo datos externos. get(...) es bueno cuando estás seguro de que el campo existe y quieres que el error sea explícito, pero en una API externa la seguridad a menudo resulta ser «optimismo». path(...) permite establecer un valor por defecto y obtener un comportamiento predecible.
// Si el campo está ausente, asInt(0) devolverá 0 — y el código seguirá funcionando de forma predecible.
int found = root.path("numFound").asInt(0);
System.out.println("numFound = " + found); // numFound = 1
El segundo truco: no esparcir las cadenas con nombres de campo por todo el proyecto. Si ya estás leyendo numFound a través del árbol, hazlo en un solo lugar: por ejemplo, en un método que analice la respuesta de búsqueda. Así, si la API externa renombra el campo, no tendrás que buscar "numFound" por todo el repositorio como si fuera un tesoro.
import com.fasterxml.jackson.databind.JsonNode;
// Es mejor sacar el acceso al campo del proveedor a un método separado y mantenerlo cerca del análisis de la respuesta.
static int readNumFound(JsonNode root) {
return root.path("numFound").asInt(0); // Valor por defecto en caso de ausencia/tipo inesperado
}
El tercer truco: no mezclar «extraer un campo» y «recoger todo el resultado». Es decir, el árbol es para una pequeña referencia, pero la lista de docs es mejor que vaya a través de un typed DTO. De lo contrario, caerás fácilmente en el estilo de «vamos a leerlo todo con el árbol, porque se puede». Se puede, pero luego tu código empezará a pedir clemencia.
Y un punto más importante: si usas un typed DTO y tu JSON tiene campos adicionales, Jackson puede quejarse si el ObjectMapper está configurado de forma estricta. En el ámbito del curso, normalmente hacemos que ObjectMapper sea más tolerante, para que los «campos extra» externos no te amarguen la vida con cada cambio del proveedor.
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
// Hacemos el mapper «tolerante»: la API externa puede enviar nuevos campos que aún no necesitamos.
ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Esto no significa «nos da igual el contrato». Significa «leemos solo lo que realmente usamos», y no nos caemos si el proveedor añade un campo nuevo que por ahora no nos interesa.
7. Errores típicos al elegir entre Typed DTO y JsonNode
Error n.º 1: hacer del árbol JSON el estilo principal del proyecto porque «así se empieza más rápido».
Sí, al principio parece que readTree(...) es un truco: no hay que describir nada, solo sacas el campo y listo. Pero en un proyecto real, esto se convierte rápidamente en un depósito de cadenas mágicas y largas cadenas de .path(...) que son difíciles de leer y aún más difíciles de modificar. Para catalog search y catalog details en nuestro curso, el camino principal debe seguir siendo orientado a DTO.
Error n.º 2: crear un DTO para cada campo raro, incluso si aparece una sola vez y no se usa en ningún otro lugar.
Este es el otro extremo: puedes convertir el proyecto en una colección de DTOs «por si acaso», donde la mitad de las clases se crearon solo para un número o una bandera. Como resultado, pierdes tiempo manteniendo algo que no te aporta valor. Si el campo es realmente auxiliar y de un solo uso, el árbol JSON puede ser más honesto y simple.
Error n.º 3: dispersar los nombres de campo del proveedor ("numFound", "docs", "authors") por diferentes partes de la aplicación.
Incluso si lees un solo campo a través de JsonNode, mantén esa lógica cerca del lugar donde trabajas con la respuesta del proveedor. En cuanto las cadenas con nombres de campo aparecen en main(), en el código de salida, en diferentes servicios — el contrato externo empieza a filtrarse por el proyecto, y cualquier cambio se convierte en una caza de erratas.
Error n.º 4: usar get(...) sin verificar y encontrarse con un NullPointerException de la nada.
El JSON externo puede no contener un campo, el array puede estar vacío, el tipo puede ser inesperado. Si lees todo con get(...) y luego llamas directamente a asText() — estás construyendo una casa de naipes. Para una API externa, es más seguro empezar con path(...) y valores por defecto; y si quieres un contrato estricto, ese es un argumento más a favor del typed DTO.
Error n.º 5: el «árbol manual» se vuelve más largo y complejo que un typed DTO, pero sigues escribiéndolo por principios.
Hay un buen indicador: si la ruta al valor ocupa más de una línea y la repites constantemente en varios lugares, significa que ya estás describiendo una estructura de datos — solo que sin tipos y sin soporte del IDE. En ese momento, es más fácil parar y sacar honestamente esa parte en un DTO del proveedor, dejando el árbol solo para valores realmente raros y auxiliares.
GO TO FULL VERSION