Spring Data JPA: Repository, Query Method e @Query

1. Visão Geral

Spring Data JPA ajuda você a escrever a camada de acesso aos dados com quase nenhuma implementação de código. Você só precisa declarar uma interface e o Spring gerará automaticamente comandos SQL baseados no nome do método ou na anotação @Query.

Este artigo passa por três partes principais: como criar um repositório básico, as convenções de nomenclatura de query methods para que o Spring deduza automaticamente as condições WHERE, e como usar @Query quando o nome do método não é suficientemente expressivo ou quando você precisa de JPQL/SQL nativo complexo. Depois de ler, você saberá escolher a abordagem certa para cada situação prática.

2. Configuração do projeto

Adicione a dependência do Spring Data JPA e o driver do banco de dados (por exemplo, H2 para demonstração):

<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>

Entidade de exemplo usada ao longo deste artigo:

@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 possui campos simples: email do cliente, valor total, status e momento da criação. Os exemplos de query abaixo giram todos em torno dessa entidade.

3. Repositório básico

Basta estender JpaRepository, e o Spring Boot cria automaticamente a implementação do bean durante a inicialização:

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

JpaRepository<Order, Long> indica que a entidade gerenciada é Order e o tipo de chave primária é Long. Esta interface já vem com métodos prontos como save(), findById(), findAll(), deleteById() — nenhuma linha de código adicional necessária.

@Service
public class OrderService {

    private final OrderRepository orderRepository;

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

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

Chamar save() executará INSERT se id for nulo, UPDATE se id já existe. Este é o comportamento padrão de SimpleJpaRepository — a implementação que o Spring Data gera automaticamente.

Importante: save() não diferencia “criar novo” ou “atualizar” baseado na lógica de negócio, mas sim se id é nulo ou não. Se você definir id manualmente antes de chamar save(), o Spring considerará como update e executará findById() + merge, causando uma query SELECT desnecessária.

4. Query method: nomeando para gerar query

4.1. Regras básicas

Spring Data JPA interpreta o nome do método seguindo o padrão findBy, existsBy, countBy, deleteBy combinado com nomes de campo e palavras-chave de condição:

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 gera WHERE status = ?.
  • findByCustomerEmailAndStatus combina duas condições com AND.
  • findFirstByCustomerEmailOrderByCreatedAtDesc retorna 1 registro, ordenado de forma decrescente por createdAt — útil quando você quer encontrar o pedido mais recente de um cliente.
  • countByStatus retorna a quantidade, não a lista.

4.2. Palavras-chave de comparação e intervalo

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 são palavras-chave que o Spring Data JPA oferece nativamente, mapeadas diretamente para as cláusulas SQL correspondentes. ContainingIgnoreCase gera LOWER(customer_email) LIKE LOWER('%keyword%') — prático para buscas insensíveis a maiúsculas/minúsculas.

Teste verificando o comportamento:

@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 carrega apenas o contexto relacionado a JPA (repositórios, entity manager), executando mais rápido que @SpringBootTest. Por padrão, usa um banco de dados em memória e faz rollback após cada teste.

4.3. Paginação e ordenação

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 é um parâmetro especial — o Spring Data o identifica automaticamente e não o trata como uma condição WHERE. Page<Order> retorna juntamente com metadados: número total de elementos, número total de páginas e conteúdo da página atual.

4.4. Quando o nome do método se torna confuso

// Difícil de ler, fácil de digitar errado
List<Order> findByStatusAndCustomerEmailContainingIgnoreCaseAndCreatedAtAfterOrderByTotalAmountDesc(
    OrderStatus status, String keyword, LocalDateTime after);

Um nome de método tão longo quanto este ainda funciona, mas é difícil de ler, difícil de manter, e o compilador não ajuda você a verificar erros de sintaxe — o Spring Data só detecta erros quando o contexto inicia (fail-fast mas ainda em runtime). Este é o momento de passar para @Query.

5. @Query: controlando o comando diretamente

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 escreve JPQL — uma linguagem de query orientada a entidades, não SQL puro (usa Order o, o.status em vez de nomes de tabelas/colunas). @Param mapeia o nome do parâmetro na query com o parâmetro do método, permitindo nomes de variáveis flexíveis e não dependentes da ordem.

5.2. SQL nativo quando necessário

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 escrever SQL real, usando sintaxe específica do banco de dados como LIMIT. Em troca, você perde portabilidade entre diferentes bancos de dados (por exemplo, Oracle não suporta LIMIT).

Importante: Uma query nativa retornando entidade ainda funciona se o nome da coluna corresponder ao mapeamento, mas se você SELECT em um DTO customizado, você precisa adicionar @SqlResultSetMapping ou usar uma interface de projection — mais complexo que JPQL.

5.3. Update e Delete com @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 informa ao Spring Data que este é um comando de modificação de dados (UPDATE/DELETE), não um SELECT. O método retorna int, que é o número de linhas afetadas. Sem @Modifying você receberá InvalidDataAccessApiUsageException porque o JPA espera um ResultSet para mapear.

Importante: Um bulk update com @Modifying vai direto ao banco de dados, não passa pelo lifecycle da entidade (não dispara @PreUpdate, não atualiza o cache da entidade gerenciada no persistence context). Se o serviço está mantendo uma instância Order antiga em mãos, os dados em memória ficarão fora de sincronização — você precisa de clearAutomatically = true em @Modifying ou chamar entityManager.clear() manualmente.

5.4. Projection: recuperando apenas os campos necessários

Quando você não precisa da entidade completa, use uma interface de projection para reduzir os dados retornados:

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);
}

O Spring Data cria um proxy implementando OrderSummary em tempo de execução, mapeando apenas os 3 campos do SELECT. Esta abordagem evita carregar a entidade completa quando você só precisa de um resumo — reduz a carga tanto da query quanto da memória.

Teste 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;
    }
}

Este teste confirma que a condição totalAmount > minAmount funciona corretamente — apenas o pedido de 500 é retornado, enquanto o pedido de 50 é filtrado.

6. Comparação entre query method e @Query

Critério Query method @Query
Velocidade de escrita Rápido para condições simples Requer escrever JPQL/SQL manualmente
Complexidade suportada Limitada com JOIN, subquery Flexível, suporta qualquer JPQL/SQL
Legibilidade Nome longo com muitas condições Claro, lógica da query separada
Verificação de erros Erro ao iniciar contexto Erro ao iniciar contexto (JPQL), erro em tempo de execução (SQL nativo)
Mais adequado para CRUD básico, 1-3 condições Query complexa, JOIN, aggregate, projection

Princípio de escolha: comece com query methods para casos simples. Quando o nome do método ultrapassar 3-4 condições ou precisar de JOIN/subquery, mude para @Query para melhor legibilidade e controle.

7. Erros comuns

Esquecendo @Param ao usar nomes de parâmetros com named parameters:

// Erro: falta @Param, Spring não sabe mapear :status com qual parâmetro
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusRaw(OrderStatus status);

Solução: adicione @Param("status") ao parâmetro, ou compile com a flag -parameters para que o Spring extraia automaticamente nomes de parâmetros do bytecode (Spring Boot já habilita essa flag por padrão no plugin Maven/Gradle, mas ainda assim é bom usar @Param explicitamente para evitar dependência de configuração de build).

Usar findAll() e depois filtrar com Java stream em vez de escrever uma query:

// Não faça assim: carrega toda a tabela em memória e depois filtra
List<Order> paidOrders = orderRepository.findAll().stream()
    .filter(o -> o.getStatus() == OrderStatus.PAID)
    .toList();

Desta forma você carrega todos os dados do banco de dados para a aplicação, desperdiçando banda e memória. Sempre coloque as condições de filtro no banco de dados através de query methods ou @Query.

Esquecendo @Transactional ao chamar @Modifying fora do escopo de uma transação:

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

Chamar este método diretamente sem uma transação envolvendo-o lançará TransactionRequiredException. Sempre envolva em um método @Transactional na camada de serviço, não chame diretamente do controller.

8. Conclusão

Query methods são apropriados para consultas simples, mantendo código conciso e legível quando as condições não excedem 2-3 campos. Quando a lógica fica mais complexa — precisando de JOIN, subquery, bulk update ou projection customizada — @Query é a escolha óbvia e mais fácil de manter.

Princípio geral: sempre coloque a lógica de filtro de dados no banco de dados em vez de processar em Java, sempre escreva testes para cada query importante, e considere usar SQL nativo apenas quando JPQL não conseguir fazer o trabalho (por exemplo, precisando de funções específicas do banco de dados). Dominando estas três técnicas, você tem ferramentas suficientes para lidar com a maioria dos problemas de acesso aos dados em aplicações Spring Boot do mundo real.

spring-bootspring-data-jpajavadatabaseorm