1. El Provider DTO no es el modelo de la aplicación
Cuando escribes un cliente para una API externa, es muy fácil caer en la trampa psicológica: como ya estamos mapeando JSON en objetos Java bonitos, entonces se supone que son «nuestros datos». Pero el proveedor externo no ha firmado contigo un pacto de amistad eterna ni de nombres de campos estables. Vive su vida, y su JSON es su idioma, sus costumbres y sus rarezas.
Imagina que llegas a otro país y aprendes exactamente una frase: «¿Dónde está la biblioteca?». Puedes usar esa frase, pero si empiezas a construir toda tu vida (trabajo, relaciones, hipoteca) basándote en ella, el primer cambio de contexto te romperá. El Provider DTO es esa «frase aprendida», mientras que nosotros necesitamos un diccionario interno del proyecto.
En ReadLater Starter esto es especialmente importante porque tenemos un contorno de aprendizaje claro: el comando catalog search y el comando catalog details. Ambos deben trabajar con el resultado en los términos de nuestro proyecto, no en los términos de «cómo lo llamó la API externa esta mañana».
2. Provider DTO: espejo del contrato externo
El Provider DTO es un modelo que refleja lo más fielmente posible el JSON del proveedor: sus nombres de campos, su estructura, sus envoltorios, sus anidaciones. Los Provider DTO no tienen por qué ser bonitos. Más aún, si empiezas a «renombrar todo a tu manera» directamente en este nivel, pierdes algo importante: la posibilidad de comparar fácilmente el DTO con el JSON de muestra y la documentación del proveedor.
Tomemos un ejemplo típico de respuesta de búsqueda: el proveedor tiene un array docs, y dentro de cada elemento los campos pueden llamarse como si los hubieran elegido por votación en un chat de tres personas. (Es broma. Normalmente lo elige una sola persona y con una fecha límite.)
Ejemplo de Provider DTO para un elemento de búsqueda:
package com.example.readlater.catalog.dto.provider;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Provider DTO: modelo lo más cercano posible a los campos del proveedor.
* Aquí nos mantenemos deliberadamente en los términos del contrato externo.
*
* key — identificador externo del libro en el catálogo del proveedor
* title — título, tal como lo envió el proveedor
* authorNames — lista de autores del campo author_name (así se llama en JSON)
*/
public record OpenLibraryDocDto(
String key,
String title,
@JsonProperty("author_name") List<String> authorNames
) {}
Aquí hay varias ideas importantes, aunque el código es corto.
Primero, no fingimos que author_name es un «nombre incorrecto». Es el nombre del proveedor, y lo aceptamos. Segundo, aun así queremos un nombre conveniente en Java (authorNames), por eso usamos @JsonProperty, y Jackson entiende que es el mismo campo.
Ahora el envoltorio de respuesta:
package com.example.readlater.catalog.dto.provider;
import java.util.List;
/**
* Provider DTO: envoltorio de respuesta de búsqueda del proveedor.
* Los campos y nombres son como en el contrato (docs, numFound), sin renombramientos «nuestros».
*/
public record OpenLibrarySearchResponseDto(
List<OpenLibraryDocDto> docs,
int numFound
) {}
Sí, numFound puede que nos haga falta o no; pero por ahora lo hemos descrito honestamente porque realmente existe en el contrato. Y lo principal: en este nivel seguimos viviendo en el mundo del proveedor. Los nombres docs, key, author_name — todo es externo.
3. Normalized DTO: lenguaje interno del proyecto
El Normalized DTO es un modelo que refleja nuestros escenarios, no una estructura JSON ajena. Aquí elegimos nombres convenientes, tipos convenientes y una forma de datos conveniente. El Normalized DTO es lo que deben usar los comandos de la aplicación (catalog search, catalog details) y cualquier lógica aplicada de la parte cliente.
Por ejemplo, en catalog search sin duda necesitamos «identificador externo del libro», «título» y «un autor para una salida bonita» (aunque el proveedor devuelva una lista de autores). El modelo interno de búsqueda puede ser simple:
package com.example.readlater.catalog.dto.normalized;
/**
* Normalized DTO: modelo interno del resultado de búsqueda para nuestra aplicación.
* Esto ya es «nuestro idioma», independiente de los nombres de campos del proveedor.
*/
public record CatalogBookSearchItem(
String externalId,
String title,
String author
) {}
Observa dos renombramientos.
El campo key del proveedor se convirtió en externalId. En nuestro proyecto es más claro: es un ID en el catálogo externo. El campo author_name (lista) se transformó en author (cadena). Esto ya es una decisión de normalización: para la salida en consola tomamos un solo autor.
Y esto es normal. El Normalized DTO existe precisamente para tomar la decisión de qué forma de datos queremos dentro del proyecto.
4. Mapping-step: normalización como paso separado
Cuando conoces por primera vez el Provider DTO y el Normalized DTO, surge el deseo de hacer «mapeo sobre la marcha»: aquí en un método un if, allá un substring, aquí un stream(), y de alguna manera se resuelve solo. Pero esto rápidamente se convierte en un caos. Es mucho más fácil para la mente — y para el mantenimiento — si entre estos modelos hay un paso de normalización explícito: un método o clase separada que se encargue de la transformación.
El esquema es muy simple — y es útil tenerlo visualmente:
flowchart TD
A["cadena JSON sin procesar"] --> B["Provider DTO OpenLibrarySearchResponseDto"]
B --> C["Normalizador / Mapeador"]
C --> D["Normalized DTO CatalogBookSearchItem"]
D --> E["Resto del código de la aplicación catalog search / details"]
Este esquema es importante porque la «suciedad del mundo exterior» (campos inestables, nombres extraños, datos superfluos) se detiene en la etapa B→C. A partir de ahí, en el punto D, el código empieza a vivir con nombres normales y una forma predecible.
Ejemplo de normalización de un elemento:
package com.example.readlater.catalog.service;
import com.example.readlater.catalog.dto.normalized.CatalogBookSearchItem;
import com.example.readlater.catalog.dto.provider.OpenLibraryDocDto;
public class CatalogNormalizer {
public static CatalogBookSearchItem toSearchItem(OpenLibraryDocDto doc) {
// El mundo exterior puede enviar null o una lista vacía — esto es normal en JSON.
// Nuestra tarea: llevar a una forma estable para el resto del código de la aplicación.
String author = (doc.authorNames() == null || doc.authorNames().isEmpty())
? "Unknown author" // Valor por defecto para que UI/CLI no falle con datos vacíos
: doc.authorNames().get(0); // Para search tomamos el primer autor (decisión de normalización)
// El key del proveedor lo convertimos en un ID externo en términos de nuestro proyecto.
return new CatalogBookSearchItem(doc.key(), doc.title(), author);
}
}
Sí, aquí hay una pequeña «decisión de negocio»: si no hay autores, escribimos Unknown author. Se podría elegir otro comportamiento, lo importante es que ahora está concentrado en un solo lugar. Y si decides poner "—" en lugar de "Unknown author", cambias una línea, en lugar de buscar por el proyecto 17 sitios donde alguien «sobre la marcha» puso un valor por defecto.
Y ahora el camino completo: JSON → provider response → lista de items normalizados.
import com.example.readlater.catalog.dto.normalized.CatalogBookSearchItem;
import com.example.readlater.catalog.dto.provider.OpenLibrarySearchResponseDto;
import com.example.readlater.catalog.service.CatalogNormalizer;
import tools.jackson.databind.ObjectMapper;
import java.util.List;
public class CatalogSearchParser {
// ObjectMapper — punto donde convertimos el JSON «crudo» en Provider DTO.
private final ObjectMapper mapper = new ObjectMapper();
public List<CatalogBookSearchItem> parse(String json) throws Exception {
// 1) Deserializamos estrictamente en Provider DTO (en términos del proveedor).
OpenLibrarySearchResponseDto dto = mapper.readValue(json, OpenLibrarySearchResponseDto.class);
// 2) Normalizamos a DTO internos, que se pueden entregar de forma segura al resto del código.
return dto.docs().stream().map(CatalogNormalizer::toSearchItem).toList();
}
}
Observa que aquí no mostramos la llamada de red — ya la tenemos en la capa de transporte. Aquí solo está la parte «después de response.body()». Y es lógico: la capa de mapeo JSON se sitúa entre el transporte y el resto de la aplicación.
5. Protección contra fugas del Provider DTO
La razón más común por la que los Provider DTO empiezan a vivir en todas partes es muy trivial: simplemente los devuelves hacia afuera desde el cliente. Y luego «afuera» alguien empieza a usarlos porque «ya existen».
Aquí ayuda una regla férrea: la capa externa (cliente del proveedor) debe devolver solo Normalized DTO. Es decir, las firmas de los métodos son tu «guardia fronterizo». Si devuelves un ProviderBookDto en la frontera, tú mismo has abierto las puertas.
Comparemos dos firmas. La primera es mala porque filtra el contrato externo:
import com.example.readlater.catalog.dto.provider.OpenLibraryDocDto;
import java.util.List;
public interface CatalogClientBadIdea {
// Mala idea: hacia afuera se filtra el Provider DTO, y el resto del código empezará a vivir con el contrato del proveedor.
List<OpenLibraryDocDto> search(String query);
}
La segunda es buena porque hacia afuera sale el lenguaje interno del proyecto:
import com.example.readlater.catalog.dto.normalized.CatalogBookSearchItem;
import java.util.List;
public interface CatalogClient {
// Buena idea: hacia afuera solo devolvemos Normalized DTO (lenguaje interno del proyecto).
List<CatalogBookSearchItem> search(String query);
}
La diferencia es «solo de tipos», pero en la práctica es la diferencia entre un proyecto que sobrevivirá a cambios del proveedor y un proyecto que se arreglará como un televisor viejo: «le diste un golpe en el costado y funcionó, pero mejor no tocarlo».
Para fijar la idea, vamos a resumirla en una tabla. No es teoría por teoría, sino literalmente «lo que verás en el código dentro de una semana».
| Característica | Provider DTO | Normalized DTO |
|---|---|---|
| ¿A quién pertenece el modelo? | Al proveedor | A nuestro proyecto |
| Nombres de campos | author_name, docs, key | externalId, items, author |
| Estabilidad | Puede cambiar sin previo aviso | Estable mientras no decidamos cambiarlo nosotros |
| ¿Dónde vive? | Dentro del catalog client | En la parte aplicada de catalog search/details |
| ¿Qué hacer ante un cambio del proveedor? | Modificamos el DTO y el normalizador | Idealmente, no tocamos el resto del código |
Y sí: los Provider DTO pueden incluso no ser públicos. Por ejemplo, se pueden mantener en un paquete que nadie «accidentalmente» importe. Pero incluso sin eso, si has construido bien las firmas, igualmente no se filtrarán.
6. Normalización: lugar para pequeñas decisiones
A muchos principiantes les asusta la idea misma de «normalización»: parece que «distorsionamos los datos». En realidad no los distorsionamos, sino que los llevamos a una forma conveniente para los escenarios. En el mundo real del backend es una práctica habitual: el contrato externo casi siempre es más rico, más extraño y menos conveniente que lo que necesita tu aplicación.
Por ejemplo, el proveedor puede dar una lista de autores, pero para catalog search necesitamos un solo autor para mostrar un resultado bonito en consola. Esa es una decisión de normalización: tomar el primer autor o unirlos con comas. El proveedor puede dar una lista vacía, pero necesitamos mostrar algo al usuario — ponemos un valor por defecto. El proveedor puede devolver title con espacios extra — hacemos trim().
Es mejor mantener esas decisiones en un solo lugar: en el normalizador. Y es muy deseable que sean lo más simples y claras posible. El normalizador no es lugar para «lanzar una mini IA para adivinar quién es el autor principal». No hay que convertirlo en una filosofía.
Ejemplo de una elección de autor un poco más «cuidadosa» sin complicar:
import java.util.List;
public class AuthorPicker {
public static String pickSingleAuthor(List<String> authors) {
// Si no hay autores, devolvemos un valor por defecto para que el resto del código no haga null-check en todas partes.
if (authors == null || authors.isEmpty()) {
return "Unknown author";
}
// Tomamos el primer autor y limpiamos espacios: es una pequeña normalización típica.
return authors.get(0).trim();
}
}
Este código es primitivo, pero en un proyecto educativo es una ventaja. Es fácil de leer, fácil de modificar y difícil de romper.
7. Escenario: el proveedor cambió el contrato
Imaginemos un día típico en la vida de una API externa: «mejoraron un poco el contrato» (léase: renombraron un campo), y ahora en lugar de author_name llega authors. Si el Provider DTO y el Normalized DTO están fusionados en uno, modificas el DTO — y luego en cascada se rompen todos los lugares donde usabas authorNames().
En cambio, si tienes un límite claro, la zona de cambios se vuelve pequeña: el Provider DTO (y posiblemente una anotación @JsonProperty) más el normalizador. Los modelos normalizados y el resto del código no saben ni deben saber lo que ocurre en el proveedor.
Por ejemplo, solo modificas el Provider DTO:
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Provider DTO después del cambio de contrato del proveedor.
* Nos adaptamos en la frontera para que el código interno del proyecto no cambie.
*/
public record OpenLibraryDocDto(
String key,
String title,
@JsonProperty("authors") List<String> authorNames // El nombre en Java lo dejamos igual, solo cambiamos el enlace al JSON
) {}
Y el normalizador, que trabaja con authorNames(), puede que ni siquiera cambie. Sí, es un ejemplo pequeño, pero muestra lo principal: localizamos el caos del mundo exterior.
En el desarrollo industrial a veces se le llama capa anticorrupción (anti-corruption layer): una capa que impide que el sistema externo «contamine» el modelo interno. El nombre es imponente, pero el sentido es simple: «no traigas rarezas ajenas a tu casa».
8. Errores típicos al trabajar con DTO
Error nº1: «¿Para qué dos DTO? Haré uno solo, pero lo llamaré bonito».
Esa decisión suele verse como record BookDto(String key, @JsonProperty("author_name") List<String> author, ...), y luego ese DTO se usa tanto como provider como interno. El problema no se manifiesta de inmediato, sino cuando necesitas renombrar key a externalId en la lógica de la aplicación, o cuando el mismo DTO empieza a servir tanto para search como para details, aunque la forma de las respuestas sea diferente. Al final el DTO se hincha, se vuelve «para todo» y pierdes claridad en los límites.
Error nº2: la normalización está dispersa por el proyecto «sobre la marcha».
Hoy escribes doc.authorNames().get(0) en algún sitio, mañana en otro lugar String.join(", ", doc.authorNames()), y pasado mañana en otro pones "Unknown author". Externamente todo funciona, pero el comportamiento se vuelve impredecible: una pantalla muestra un autor, otra todos, una tercera el valor por defecto. El normalizador es precisamente para que el comportamiento sea uniforme.
Error nº3: el Normalized DTO se convierte en una copia del Provider DTO «por si acaso».
A veces un estudiante piensa: «Haré un Normalized DTO, pero meteré todos los campos del proveedor, por si acaso». Ese es exactamente el momento en que el Normalized DTO deja de ser el lenguaje interno del proyecto y vuelve a ser un espejo del mundo exterior, solo que con otro nombre. El modelo normalizado debe contener exactamente lo que necesitan tus escenarios ahora; de lo contrario, pierdes el sentido de la separación.
Error nº4: los nombres de campos del proveedor se filtran al Normalized DTO.
Si en tu modelo del proyecto aparecen campos como doc, numFound, key, author_name, entonces prácticamente has reconocido al proveedor como «dueño principal del significado». Los modelos internos deben hablar en tu idioma: externalId, items, count, author. No es estética, sino una forma de mantener el código legible e independiente.
Error nº5: falta de manejo de null/listas vacías en el normalizador.
En el JSON real del mundo exterior «a veces no hay autor», «a veces la lista está vacía», «a veces el campo falta». Si el normalizador hace get(0) sin verificar, obtendrás un hermoso IndexOutOfBoundsException en el momento más inoportuno — normalmente cuando estás mostrando el proyecto a alguien. El normalizador es el lugar donde debes «aterrizar» los datos en una forma estable.
GO TO FULL VERSION