Spring Data JPA: Repository, Query Method y @Query

1. Resumen general

Spring Data JPA te ayuda a escribir la capa de acceso a datos con casi ningún código de implementación. Solo necesitas declarar una interfaz, y Spring genera automáticamente las sentencias SQL basándose en el nombre del método o la anotación @Query.

Este artículo cubre tres partes principales: cómo crear un repositorio básico, las reglas de nomenclatura para query methods a fin de que Spring deduzca automáticamente las condiciones WHERE, y cómo usar @Query cuando el nombre del método no es suficientemente expresivo o cuando necesitas JPQL/SQL nativo más complejo. Al terminar, sabrás elegir el enfoque correcto para cada situación real.

2. Configuración del proyecto

Añade la dependencia de Spring Data JPA y el driver de base de datos (por ejemplo H2 para demostración):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Entidad de ejemplo utilizada en todo el artículo:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerEmail;
    private BigDecimal totalAmount;
    private OrderStatus status;
    private LocalDateTime createdAt;

    // getters, setters
}

public enum OrderStatus {
    PENDING, PAID, SHIPPED, CANCELLED
}

Order tiene campos simples: correo del cliente, monto total, estado, momento de creación. Los ejemplos de consultas a continuación giran en torno a esta entidad.

3. Repositorio básico

Solo necesitas extender JpaRepository, y Spring Boot crea automáticamente la implementación del bean al inicio:

public interface OrderRepository extends JpaRepository<Order, Long> {
}

JpaRepository<Order, Long> especifica que la entidad gestionada es Order y el tipo de clave primaria es Long. Esta interfaz ya tiene métodos listos como save(), findById(), findAll(), deleteById() — sin necesidad de escribir una sola línea de código más.

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order createOrder(Order order) {
        return orderRepository.save(order);
    }
}

Llamar save() hará INSERT si id es null, UPDATE si id ya existe. Este es el comportamiento predeterminado de SimpleJpaRepository — la implementación que Spring Data genera automáticamente.

Nota: save() no distingue entre “crear” y “actualizar” basándose en lógica de negocio, sino en si id es null o no. Si estableces manualmente id antes de llamar save(), Spring lo considerará una actualización y ejecutará findById() + merge, causando una consulta SELECT innecesaria.

4. Query method: nomenclatura para generar consultas

4.1. Reglas básicas

Spring Data JPA analiza el nombre del método según patrones como findBy, existsBy, countBy, deleteBy combinando el nombre del campo y palabras clave de condición:

public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findByStatus(OrderStatus status);

    List<Order> findByCustomerEmailAndStatus(String email, OrderStatus status);

    Optional<Order> findFirstByCustomerEmailOrderByCreatedAtDesc(String email);

    long countByStatus(OrderStatus status);
}
  • findByStatus genera WHERE status = ?.
  • findByCustomerEmailAndStatus combina dos condiciones con AND.
  • findFirstByCustomerEmailOrderByCreatedAtDesc obtiene 1 registro, ordenado de forma descendente por createdAt — útil cuando deseas encontrar el pedido más reciente del cliente.
  • countByStatus devuelve la cantidad, no una lista.

4.2. Palabras clave de comparación y rango

public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findByTotalAmountGreaterThan(BigDecimal amount);

    List<Order> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);

    List<Order> findByStatusIn(List<OrderStatus> statuses);

    List<Order> findByCustomerEmailContainingIgnoreCase(String keyword);
}

GreaterThan, Between, In, ContainingIgnoreCase son palabras clave que Spring Data JPA soporta de forma nativa, se asignan directamente a las cláusulas SQL correspondientes. ContainingIgnoreCase genera LOWER(customer_email) LIKE LOWER('%keyword%') — conveniente para búsquedas sin distinción de mayúsculas.

Prueba para verificar el comportamiento:

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldFindOrdersByStatus() {
        Order order = new Order();
        order.setStatus(OrderStatus.PAID);
        order.setCustomerEmail("a@test.com");
        order.setTotalAmount(BigDecimal.valueOf(100));
        order.setCreatedAt(LocalDateTime.now());
        orderRepository.save(order);

        List<Order> result = orderRepository.findByStatus(OrderStatus.PAID);

        assertThat(result)
            .hasSize(1)
            .first()
            .extracting(Order::getCustomerEmail)
            .isEqualTo("a@test.com");
    }
}

@DataJpaTest carga solo el contexto relacionado con JPA (repositorio, entity manager), se ejecuta más rápido que @SpringBootTest. Por defecto usa una base de datos en memoria y revierte después de cada prueba.

4.3. Paginación y ordenamiento

public interface OrderRepository extends JpaRepository<Order, Long> {

    Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);

System.out.println(page.getTotalElements());
System.out.println(page.getContent());

Pageable es un parámetro especial — Spring Data lo reconoce automáticamente y no lo considera como una condición WHERE. Page<Order> devuelve con metadatos: número total de elementos, número total de páginas, contenido de la página actual.

4.4. Cuándo el nombre del método se vuelve confuso

// Difícil de leer, fácil escribir mal las palabras clave
List<Order> findByStatusAndCustomerEmailContainingIgnoreCaseAndCreatedAtAfterOrderByTotalAmountDesc(
    OrderStatus status, String keyword, LocalDateTime after);

El nombre del método tan largo sigue funcionando, pero es difícil de leer, difícil de mantener, y el compilador no te ayuda a verificar errores de sintaxis — Spring Data solo detecta errores al iniciar el contexto (fail-fast pero aún en tiempo de ejecución). Este es el momento para cambiar a @Query.

5. @Query: control directo de la sentencia

5.1. JPQL básico

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.status = :status AND o.totalAmount > :minAmount")
    List<Order> findHighValueOrders(@Param("status") OrderStatus status,
                                     @Param("minAmount") BigDecimal minAmount);
}

@Query escribe JPQL — lenguaje de consulta orientado a entidades, no SQL puro (usa Order o, o.status en lugar de nombres de tabla/columna). @Param mapea el nombre del parámetro en la consulta con el parámetro del método, permitiendo nombres de variables flexibles, independientes del orden.

5.2. SQL nativo cuando sea necesario

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "SELECT * FROM orders WHERE total_amount > :minAmount LIMIT :limit",
           nativeQuery = true)
    List<Order> findTopOrders(@Param("minAmount") BigDecimal minAmount,
                               @Param("limit") int limit);
}

nativeQuery = true permite escribir SQL real, puedes usar sintaxis específica de la base de datos como LIMIT. A cambio, pierdes portabilidad entre diferentes bases de datos (por ejemplo, Oracle no soporta LIMIT).

Nota: Las consultas nativas que devuelven entidades aún funcionan si los nombres de columna coinciden con el mapeo, pero si realizas SELECT en un DTO arbitrario, necesitas añadir @SqlResultSetMapping o usar una interfaz de proyección — más complejo que JPQL.

5.3. Actualizar y eliminar con @Modifying

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Modifying
    @Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus")
    int updateStatusBulk(@Param("oldStatus") OrderStatus oldStatus,
                          @Param("newStatus") OrderStatus newStatus);
}

@Modifying le indica a Spring Data que esta es una sentencia que modifica datos (UPDATE/DELETE), no SELECT. El método devuelve int, que es el número de filas afectadas. La falta de @Modifying causa una excepción InvalidDataAccessApiUsageException porque JPA espera un ResultSet para mapear.

Nota: Una actualización masiva con @Modifying va directamente a la base de datos, no se ejecuta a través del ciclo de vida de la entidad (no dispara @PreUpdate, no actualiza la caché de la entidad administrada en el contexto de persistencia). Si el servicio mantiene una instancia de Order antigua en la mano, los datos en memoria estarán desincronizados — necesitas clearAutomatically = true en @Modifying o llamar manualmente entityManager.clear().

5.4. Proyección: obtener solo los campos necesarios

Cuando no necesitas la entidad completa, usa una proyección de interfaz para reducir los datos devueltos:

public interface OrderSummary {
    Long getId();
    String getCustomerEmail();
    BigDecimal getTotalAmount();
}
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o.id AS id, o.customerEmail AS customerEmail, o.totalAmount AS totalAmount " +
           "FROM Order o WHERE o.status = :status")
    List<OrderSummary> findSummariesByStatus(@Param("status") OrderStatus status);
}

Spring Data crea un proxy que implementa OrderSummary en tiempo de ejecución, solo mapea los 3 campos que fueron seleccionados. Este enfoque evita cargar la entidad completa cuando solo necesitas mostrar una lista de resumen — reduce la carga tanto de la consulta como de la memoria.

Prueba para @Query:

@DataJpaTest
class OrderRepositoryQueryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldFindHighValueOrders() {
        Order cheap = buildOrder(OrderStatus.PAID, BigDecimal.valueOf(50));
        Order expensive = buildOrder(OrderStatus.PAID, BigDecimal.valueOf(500));
        orderRepository.saveAll(List.of(cheap, expensive));

        List<Order> result = orderRepository.findHighValueOrders(
            OrderStatus.PAID, BigDecimal.valueOf(100));

        assertThat(result)
            .hasSize(1)
            .first()
            .extracting(Order::getTotalAmount)
            .isEqualTo(BigDecimal.valueOf(500));
    }

    private Order buildOrder(OrderStatus status, BigDecimal amount) {
        Order order = new Order();
        order.setStatus(status);
        order.setTotalAmount(amount);
        order.setCustomerEmail("test@test.com");
        order.setCreatedAt(LocalDateTime.now());
        return order;
    }
}

Esta prueba verifica que la condición totalAmount > minAmount funciona correctamente — solo el pedido de 500 es devuelto, el de 50 es descartado.

6. Comparación entre query method y @Query

Criterio Query method @Query
Velocidad de escritura Rápido para condiciones simples Requiere escribir JPQL/SQL manualmente
Complejidad soportada Limitado con JOIN, subquery Flexible, soporta cualquier JPQL/SQL
Legibilidad Nombres largos con múltiples condiciones Claro, lógica de consulta separada
Verificación de errores Error al iniciar el contexto Error al iniciar el contexto (JPQL), error en tiempo de ejecución (SQL nativo)
Recomendado para CRUD básico, 1-3 condiciones Consultas complejas, JOIN, agregación, proyección

Principio de selección: comienza con query method para casos simples. Cuando el nombre del método excede 3-4 condiciones o necesita JOIN/subquery, cambia a @Query para mejor legibilidad y control.

7. Errores comunes

Olvidar @Param al usar nombres de parámetro con nombre:

// Error: falta @Param, Spring no sabe mapear :status con qué parámetro
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusRaw(OrderStatus status);

Cómo arreglarlo: añade @Param("status") al parámetro, o compila con la bandera -parameters para que Spring obtenga automáticamente el nombre del parámetro del bytecode (Spring Boot ha habilitado esta bandera por defecto desde el plugin Maven/Gradle, pero aún debes escribir @Param explícitamente para evitar depender de la configuración de compilación).

Usar findAll() y luego filtrar con Java stream en lugar de escribir una consulta:

// No recomendado: cargar toda la tabla en memoria y luego filtrar
List<Order> paidOrders = orderRepository.findAll().stream()
    .filter(o -> o.getStatus() == OrderStatus.PAID)
    .toList();

Este enfoque carga todos los datos de la base de datos en la aplicación, desperdicia ancho de banda y memoria. Siempre envía las condiciones de filtrado a la base de datos a través de query method o @Query.

Olvidar @Transactional al llamar @Modifying fuera del alcance de un servicio con transacción:

@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status")
void deleteByStatusBulk(@Param("status") OrderStatus status);

Llamar este método directamente sin una transacción envolvente lanzará TransactionRequiredException. Siempre envuelve en un método @Transactional en la capa de servicio, no llames directamente desde el controlador.

8. Conclusión

Query method es adecuado para consultas simples, ayuda a que el código sea conciso y legible cuando las condiciones no exceden 2-3 campos. Cuando la lógica es más compleja — necesitas JOIN, subquery, actualización masiva, o proyección personalizada — @Query es la opción más clara y fácil de mantener.

Principio general: prioriza enviar la lógica de filtrado de datos a la base de datos en lugar de procesarla en Java, siempre escribe pruebas para cada consulta importante, y considera usar SQL nativo solo cuando JPQL no puede satisfacer tus necesidades (por ejemplo, cuando necesitas funciones específicas de la base de datos). Dominando estas tres técnicas, tendrás suficientes herramientas para resolver la mayoría de problemas de acceso a datos en aplicaciones Spring Boot reales.

spring-bootspring-data-jpajavadatabaseorm