Hasta ahora, las consultas que hemos implementado en el curso (derivadas y JPQL) retornan entidades o un único valor escalar (una cadena, un número, un lógico). Una limitación importante porque nos impide proyectar cualquier conjunto de datos. Asimismo, está el hecho, nada evidente, de que recuperar entidades tiene implicaciones que no siempre necesitamos asumir.
Índice
- El problema con las entidades
- Proyección en constructor
- Otras proyecciones para JPQL
- Proyección en interfaz
- Relaciones
- Tipado dinámico
- Resumen
- Código de ejemplo
El problema con las entidades
Por eficiencia, solo debemos obtener desde la base de datos la información imprescindible. Esto incluye tanto al número de entidades como a sus campos. En JPA hay que añadir una consideración adicional que, ya sea por ignorancia o vagancia, se suele pasar por alto: solo obtendremos entidades si las necesitamos, y eso será cuando tengamos que modificarlas. Lo habitual es el requisito contrario: leer datos.
Aunque esta recomendación parece una obviedad, su justificación no resulta evidente. Ten en cuenta que la obtención de entidades conlleva lo siguiente:
- Se incorporan al contexto de persistencia. Son objetos que quedan almacenados en el contexto hasta que este sea destruido.
- Sus modificaciones se trasladan a la base de datos, salvo que empleemos transacciones de solo lectura (modo predeterminado).
- Se obtienen todos los atributos persistentes de la entidad. No siempre necesitamos todos.
- La tentación de obtener relaciones perezosas está ahí. Lo óptimo en la mayoría de situaciones es recuperarlas en una consulta. Para ello, JPQL cuenta con el operador Fetch join.
- Si necesitamos los datos en otros módulos de la aplicación, fuera de la persistencia o la lógica de negocio, utilizando entidades estaremos esparciendo código de persistencia por todo el sistema, además de exponer la estructura interna de la base de datos.
La solución habitual al último punto consiste en volcar el contenido de las entidades en objetos DTO, acrónimo que significa «objeto para la transferencia de datos». Estos objetos se modelan con clases diseñadas para contener datos y distribuirlos. Por lo general esas clases serán inmutables; es decir, sus objetos no pueden modificarse una vez creados. Aseguramos esta inmutabilidad declarando todos los atributos como finales.
¿Y si conseguimos que los repositorios ya retornen esos DTO cuando solo queremos leer datos? Evitamos la obtención de las entidades y escribir los conversores entidad-DTO. Un doble triunfo irresistible ✌️
Proyección en constructor
Vayamos al proyecto de ejemplo. Supongamos que queremos una versión del método que obtiene los países de una confederación que únicamente devuelva el nombre del país y su identificador, en lugar de entidades de la clase Country
. Por consiguiente, procedemos a crear esta clase:
package com.danielme.springdatajpa.model.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class IdNameDTO {
private final Long id;
private final String name;
}
Estamos ante un DTO inmutable porque sus atributos son finales, lo que implica que su valor debe establecerse en un constructor. Si bien con lombok es muy simple crear este tipo de clases, con Java 14 tenemos record
. Se trata de una clase especial cuyos atributos son finales y que cuenta de serie con un constructor con parámetros, los getters adecuados (aunque sin el prefijo get) e implementaciones de toString
, equals
y hashCode
. Por cierto, si queremos todo esto en IdNameDTO
, bastaría con marcarla con la anotación @Data
de lombok.
La clase IdNameDTO
como record
:
package com.danielme.springdatajpa.model.dto;
public record IdNameRecord(Long id, String name) {
}
¡Eso fue conciso! IntelliJ también lo cree así y nos animará a crear records
cuando sea posible.

En cualquier caso, la cuestión es cómo recoger en clases regulares y records
los resultados de las consultas derivadas. Pues lo cierto es que resulta trivial:
List<IdNameRecord> findIdNameAsRecordByConfederationId(Long id);
Esta magia tiene truco. El conjuro funciona porque IdNameRecord
tiene un único constructor y los nombres de sus parámetros coinciden con atributos de la clase de tipo entidad (Country
) del repositorio (CountryJpqlQueryRepository
).

Si algún parámetro del constructor incumpliera con la convención anterior, Spring lanzará una excepción durante el arranque, lo que a su vez aborta el inicio de la aplicación. Asimimo, si el tipado de algún parámetro fuera incompatible con el atributo equivalente en la clase, se lanzará una excepción cuando la consulta sea ejecutada.
Esta prueba verifica que el método findIdNameAsRecordByConfederationId
entrega los identificadores y nombres de las confederaciones:
@Test
void testFindIdNameAsRecordByConfederationId() {
List<IdNameRecord> uefaCountries
= countryRepository.findIdNameAsRecordByConfederationId(UEFA_ID);
assertThat(uefaCountries)
.extracting(IdNameRecord::id, IdNameRecord::name)
.containsExactlyInAnyOrder(tuple(SPAIN_ID, SPAIN_NAME),
tuple(NORWAY_ID, NORWAY_NAME),
tuple(NETHERLANDS_ID, NETHERLANDS_NAME));
}
Chequeando la bitácora veremos que la consulta SQL solo recupera el identificador y el nombre del país:
select c1_0.id,c1_0.name
from countries c1_0
where c1_0.confederation_id=?
Sencillo y eficiente. Bravo. 👏
¿Funcionará la proyección en constructor en una consulta JPQL? Hagamos lo mismo empleando @Query
:
@Query("SELECT c FROM Country c WHERE c.confederation.id=:id")
List<IdNameRecord> findIdNameAsRecordByConfederationId(Long id);
Pues va a ser que no:
org.springframework.core.convert.ConverterNotFoundException: No converter found capable of converting from type [com.danielme.springdatajpa.model.entity.Country] to type [com.danielme.springdatajpa.model.dto.IdNameRecord]
Funciona si recurrimos a la funcionalidad estándar de JPQL de proyección en constructor que apunté en el capítulo previo. Consiste en indicar en la claúsula SELECT
el constructor del siguiente modo:
@Query("""
SELECT new com.danielme.springdatajpa.model.dto.IdNameRecord(c.id, c.name)
FROM Country c WHERE c.confederation.id=:id""")
List<IdNameRecord> findIdNameAsRecordByConfederationId(Long id);
¡Genial! Ahora la proyección puede ser «cualquier cosa» admitida por un constructor. Ya nada limita a las consultas JPQL cuando las empleamos con Spring Data JPA. Por ejemplo, imagina que quieres un resumen estadístico que recopile el identificador de cada confederación futbolística, su nombre y la cantidad de países asociados. Escribirías algo así:
@Query("""
SELECT new com.danielme.springdatajpa.model.dto.ConfederationSummaryRecord(
c.id, c.name as name, COUNT(ct.id) as countries)
FROM Country ct INNER JOIN ct.confederation c
GROUP BY ct.confederation.id, c.name""")
List<ConfederationSummaryRecord> getSummaryCountryCount(Sort sort);
public record ConfederationSummaryRecord
(Long id, String name, Long countries){
}
Otras proyecciones para JPQL
Aun cuando la proyección en constructor es la mejor técnica para recoger el resultado de una consulta JPQL en cualquier clase, existen otras alternativas menos prácticas. En aras de una mayor completitud del contenido del curso, a continuación presentaré dos de ellas aplicadas a la consulta del último ejemplo.
Datos en crudo (raw) con Object[]
La primera alternativa es esta:
@Query("""
SELECT c.id, c.name, COUNT(ct.id) FROM Country ct INNER JOIN ct.confederation c
GROUP BY ct.confederation.id, c.name
ORDER BY c.name ASC""")
List<Object[]> getSummaryCountryCountAsRaw();
El resultado es un array de Object
cuyas posiciones respetan el orden de cada elemento declarado en la SELECT
:
Array SELECT
[0] -> c.id
[1] -> c.name
[2] -> COUNT(ct.id)
Esta forma de obtener los resultados se conoce como raw («en crudo» o «en bruto») y, dado que cualquier dato es Object
, nos sirve para cualquier proyección. El problema radica en que trabajar con un array de Object
es un fastidio, así que lo transformaremos en una clase más fácil de manejar:
@Test
void testSummaryCountriesCountAsRaw() {
List<Object[]> confederationsSummary = confederationRepository.getSummaryCountryCountAsRaw();
List<ConfederationSummaryRecord> confSummaryMapped = map(confederationsSummary);
assertThat(confSummaryMapped).extracting(
ConfederationSummaryRecord::id,
ConfederationSummaryRecord::countries)
.containsExactly(
tuple(AFC_ID, 1L),
tuple(CONCACAF_ID, 5L),
tuple(CONMEBOL_ID, 2L),
tuple(UEFA_ID, 3L));
}
private List<ConfederationSummaryRecord> map(List<Object[]> confederationsSummary) {
return confederationsSummary.stream()
.map(object -> new ConfederationSummaryRecord((Long) object[0], (String) object[1], (Long) object[2]))
.toList();
}
Mal asunto. Se precisa un método que convierta los resultados en objetos de tipo ConfederationSummaryRecord
. Un código feo y, debido a los índices del array, inseguro.
La interfaz Tuple de JPA
Mejor la segunda alternativa, consistente en solicitar a Spring Data JPA que recoja cada resultado en un objeto de la interfaz Tuple
.

Indicamos un alias para cada dato devuelto por la SELECT
:
@Query("""
SELECT c.id as id, c.name as name, COUNT(ct.id) as countries FROM Country ct INNER JOIN ct.confederation c
GROUP BY ct.confederation.id, c.name
ORDER BY c.name ASC""")
List<Tuple> getSummaryCountryCountAsTuple();
Recuperamos cada columna según su alias y tipo:
private List<ConfederationSummaryRecord> mapTuples(List<Tuple> confederationsSummary) {
return confederationsSummary.stream()
.map(tuple -> new ConfederationSummaryRecord(
tuple.get("id", Long.class),
tuple.get("name", String.class),
tuple.get("countries", Long.class)))
.toList();
}
Proyección en interfaz
Spring Data provee una alternativa ingeniosa a los DTO o records
consistente en crear una interfaz que contenga los getters correspondientes a los atributos de la clase de tipo entidad deseados. Por ello, esta técnica se denomina proyección en interfaz, aunque en la documentación oficial aparece con el nombre «proyección cerrada» (closed projection).
Esta interfaz recoge los atributos id
y name
de cualquier clase de entidad que cuente con ellos:
public interface IdName {
Long getId();
String getName();
}
El resultado de los getter se envuelve en Optional
cuando pueda ser nulo, si así lo deseamos.
No hay nada más que hacer. Usamos IdName
como resultado de la consulta.
List<IdName> findIdNameAsInterfaceByConfederationId(Long id);
Spring recogerá los resultados en objetos de tipo proxy
para la interfaz, y esta prueba será exitosa:
@Test
void testFindCountryIdNameByConfederationId() {
List<IdName> uefaCountries
= countryRepository.findCountryIdNameByConfederationId(UEFA_ID);
assertThat(uefaCountries)
.extracting(IdName::getId)
.containsExactlyInAnyOrder(SPAIN_ID, NORWAY_ID, NETHERLANDS_ID);
}

La consulta SQL es precisa, pues solo obtiene las columnas id
y name
:
select c1_0.id,c1_0.name
from countries c1_0
where c1_0.confederation_id=?
La proyección en interfaz también funciona con @Query
:
@Query("SELECT c FROM Country c WHERE c.confederation.id=:id")
List<IdName> findIdNameAsInterfaceByConfederation(Long id);
El inconveniente del método anterior es que recupera todos los campos de Country
. En consecuencia, nos tocará pulir la consulta JPQL, pero ojo, que tiene truco:
@Query("SELECT c.id as id, c.name as name FROM Country c WHERE c.confederation.id=:id")
List<IdName> findIdNameAsInterfaceByConfederation(Long id);
Puesto que ahora la SELECT
no retorna una entidad, cada elemento de la proyección debe nombrarse con un alias coincidente con el getter de la interfaz que devuelva ese elemento. Si no hicieras esto, el método devolverá una lista de objetos IdName
con sus campos a nulo.
Fíjate que con la técnica anterior puedes recibir en una interfaz cualquier resultado devuelto por una consulta JPQL; basta con que seas cuidadoso con el nombrado de getters y alias.
Una capacidad curiosa de estas interfaces es la posibilidad de crear métodos que evaluen una expresión en tiempo de ejecución:
public interface StringCode {
@Value("#{target.id + '-' + target.name}")
String getCode();
}
La anotación @Value
contiene una expresión escrita con el lenguaje de Spring llamado SpEL al que ya hemos mencionado un par de veces en el curso. La variable target
representa al resultado que devuelve la consulta y permite navegar por los componentes de la proyección con el operador punto. Así pues, el método getCode
devolverá una cadena con la concatenación de los elementos id
y name
de la proyección. La documentación denomina a estas interfaces «proyecciones abiertas» (open projections).
Aquí se emplea la interfaz StringCode
:
@Query("SELECT c.id as id, c.name as name FROM Country c WHERE c.confederation.id=:id")
List<StringCode> findCountryCodeByConfederationId(Long id);
Esta funcionalidad presenta un problema al usarla con consultas derivadas. Ejecutemos el siguiente método:
List<StringCode> findCountryCodeByConfederationId(Long confederationId);
La bitácora revela que se recupera la entidad al completo:
select c1_0.id,c1_0.capital,c1_0.confederation_id,c1_0.name,c1_0.ocde,c1_0.population,c1_0.united_nations_admission from countries c1_0 where c1_0.confederation_id=?
Sea como fuere, resulta peligroso programar en cadenas de texto, ya que es un código que no se compila. Nuestro caso de estudio es mejor implementarlo con un método default
en la interfaz:
public interface StringCode {
Long getId();
String getName();
default String getCode() {
return getId() + "-" + getName();
}
}
Relaciones
¿Y las relaciones? También es posible recuperarlas con una proyección en interfaz o constructor. Sean perezosas o no, se obtendrán siempre desde la base de datos al realizarse la consulta.
La siguiente interfaz recoje el nombre, el identificador y la entidad de tipo Confederation
de un país:
public interface CountryIdNameConfederationProjection {
Long getId();
String getName();
Confederation getConfederation();
}
@Query("""
SELECT c.id as id, c.name as name, c.confederation as confederation
FROM Country c WHERE c.confederation.id=:id""")
List<CountryIdNameConfederationProjection> findIdNameConfederationByConfederationId(Long id);
¡Ojo avizor! Comprobemos el SQL generado:
select c1_0.id, c1_0.name, c2_0.id,c2_0.name
from countries c1_0 left join confederations c2_0 on c2_0.id=c1_0.confederation_id
where c1_0.confederation_id=?
Todo correcto. La confederación del país se obtuvo de manera óptima con una reunión externa (un país está asociado de forma opcional con una única confederación). En otros tipos de relaciones, como las múltiples, tal vez no sea así y aparezca el infame problema N+1 (se ejecuta una consulta para cada entidad relacionada). Si esto sucediera, lo mejor será escribir una consulta JPQL a medida. De nuevo, te remito a la publicación en la que hablo de Fetch join. Y, de paso, aprovecho para subrayar la importancia de revisar el SQL generado por Hibernate para todas nuestras consultas. Evitaremos algún que otro problema de rendimiento.
Lo que ya no está tan bien es el hecho de que la confederación se recupera como una entidad. Volvamos a crear una proyección en interfaz o DTO para la confederación:
public interface CountryIdNameConfederationProjection {
Long getId();
String getName();
ConfederationDTO getConfederation();
}
Dado que el nombre findIdNameConfederationByConfederationId
sirve de consulta derivada con la misma función de la consulta JPQL, podemos eliminar esta última y todo seguirá funcionando a la perfección:
List<CountryIdNameConfederationProjection> findIdNameConfederationByConfederationId(Long id);
Tipado dinámico
Las consultas que hemos escrito en lo que llevamos de curso tiene su resultado tipado de forma fija. Es lo más común, por no decir lo que siempre haremos. La alternativa es proporcionar el tipo del resultado como un parámetro:
<T> List<T> findDynamicProjectionByConfederationId(Long id, Class<T> type);
La consulta nunca cambiará (recupera los países pertenecientes a una confederación); pero el método posibilita elegir en qué clase queremos recoger la proyección, de acuerdo a lo explicado en el presente capítulo. Entre esas clases, cómo no, se encuentra la entidad del repositorio.
Aquí tienes algunas de las consultas derivadas de este capítulo reescritas como llamadas al método dinámico anterior:
List<IdName> uefaCountries
= countryRepository.findDynamicProjectionByConfederationId(UEFA_ID, IdName.class);
List<IdNameDTO> uefaCountries
= countryRepository.findDynamicProjectionByConfederationId(UEFA_ID, IdNameDTO.class);
List<IdNameRecord> uefaCountries
= countryRepository.findDynamicProjectionByConfederationId(UEFA_ID, IdNameRecord.class);
List<Country> uefaCountries
= countryRepository.findDynamicProjectionByConfederationId(UEFA_ID, Country.class);
En el caso de las consultas JPQL esta flexibilidad no es tan útil. Nos obliga a escribir una sola sentencia JPQL compatible con todos los posibles tipos de respuesta, así que es imposible afinar la consulta para cada tipo. Además, la proyección en constructor se declara en la propia consulta, con lo que esta no es reutilizable.
A la hora de probar las cuatro llamadas anteriores, date cuenta de que debemos ejecutar cuatro veces el mismo test cambiando cada vez el valor de type
en findDynamicProjectionByConfederationId
. No programes este comportamiento a mano; para eso existen los tests parametrizados de Jupiter \ Jupiter 5: ejecutar una prueba varias veces con argumentos distintos en cada ocasión.
@ParameterizedTest
@ValueSource(classes = {IdName.class, IdNameDTO.class, IdNameRecord.class, Country.class})
void testFindDynamicProjectionByConfederationId(Class projectionClass) {
List uefaCountries
= countryRepository.findDynamicProjectionByConfederationId(UEFA_ID, projectionClass);
assertThat(uefaCountries)
.extracting("id", "name")
.containsExactlyInAnyOrder(
tuple(SPAIN_ID, SPAIN_NAME),
tuple(NORWAY_ID, NORWAY_NAME),
tuple(NETHERLANDS_ID, NETHERLANDS_NAME));
}
Cada clase declarada en @ValueSource
provoca una nueva ejecución de testFindDynamicProjectionByConfederationId
que recibirá esa clase como argumento. El resultado final son cuatro tests independientes, algo que nos facilita saber en caso de error la prueba que falla.

Resumen
Resumo en titulares lo que ha dado de sí la séptima entrega del curso:
- Obtén entidades solo cuando resulte imprescindible. Por lo común, cuando tengas que modificarlas.
- La proyección en constructor permite recibir en cualquier clase o
record
una selección de campos de una clase de tipo entidad. En el caso de las consultas JPQL, permite recoger el resultado de cualquierSELECT
. - Como alternativa, la proyección puede recibirse en una interfaz. Spring creará sus objetos como proxy.
- Con
@Query
es posible recoger los resultados en arrays deObject
o deTuple
. No obstante, la proyección en constructor es más práctica. - El tipado del retorno de un método de un repositorio puede ser dinámico. Resulta útil para reutilizar ciertas consultas derivadas, aunque no es una situación que encontraremos a menudo.
- De propina, hemos echado un vistazo rápido a los tests parametrizados de JUnit 5.
Código de ejemplo
El proyecto, explicado en el capítulo dos, se encuentra en GitHub. Para más información sobre cómo utilizar GitHub consulta este artículo.
Otros tutoriales relacionados con Spring
Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA
Spring Boot: Gestión de errores en aplicaciones web y REST
Testing en Spring Boot con JUnit 45. Mockito, MockMvc, REST Assured, bases de datos embebidas