Bases de datos y JPA: la traición de la normalización de manual

«La duplicación de datos es el mal absoluto»

En las clases de bases de datos de la universidad, el profesor lo repetía con tanta pasión que casi salpicaba saliva. «Primera forma normal, segunda forma normal, tercera forma normal… los datos duplicados desperdician espacio y rompen la integridad. ¡Divídanlo, y luego vuelvan a dividirlo!»

Yo seguí aquella enseñanza con disciplina. En el proyecto de fin de carrera dividí las tablas en 10, luego en 20 partes. ‘User’, ‘Address’, ‘City’, ‘Zipcode’… Mi diseño de base de datos era tan perfectamente «de manual» que para guardar una sola dirección hacían falta tres tablas distintas.

Pero en el trabajo real, ese diseño perfecto se convirtió en un desastre. Solo quería consultar una lista de clientes y terminé teniendo que encadenar cinco JOIN. La consulta se volvió compleja, la velocidad cayó y, peor aún, pasar esos datos a objetos Java era un sufrimiento.

«¿Por qué el código se vuelve tan complicado si solo quiero sacar una línea de datos para mostrarla en pantalla?»

Ahí fue cuando lo entendí. En la universidad te enseñan a optimizar el almacenamiento. En la práctica, la eficiencia de lectura importa mucho más. Y entre un lenguaje orientado a objetos como Java y una base de datos relacional corre un río mucho más difícil de cruzar de lo que parece.

Ordenarlo todo con limpieza, es decir, normalizar, no es lo mismo que dejarlo preparado para leerlo con comodidad.

Desajuste de paradigma: cuadrados y círculos

La causa profunda de este dolor es el llamado desajuste de paradigma, el famoso Impedance Mismatch.

En la universidad yo forzaba estos dos mundos a encajar escribiendo SQL a mano. Troceaba objetos Java para meterlos en la base de datos con INSERT, luego sacaba los datos con SELECT, los leía línea a línea desde un ResultSet y los iba pasando manualmente a colecciones Java como Set o List. Más que desarrollador, me sentía como un traductor de datos.

La tecnología que apareció para resolver ese trabajo repetitivo y agotador fue precisamente JPA (Java Persistence API), es decir, el mundo del ORM (Object-Relational Mapping).

JPA y ORM: trabajar con la base de datos a través de objetos

ORM, dicho literalmente, es una técnica que conecta objetos y bases de datos relacionales. Como indica el nombre, la clave está en definir y manejar las tablas de la base de datos como si fueran objetos Java.

Ya no hace falta escribir consultas CREATE TABLE a mano. En su lugar, creamos una clase Java y le pegamos una etiqueta llamada @Entity. Entonces JPA, el estándar ORM de Java, mira esa clase y dice: «Ah, así es la tabla que necesitas», y la crea automáticamente en la base de datos.

Al guardar datos tampoco hace falta escribir SQL. Basta con algo como repository.save(member), casi como si estuviéramos metiendo un elemento en una colección Java. El desarrollador se mantiene completamente dentro del pensamiento orientado a objetos, mientras que el trabajo sucio de traducir a SQL se lo delega a JPA.

Pero, como aprendimos en la serie Re: Booting, toda comodidad tiene un precio. Y precisamente por confiar demasiado en ese traductor automático llamado JPA, terminé plantando una bomba de relojería en mi código: el problema N+1.

[Code Verification] El problema N+1, una bomba de consultas

Es un problema por el que pasa prácticamente cualquier desarrollador junior la primera vez que usa JPA. La situación es simple: «Muestra todos los miembros junto con el nombre del equipo al que pertenecen.»

// 1. Consultar todos los miembros (1 consulta ejecutada)
List<Member> members = memberRepository.findAll();

for (Member member : members) {
    // 2. Imprimir el nombre de equipo de cada miembro
    // Si hay 100 miembros se ejecutan 100 consultas extra para traer los equipos.
    System.out.println(member.getTeam().getName());
}

La consulta que esperábamos: SELECT * FROM Member JOIN Team ... (solo una vez)

La consulta que realmente ocurrió:

Si hay 100 miembros, se lanzan 101 consultas: 1 + N. ¿Y si hay 10.000 miembros? Entonces 10.001 consultas golpean la base de datos. Ese es el famoso problema N+1, el tipo de error que puede tumbar un servidor. JPA intentó ser cómodo trayendo los datos de forma perezosa, cuando hacían falta, y esa misma comodidad terminó provocando el desastre.

Lo que podría recuperarse de una sola vez se divide en cien viajes. Esa es la ineficiencia del problema N+1.

Consejo práctico: diseño de base de datos pragmático

Entonces, ¿qué se hace en la práctica? Hay que encontrar un equilibrio entre la normalización de manual y la comodidad de JPA.

Cierre: para estar más cómodo, hay que saber más

JPA es, sin duda, una revolución. Nos liberó de la repetición agotadora de escribir SQL una y otra vez. Pero pensar «como ya uso JPA, ya no necesito saber SQL» es peligroso.

JPA no es un mago. Es solo un secretario que escribe SQL en tu lugar. Si le das malas instrucciones a ese secretario, mediante un mapeo incorrecto, EAGER loading y similares, ese secretario disparará en silencio cien consultas y dejará la base de datos fuera de combate. Para vigilar y ajustar si el SQL generado por JPA es realmente eficiente, paradójicamente necesitas conocer SQL todavía más a fondo. La comodidad siempre trae consigo responsabilidad.

Ahora ya sabemos cómo meter datos en objetos. Pero ¿se puede enviar esos objetos, esas entidades, tal cual al frontend, a Vue.js? ¿Y si la entidad User contiene la contraseña? ¿Y qué pasa con la seguridad si terminamos enviando al frontend información que nunca debió recibir?

La próxima vez hablaremos de los DTO, Data Transfer Objects, y del diseño de APIs REST, es decir, de las técnicas para empaquetar los datos de forma segura antes de entregarlos.

Deja un comentario