Criteria Builder para queries dinámicas

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-jpa-criteria-builder-dinamico

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 EmpleadoRepositoryImpl no es casual: Spring Data busca una clase con el nombre <Repositorio>Impl para encontrar la implementación de la interface custom. Cambiarlo a EmpleadoRepositoryCustomImpl requiere configurar repositoryImplementationPostfix en @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, con null como "sin filtro".
  • Paginación con count separado: cuando construyes un Page manualmente, no olvides la query de count o Page.getTotalElements() mentirá.
  • Tests con @DataJpaTest para verificar que los Specifications generan el SQL esperado. Revisa con spring.jpa.show-sql=true que no aparecen joins inesperados.
  • No mezcles fetch joins en queries Criteria si vas a paginar: aplica @EntityGraph en el método del repositorio o usa Specification con un query.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 - Autor del tutorial

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.