CodeGym /Cursos /Spring Data JPA /Projections: solo los datos necesarios

Projections: solo los datos necesarios

Spring Data JPA
Nivel 22, Lección 3
Disponible

1. Cuándo se necesitan projections

Si acabas de dominar join fetch y @EntityGraph, una idea lógica suena así: «Bueno, ahora voy a cargar las relaciones con una sola consulta en todas partes y vivir feliz». Y es casi cierto… hasta que te sorprendes cargando toda una entidad por un par de campos. Es como pedir un camión de carga para traer un solo panecillo: el panecillo llegará, pero pagaste el camión, la gasolina y el conductor, que además preguntará dónde descargar los palés.

Recordemos la realidad de nuestro proyecto shop-data-jpa. Tenemos las entidades Product, Category, CustomerOrder, OrderItem. Las entidades suelen tener muchos campos, a veces campos grandes de texto (descripciones), a veces relaciones (categoría, líneas de pedido, productos en las líneas). Cuando devolvemos una entidad, devolvemos un objeto ORM que vive según las reglas del persistence context, puede ser un proxy, puede cargar perezosamente relaciones y, en general, es un «modelo de trabajo para escritura».

Pero muchos read-use-case en el backend no son de escritura. Por ejemplo, «mostrar la tabla del catálogo de productos» normalmente requiere sku, name, price y, quizás, category.name. Y eso es todo. No vamos a modificar esos productos directamente en este escenario; solo queremos mostrar filas.

Aquí es donde projections se convierten no solo en un «DTO bonito», sino en una verdadera herramienta de rendimiento: permiten leer la forma mínima suficiente de datos sin materializar el grafo de entidades y sin crear la ilusión de que son «objetos que se pueden modificar».

Para que sea más fácil mantener la imagen, aquí hay un mini-esquema de cómo cambia la idea:

flowchart TD
    A["Caso de uso de lectura: 'tabla del catálogo'"] --> B["Decidimos: ¿entidad o modelo de lectura?"]
    B -->|entidad| C["join fetch / @EntityGraph (si se necesitan relaciones)"]
    B -->|modelo de lectura| D["Projection (leemos solo los campos necesarios)"]
    D --> E["SQL selecciona 3-6 columnas, no todo el objeto"]

Entity vs Projection: qué obtienes «en tus manos»

Antes de escribir código, es importante acordar la terminología. Projection en Spring Data JPA es una forma de obtener el resultado de una consulta no como entidad, sino como una «vista»: una interfaz o DTO/record que contiene solo los campos necesarios. Para un principiante suena similar a «bueno, es lo mismo que DTO», pero la diferencia está en dónde se forma el resultado y qué se lee de la base de datos.

En nuestro curso mantenemos deliberadamente las relaciones LAZY como un default razonable y aprendemos a gestionar la lectura de forma consciente. Projections encajan perfectamente en esta idea: transforman la lectura de «tomaré la entidad y luego ya veré» a «leeré exactamente lo que necesita el caso de uso».

Comparemos la lectura basada en entidades y la lectura basada en proyecciones a nivel de sensaciones (y consecuencias):

Característica Entity (Product, CustomerOrder) Projection (ProductListView, OrderSummaryRow)
Qué es Entidad ORM, parte del modelo de escritura Modelo de lectura, resultado de consulta
Estado gestionado Puede estar managed en una transacción No, no es una entidad gestionada
Dirty checking Funciona (si la entidad es managed) No funciona, y eso es bueno (para lectura)
Carga perezosa Puede «dispararse» No «carga más» relaciones; obtienes solo lo solicitado
Volumen de datos A menudo más de lo necesario Normalmente el mínimo necesario
Mejor escenario Ficha + trabajo posterior dentro de la transacción (cambios, invariantes) Listas tabulares, resúmenes, informes, lecturas ligeras

Conclusión práctica clave: si el caso de uso es «leer y mostrar», entonces projection a menudo hace que el código sea más honesto. A nivel de la firma del repositorio dices inmediatamente: «estoy leyendo una línea del catálogo», no «estoy leyendo un producto como si fuera a modificarlo».

2. Projections basadas en interfaz

La proyección de interfaz es la forma más sencilla de empezar: declaras una interfaz con getters, y Spring Data JPA te devuelve un «objeto que implementa esos getters». Para un principiante parece magia, pero es magia útil: te obliga a formular el modelo de lectura de forma muy explícita. Además, una ventaja agradable: estas proyecciones suelen encajar muy bien con Page y Slice, por lo que son adecuadas para listas.

Imaginemos un caso de uso: «mostrar una página del catálogo de productos». En la UI o capa externa (incluso si es solo una verificación por consola) necesitamos sku, name, price. Para ello crearemos una interfaz de proyección, por ejemplo en el paquete com.example.shopdatajpa.catalog.query.

package com.example.shopdatajpa.catalog.query;

import java.math.BigDecimal;

public interface ProductListView {
    // SKU — normalmente el identificador comercial del producto, que se muestra en las listas
    String getSku();

    // Nombre del producto para la línea del catálogo
    String getName();

    // Precio — lo leemos como BigDecimal para evitar errores de redondeo
    BigDecimal getPrice();
}

Observa: aquí no hay setPrice(), no hay getCategory() (por ahora), no hay getId() «por si acaso». No intentamos hacer «casi Product». Describimos honestamente lo que necesita la lista.

Ahora agreguemos un método en ProductRepository. Importante: no creamos un repositorio separado «para proyecciones», simplemente agregamos un método de lectura dentro de la feature catalog (y si el proyecto crece, mueves los métodos de lectura a catalog.query, como está previsto en la estructura de nuestro curso).

import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductListView;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

// El repositorio devuelve directamente el modelo de lectura, no la entidad Product
Page<ProductListView> findByStatus(ProductStatus status, Pageable pageable);

Parece demasiado simple, y esa es la gracia. Ahora el servicio puede decir: «dame una página de líneas del catálogo», y el repositorio realmente devolverá no un Product, sino un ProductListView.

Ejemplo de uso dentro de la lectura del servicio (mantenemos el límite de lectura en el servicio, como hemos hecho en días anteriores):

import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductListView;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;

// Solicitamos la primera página (0) con 3 elementos
PageRequest page = PageRequest.of(0, 3);

// Obtenemos exactamente las líneas del catálogo, no las entidades
Page<ProductListView> rows = productRepository.findByStatus(ProductStatus.ACTIVE, page);

// Usamos solo los campos que realmente necesita el escenario de lectura
rows.forEach(r -> System.out.println(r.getSku() + " -> " + r.getPrice())); // SKU-1 -> 19.99

Desde el punto de vista de la ingeniería, esto es más importante de lo que parece. Si en su lugar haces Page<Product> y luego «recortas» los campos, seguirás cargando la entidad (y potencialmente todo lo que arrastre consigo), y luego fingirás que todo fue fácil.

Projection es la forma de «no cargar un camión», sino pedir la entrega exacta de un solo paquete.

3. Projections y JOIN para relaciones

Una situación muy común: quieres mostrar en una lista no solo los campos del producto, sino también algo de la entidad relacionada. Por ejemplo, en el catálogo te gustaría mostrar category.name. Y aquí empiezan los típicos «sueños de novato»: «Bueno, la categoría está en el producto, simplemente haré product.getCategory().getName()». Y luego volvemos a estar en N+1 (si la categoría es LAZY) o en una carga excesiva (si alguien puso EAGER y ahora la categoría se arrastra a todas partes).

Projection resuelve este problema, pero la regla importante del día es: la proyección no reemplaza la reflexión sobre la forma de la consulta. Si necesitas datos de una tabla relacionada, en SQL igual habrá un JOIN. La diferencia es que haces ese JOIN de forma consciente y lees las columnas necesarias.

El enfoque más cuidadoso para proyecciones de interfaz con relaciones es usar una proyección anidada. Es decir, ProductListWithCategoryView contiene un método getCategory() que devuelve una pequeña proyección para la categoría.

package com.example.shopdatajpa.catalog.query;

import java.math.BigDecimal;

public interface ProductListWithCategoryView {
    // Campos de la línea del catálogo
    String getSku();
    String getName();
    BigDecimal getPrice();

    // Proyección anidada: aquí decimos explícitamente que queremos un "trozo" de la categoría
    CategoryView getCategory();
}

Y CategoryView por separado (puede ser anidada, pero así es más compacto):

package com.example.shopdatajpa.catalog.query;

public interface CategoryView {
    // Código de la categoría (normalmente corto, estable)
    String getCode();

    // Nombre de la categoría para mostrar en la UI
    String getName();
}

Ahora el método del repositorio puede ser igual de simple:

import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductListWithCategoryView;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

Page<ProductListWithCategoryView> findByStatus(ProductStatus status, Pageable pageable);

Y el uso se vuelve honesto y seguro:

ProductListWithCategoryView row = rows.getContent().getFirst();

System.out.println(row.getName());                     // iPhone 17
System.out.println(row.getCategory().getName());       // Electronics

Es importante captar la idea: aquí no hay «carga perezosa». Si solicitaste category.name a través de una proyección, Spring Data formará la consulta para obtener ese campo. Es decir, el JOIN será parte del SQL, no una «navegación aleatoria por el objeto después del repositorio».

Si te surge la pregunta interna «¿y realmente es una sola consulta?», es una excelente pregunta. En este curso volvemos constantemente a los logs de SQL precisamente porque «un código Java bonito» no garantiza «un SQL bonito». Las proyecciones no son una excepción: si empiezas a usar getters «calculados» demasiado ingeniosos o expresiones SpEL en las interfaces, puedes terminar cargando la entidad y teniendo accesos perezosos a la base de datos. Por eso, dentro de nuestro enfoque junior-friendly, mantenemos la regla: la interface-based projection debe ser lo más «directa» posible, sin trucos, y reflejar los campos y relaciones reales.

4. DTO/record projections: select new ...

Las projections de interfaz son buenas porque son rápidas de escribir y se adaptan perfectamente a listas simples. Pero a veces quieres más claridad: ver directamente en la consulta qué campos se seleccionan, qué joins se hacen, si hay agregaciones (count, sum), y qué forma tiene el resultado.

Aquí nos ayuda la DTO/record projection mediante la expresión constructora de JPQL: select new ...

Tomemos un escenario típico de ordering: «mostrar una lista de pedidos por estado», donde en la tabla se necesitan orderNumber, totalAmount y la cantidad de líneas (itemsCount). No necesitamos la entidad del pedido con todas sus líneas; necesitamos un resumen.

Crearemos un record en com.example.shopdatajpa.ordering.query:

package com.example.shopdatajpa.ordering.query;

import java.math.BigDecimal;

// Modelo de lectura para una línea de la lista de pedidos (resumen, sin grafo de entidades)
public record OrderListRow(
        String orderNumber,     // número de pedido para mostrar
        BigDecimal totalAmount, // importe total del pedido
        long itemsCount         // cantidad de líneas en el pedido
) {}

Ahora agreguemos un método en CustomerOrderRepository (o como hayas llamado al repositorio de pedidos). Aquí escribimos @Query de forma consciente, porque tenemos un agregado count(i) y una agrupación.

import com.example.shopdatajpa.ordering.entity.OrderStatus;
import com.example.shopdatajpa.ordering.query.OrderListRow;
import org.springframework.data.jpa.repository.Query;

// Aquí describimos explícitamente la forma SQL del resultado: 3 campos + agregado count
@Query("""
  select new com.example.shopdatajpa.ordering.query.OrderListRow(
    o.orderNumber, o.totalAmount, count(i)
  )
  from CustomerOrder o left join o.items i
  where o.status = :status
  group by o.id, o.orderNumber, o.totalAmount
""")
java.util.List<OrderListRow> findRowsByStatus(OrderStatus status);

¿Qué es importante aquí (y útil para la mente de un novato)?

Ves que el join con items se hace directamente en la consulta. Nada de «uy, ¿por qué fue a la base de datos cuando llamé a getItems()?». Ves que el resultado no es CustomerOrder, sino OrderListRow. Y, finalmente, ves que leemos exactamente tres «columnas de significado»: número, importe, cantidad de líneas. Nada de deliveryAddress, nada de una lista de OrderItem, nada de Product dentro de cada línea.

Como resultado, esa consulta normalmente se convierte en un SQL bastante simple, similar en idea a:

select o.order_number, o.total_amount, count(i.id)
from customer_order o
left join order_item i on i.order_id = o.id
where o.status = ?
group by o.id, o.order_number, o.total_amount;

Esto ya se parece a una consulta de adulto, no a «carguemos todo el grafo de objetos y luego calculemos el tamaño de la colección en Java». Y sí, a veces una «consulta de adulto» como esta resulta inesperadamente más simple y rápida que intentar «arreglar N+1 con join fetch» cuando las entidades ni siquiera son necesarias.

5. Projections en el servicio y la entidad

El error más peligroso al estudiar las proyecciones es enamorarse de ellas y empezar a fingir que las entidades ya no son necesarias. Es como enamorarse de un destornillador e intentar clavar clavos con él. El clavo, por supuesto, se clavará… pero sufrirás de forma muy convincente.

El modelo correcto es: projections son herramientas del read-side. Son ideales para listas, resúmenes, informes, lecturas ligeras. Pero tan pronto como el caso de uso implique cambios de estado, invariantes y unit of work, vuelves a las entidades y a la transacción.

Veamos un contraste muy cotidiano.

Escenario de lectura del catálogo: devolvemos Page<ProductListView> o Page<ProductListWithCategoryView>. Es lectura pura, y el servicio puede ser readOnly.

import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductListView;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true) // Importante: transacción de solo lectura para el escenario "leer y mostrar"
public Page<ProductListView> loadCatalog(ProductStatus status, Pageable pageable) {
    // Devolvemos la proyección directamente desde el repositorio (sin cargar la entidad)
    return productRepository.findByStatus(status, pageable);
}

Y ahora el escenario de cambiar el precio de un producto. Aquí la proyección no sirve, porque necesitamos una entidad gestionada, dirty checking y una operación de negocio normal.

import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.transaction.annotation.Transactional;

@Transactional // Importante: transacción normal porque estamos cambiando el estado
public void changePrice(long productId, java.math.BigDecimal newPrice) {
    // Cargamos la entidad gestionada, de lo contrario el dirty checking no funcionará
    Product p = productRepository.findById(productId).orElseThrow();
    p.setPrice(newPrice); // El UPDATE saldrá a través del dirty checking
}

Si intentas «actualizar a través de una proyección», o no tendrás setter, o tendrás la ilusión de que modificaste el objeto, pero en la base de datos no cambiará nada. Y está bien: la proyección no debe convertirse en una «zona gris» donde no esté claro qué se guarda y qué no.

Hay otra disciplina importante: es mejor leer la proyección directamente desde el repositorio, en lugar de hacer esto: List<Product> products = repo.find... y luego products.stream().map(p -> new Row(...)). Ese mapeo a veces es aceptable, pero no soluciona el problema de la lectura excesiva. Ya cargaste la entidad (y posiblemente obtuviste N+1) antes de crear tu DTO. Por eso, en este día entendemos la proyección como «una forma de lectura a nivel de SQL», no como «un objeto bonito después del hecho».

6. Errores típicos al trabajar con projections

Error nº1: Leer la entidad y luego «recortar» 3 campos y considerarlo una proyección.
La trampa más común se ve así: devuelves List<Product>, luego haces map(p -> new ProductRow(p.getSku(), p.getName(), p.getPrice())) y te alegras. Visualmente obtienes «líneas del catálogo», pero ya no controlas el SQL: pudiste haber leído columnas adicionales, pudiste haber provocado accidentalmente cargas perezosas, pudiste haber obtenido N+1 incluso antes del mapeo. Una proyección real es cuando el repositorio devuelve directamente ProductListView o ProductCatalogRow.

Error nº2: Intentar usar la proyección como modelo de escritura.
A veces queremos «ahorrar» y escribir un método como ProductListView find..., luego llamar a un setter (que no existe), o fingir que «bueno, cambiaré el campo y luego lo guardaré». La proyección no es managed, no tiene ciclo de vida de entidad, no participa en dirty checking. Si el caso de uso implica cambios, carga la entidad dentro de una transacción y modifícala como entidad.

Error nº3: Hacer la proyección demasiado amplia — «casi entidad», pero sin la responsabilidad de una entidad.
Cuando la proyección incluye todos los campos del producto, y la categoría, y los detalles, y los stocks, y además «por si acaso» la dirección de envío, vuelves a obtener un «grafo de objetos gigante», solo que sin las ventajas de la entidad. Esas proyecciones se vuelven frágiles, difíciles de mantener y pierden el sentido como modelo mínimo de lectura. Una buena señal de una proyección saludable es que responde a un solo caso de uso y se parece a una «línea de pantalla», no a una «entidad universal».

Error nº4: Esperar que la proyección «por sí sola» elimine la necesidad de pensar en JOIN.
La proyección no teletransporta datos de tablas relacionadas. Si quieres category.name en una línea del catálogo, eso implica un JOIN en la consulta. La belleza de la proyección es que el JOIN se vuelve consciente y controlado, no «surgido accidentalmente por navegar por el objeto después del repositorio». Por lo tanto, al agregar campos anidados, pregúntate siempre mentalmente: «¿cómo se verá esto en SQL?».

Error nº5: Olvidar verificar que tu bonito código Java produce el SQL esperado.
Projections normalmente reducen el volumen de datos leídos y suelen reducir el número de consultas, pero la palabra «normalmente» es clave aquí. Si activas trucos ingeniosos, formulas mal la consulta o mezclas proyecciones con navegación perezosa de entidades, el resultado puede ser inesperado. Por eso mantenemos el hábito básico del curso: activamos los logs de SQL, miramos cuántas consultas se enviaron y qué columnas se leen realmente. A ORM le encantan las sorpresas, y a nosotros nos encanta la previsibilidad.

1
Tarea
Spring Data JPA, nivel 22, lección 3
Bloqueada
Proyección de interfaz para líneas del catálogo
Proyección de interfaz para líneas del catálogo
1
Tarea
Spring Data JPA, nivel 22, lección 3
Bloqueada
Record projection para el resumen de pedidos
Record projection para el resumen de pedidos
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION