
El problema: queries dinámicas
Un endpoint de búsqueda de empleados acepta varios filtros opcionales: nombre, departamento, salario mínimo, salario máximo, fecha de alta desde y hasta. Cualquiera puede venir o no venir. La tentación es concatenar JPQL como strings:
public List<Empleado> buscar(String nombre, Long departamentoId,
BigDecimal salarioMin, BigDecimal salarioMax) {
var jpql = new StringBuilder("select e from Empleado e where 1=1");
var params = new HashMap<String, Object>();
if (nombre != null) {
jpql.append(" and lower(e.nombre) like :nombre");
params.put("nombre", "%" + nombre.toLowerCase() + "%");
}
if (departamentoId != null) {
jpql.append(" and e.departamento.id = :depId");
params.put("depId", departamentoId);
}
// ... y así para los demás
}
Funciona, pero es frágil. Un typo en el nombre de un campo (e.salatio en lugar de e.salario) se descubre en runtime, normalmente en producción. Refactorizar la entidad rompe queries silenciosamente. Y si los criterios crecen a doce, el método se vuelve ilegible.
La API CriteriaBuilder de Jakarta Persistence resuelve esto: construye la query con objetos Java en lugar de strings, type-safe a tiempo de compilación, y el compilador detecta los errores antes de que lleguen al
git push.
CriteriaBuilder básico: select y where
La estructura de una CriteriaQuery siempre es la misma: el EntityManager da un CriteriaBuilder, que construye una CriteriaQuery<T>, que define un Root<E> (la entidad raíz) y aplica select, where, groupBy y orderBy.
@Service
@RequiredArgsConstructor
public class EmpleadoConsultaService {
private final EntityManager em;
public List<Empleado> buscarPorSalarioMinimo(BigDecimal min) {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(Empleado.class);
var root = query.from(Empleado.class);
query.select(root)
.where(cb.greaterThanOrEqualTo(root.get("salario"), min));
return em.createQuery(query).getResultList();
}
}
Lo equivalente en JPQL sería select e from Empleado e where e.salario >= :min. La construcción por objetos es más verbosa, pero permite que cada parte sea opcional.
Predicate dinámicos: el caso real
El valor real de Criteria aparece cuando los filtros se aplican condicionalmente:
public List<Empleado> buscar(EmpleadoFiltro filtro) {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(Empleado.class);
var root = query.from(Empleado.class);
var predicados = new ArrayList<Predicate>();
if (filtro.nombre() != null && !filtro.nombre().isBlank()) {
predicados.add(cb.like(
cb.lower(root.get("nombre")),
"%" + filtro.nombre().toLowerCase() + "%"));
}
if (filtro.departamentoId() != null) {
predicados.add(cb.equal(
root.get("departamento").get("id"),
filtro.departamentoId()));
}
if (filtro.salarioMin() != null) {
predicados.add(cb.greaterThanOrEqualTo(
root.get("salario"), filtro.salarioMin()));
}
if (filtro.salarioMax() != null) {
predicados.add(cb.lessThanOrEqualTo(
root.get("salario"), filtro.salarioMax()));
}
query.select(root)
.where(cb.and(predicados.toArray(Predicate[]::new)))
.orderBy(cb.asc(root.get("nombre")));
return em.createQuery(query)
.setMaxResults(filtro.limite())
.getResultList();
}
public record EmpleadoFiltro(
String nombre,
Long departamentoId,
BigDecimal salarioMin,
BigDecimal salarioMax,
int limite
) {}
Cada bloque if añade un predicado a la lista. cb.and(...) los combina con AND. Si la lista está vacía, no hay where y se devuelven todos los empleados.
Joins y proyecciones
Los joins se construyen con root.join(...). El segundo argumento controla el tipo de join:
public List<Empleado> buscarConDepartamentoActivo() {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(Empleado.class);
var root = query.from(Empleado.class);
Join<Empleado, Departamento> dep = root.join("departamento", JoinType.INNER);
query.select(root)
.where(cb.equal(dep.get("activo"), true));
return em.createQuery(query).getResultList();
}
Para devolver una proyección DTO en lugar de la entidad completa:
public List<EmpleadoResumenDto> buscarResumenes() {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(EmpleadoResumenDto.class);
var root = query.from(Empleado.class);
var dep = root.join("departamento");
query.select(cb.construct(
EmpleadoResumenDto.class,
root.get("id"),
root.get("nombre"),
dep.get("nombre")
));
return em.createQuery(query).getResultList();
}
public record EmpleadoResumenDto(Long id, String nombre, String departamento) {}
cb.construct instancia el record con los valores seleccionados. Es el equivalente a select new com.empresa.EmpleadoResumenDto(...) en JPQL.
Subqueries y agregaciones
Una subquery encuentra empleados con salario superior al promedio de su departamento:
public List<Empleado> buscarSobreElPromedioDelDepartamento() {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(Empleado.class);
var root = query.from(Empleado.class);
Subquery<BigDecimal> subquery = query.subquery(BigDecimal.class);
var subRoot = subquery.from(Empleado.class);
subquery.select(cb.avg(subRoot.get("salario")).as(BigDecimal.class))
.where(cb.equal(
subRoot.get("departamento"),
root.get("departamento")));
query.select(root)
.where(cb.greaterThan(root.get("salario"), subquery));
return em.createQuery(query).getResultList();
}
Una agregación con groupBy calcula el salario medio por departamento:
public List<DepartamentoSalarioDto> calcularSalarioMedioPorDepartamento() {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(DepartamentoSalarioDto.class);
var root = query.from(Empleado.class);
var dep = root.join("departamento");
query.select(cb.construct(
DepartamentoSalarioDto.class,
dep.get("id"),
dep.get("nombre"),
cb.avg(root.get("salario"))))
.groupBy(dep.get("id"), dep.get("nombre"))
.having(cb.greaterThan(cb.avg(root.get("salario")), BigDecimal.valueOf(30000)))
.orderBy(cb.desc(cb.avg(root.get("salario"))));
return em.createQuery(query).getResultList();
}
public record DepartamentoSalarioDto(Long id, String nombre, Double salarioMedio) {}
Integración con Spring Data: interface custom
En proyectos Spring Boot, no se inyecta EntityManager en el servicio. Se sigue el patrón interface custom + impl que Spring Data soporta de forma nativa:
// 1. Interface custom con los métodos no derivables
public interface EmpleadoRepositoryCustom {
List<Empleado> buscar(EmpleadoFiltro filtro);
Page<Empleado> buscarPaginado(EmpleadoFiltro filtro, Pageable pageable);
}
// 2. Implementación, accediendo al EntityManager
@RequiredArgsConstructor
public class EmpleadoRepositoryImpl implements EmpleadoRepositoryCustom {
private final EntityManager em;
@Override
public List<Empleado> buscar(EmpleadoFiltro filtro) {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(Empleado.class);
var root = query.from(Empleado.class);
query.select(root)
.where(construirPredicados(cb, root, filtro));
return em.createQuery(query).getResultList();
}
@Override
public Page<Empleado> buscarPaginado(EmpleadoFiltro filtro, Pageable pageable) {
var cb = em.getCriteriaBuilder();
// Query de datos
var query = cb.createQuery(Empleado.class);
var root = query.from(Empleado.class);
query.select(root)
.where(construirPredicados(cb, root, filtro));
var resultados = em.createQuery(query)
.setFirstResult((int) pageable.getOffset())
.setMaxResults(pageable.getPageSize())
.getResultList();
// Query de count (obligatoria para Page)
var countQuery = cb.createQuery(Long.class);
var countRoot = countQuery.from(Empleado.class);
countQuery.select(cb.count(countRoot))
.where(construirPredicados(cb, countRoot, filtro));
var total = em.createQuery(countQuery).getSingleResult();
return new PageImpl<>(resultados, pageable, total);
}
private Predicate construirPredicados(CriteriaBuilder cb,
Root<Empleado> root,
EmpleadoFiltro filtro) {
var preds = new ArrayList<Predicate>();
if (filtro.nombre() != null) {
preds.add(cb.like(cb.lower(root.get("nombre")),
"%" + filtro.nombre().toLowerCase() + "%"));
}
if (filtro.salarioMin() != null) {
preds.add(cb.greaterThanOrEqualTo(root.get("salario"), filtro.salarioMin()));
}
// ... resto
return cb.and(preds.toArray(Predicate[]::new));
}
}
// 3. Repositorio que extiende ambas
public interface EmpleadoRepository
extends JpaRepository<Empleado, Long>, EmpleadoRepositoryCustom {
}
El nombre
EmpleadoRepositoryImplno es casual: Spring Data busca una clase con el nombre<Repositorio>Implpara encontrar la implementación de la interface custom. Cambiarlo aEmpleadoRepositoryCustomImplrequiere configurarrepositoryImplementationPostfixen@EnableJpaRepositories.
Metamodel: adiós a los strings literales
Los root.get("nombre") siguen siendo strings literales: un typo o un rename del campo no salta hasta runtime. Jakarta Persistence define un Metamodel generado a partir de las entidades: por cada @Entity Empleado se genera una clase Empleado_ con atributos estáticos.
Activación en pom.xml:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<scope>provided</scope>
</dependency>
Tras compilar, aparece target/generated-sources/annotations/com/empresa/Empleado_.java:
@StaticMetamodel(Empleado.class)
public abstract class Empleado_ {
public static volatile SingularAttribute<Empleado, Long> id;
public static volatile SingularAttribute<Empleado, String> nombre;
public static volatile SingularAttribute<Empleado, BigDecimal> salario;
public static volatile SingularAttribute<Empleado, Departamento> departamento;
public static volatile ListAttribute<Empleado, Proyecto> proyectos;
}
Y el código pasa de strings a referencias type-safe:
// Antes (frágil)
predicados.add(cb.equal(root.get("salario"), min));
// Después (type-safe)
predicados.add(cb.equal(root.get(Empleado_.salario), min));
graph LR
A[Empleado.java<br/>@Entity] --> B[hibernate-jpamodelgen]
B --> C[Empleado_.java<br/>generado]
C --> D[CriteriaBuilder usa<br/>Empleado_.salario]
D --> E[Compilador detecta<br/>renames y typos]
Specification de Spring Data: la alternativa concisa
Spring Data envuelve CriteriaBuilder en Specification<T>, una API funcional para predicados reutilizables. El repositorio extiende JpaSpecificationExecutor:
public interface EmpleadoRepository
extends JpaRepository<Empleado, Long>, JpaSpecificationExecutor<Empleado> {
}
Specifications se componen con and, or, not:
public class EmpleadoSpecs {
public static Specification<Empleado> nombreContiene(String nombre) {
return (root, query, cb) -> nombre == null
? cb.conjunction()
: cb.like(cb.lower(root.get(Empleado_.nombre)),
"%" + nombre.toLowerCase() + "%");
}
public static Specification<Empleado> salarioMinimo(BigDecimal min) {
return (root, query, cb) -> min == null
? cb.conjunction()
: cb.greaterThanOrEqualTo(root.get(Empleado_.salario), min);
}
public static Specification<Empleado> deDepartamento(Long depId) {
return (root, query, cb) -> depId == null
? cb.conjunction()
: cb.equal(root.get(Empleado_.departamento).get(Departamento_.id), depId);
}
}
Y el servicio queda mucho más conciso:
public Page<Empleado> buscar(EmpleadoFiltro filtro, Pageable pageable) {
var spec = Specification.where(EmpleadoSpecs.nombreContiene(filtro.nombre()))
.and(EmpleadoSpecs.salarioMinimo(filtro.salarioMin()))
.and(EmpleadoSpecs.deDepartamento(filtro.departamentoId()));
return repository.findAll(spec, pageable);
}
cb.conjunction() es el predicado neutro (siempre true) que devuelve cuando el filtro es null, eliminando el if boilerplate.
Cuándo usar cada herramienta
| Caso | Herramienta recomendada |
|------|-------------------------|
| Query simple, fija, sin condiciones opcionales | @Query JPQL o método derivado |
| Filtro con 3-10 criterios opcionales | Specification (concisa, reutilizable) |
| Query muy compleja con subqueries, agregaciones, varios joins | CriteriaBuilder directo (más control) |
| Equipo con preferencia por DSL fluido y type-safe estricto | QueryDSL (librería externa) |
| Reportes ad hoc, queries que cambian sin recompilar | JPQL en string (acepta el coste) |
Specification es el sweet spot para el 80% de los filtrados dinámicos en Spring Data. CriteriaBuilder directo se justifica cuando hay subqueries complejas o el control fino sobre la query es crítico. QueryDSL es la opción si el equipo ya lo conoce y se quiere evitar el ruido del Metamodel.
Anti-patrones
CriteriaBuilder para queries fijas
Si la query no tiene condiciones opcionales, @Query("select e from Empleado e where e.activo = true") es más legible que veinte líneas de Criteria. No uses CriteriaBuilder por moda.
Strings literales con Metamodel disponible
Una vez añadida la dependencia hibernate-jpamodelgen, no quedan excusas para root.get("nombre"). Usa Empleado_.nombre en todo el código nuevo.
Specification sin conjunction()
public static Specification<Empleado> nombreContiene(String nombre) {
return (root, query, cb) -> cb.like(root.get(Empleado_.nombre), "%" + nombre + "%");
}
Si nombre es null, esto lanza NPE en Hibernate. Devuelve siempre cb.conjunction() cuando el parámetro es null para que la spec sea componible sin checks externos.
Mezclar concerns en el repositorio custom
EmpleadoRepositoryImpl no debe saber de DTOs de respuesta ni de paginación de capas superiores. Devuelve List<Empleado> o Page<Empleado> y deja la transformación en el servicio.
Buenas prácticas
- Metamodel siempre activado en proyectos nuevos. Una dependencia, beneficios masivos.
- Specifications para filtrado dinámico, CriteriaBuilder directo solo cuando Specification se queda corto.
- Records para los filtros (
EmpleadoFiltro): inmutables, expresivos, connullcomo "sin filtro". - Paginación con count separado: cuando construyes un
Pagemanualmente, no olvides la query de count oPage.getTotalElements()mentirá. - Tests con
@DataJpaTestpara verificar que los Specifications generan el SQL esperado. Revisa conspring.jpa.show-sql=trueque no aparecen joins inesperados. - No mezcles fetch joins en queries Criteria si vas a paginar: aplica
@EntityGraphen el método del repositorio o usa Specification con unquery.distinct(true)controlado.
CriteriaBuilder es la API estándar de Jakarta Persistence para queries dinámicas. Specification de Spring Data lo hace operativo en el día a día. El Metamodel cierra el círculo eliminando los strings literales. En un proyecto Spring Boot 4 maduro, los tres elementos juntos producen filtros dinámicos type-safe, refactorizables y testables, sin sacrificar la flexibilidad que pide el frontend.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Construir CriteriaQuery con select, where, joins y order by sin strings literales. Definir Predicate dinámicos según parámetros opcionales del filtrado. Integrar la API con Spring Data mediante una interface custom (EmpleadoRepositoryCustom). Generar y usar el Metamodel JPA para queries type-safe. Decidir entre CriteriaBuilder puro, Specification y QueryDSL según el caso.