Spring Data JPA: Repository, Query Method và @Query
1. Tổng quan
Spring Data JPA giúp bạn viết tầng truy xuất dữ liệu mà gần như không cần code implementation. Bạn chỉ cần khai báo interface, Spring tự sinh câu lệnh SQL dựa trên tên method hoặc annotation @Query.
Bài này đi qua ba phần chính: cách tạo repository cơ bản, quy tắc đặt tên query method để Spring tự suy luận điều kiện WHERE, và cách dùng @Query khi tên method không đủ biểu đạt hoặc khi cần JPQL/native SQL phức tạp. Đọc xong, bạn sẽ biết chọn đúng cách tiếp cận cho từng tình huống thực tế.
2. Thiết lập dự án
Thêm dependency Spring Data JPA và driver database (ví dụ H2 cho demo):
<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>
Entity mẫu dùng xuyên suốt bài:
@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 có các trường đơn giản: email khách, tổng tiền, trạng thái, thời điểm tạo. Các ví dụ query phía dưới đều xoay quanh entity này.
3. Repository cơ bản
Chỉ cần extend JpaRepository, Spring Boot tự tạo bean implementation lúc khởi động:
public interface OrderRepository extends JpaRepository<Order, Long> {
}
JpaRepository<Order, Long> cho biết entity quản lý là Order, kiểu khóa chính là Long. Interface này đã có sẵn các method như save(), findById(), findAll(), deleteById() — không cần viết thêm dòng code nào.
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Order createOrder(Order order) {
return orderRepository.save(order);
}
}
Gọi save() sẽ INSERT nếu id null, UPDATE nếu id đã tồn tại. Đây là hành vi mặc định của SimpleJpaRepository — implementation mà Spring Data tự sinh.
Lưu ý:
save()không phân biệt “tạo mới” hay “cập nhật” dựa trên logic nghiệp vụ, mà dựa vàoidnull hay không. Nếu bạn tự setidtrước khi gọisave(), Spring sẽ coi đó là update và thực hiệnfindById()+ merge, gây thêm một query SELECT không cần thiết.
4. Query method: đặt tên để sinh query
4.1. Quy tắc cơ bản
Spring Data JPA parse tên method theo pattern findBy, existsBy, countBy, deleteBy kết hợp tên field và từ khóa điều kiệ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);
}
findByStatussinh raWHERE status = ?.findByCustomerEmailAndStatusghép hai điều kiện bằngAND.findFirstByCustomerEmailOrderByCreatedAtDesclấy 1 bản ghi, sắp xếp giảm dần theocreatedAt— hữu ích khi muốn tìm đơn hàng mới nhất của khách.countByStatustrả về số lượng, không phải danh sách.
4.2. Từ khóa so sánh và phạm vi
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 là các từ khóa Spring Data JPA hỗ trợ sẵn, map trực tiếp sang mệnh đề SQL tương ứng. ContainingIgnoreCase sinh ra LOWER(customer_email) LIKE LOWER('%keyword%') — tiện cho tìm kiếm không phân biệt hoa thường.
Test kiểm tra hành vi:
@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 chỉ load context liên quan JPA (repository, entity manager), chạy nhanh hơn @SpringBootTest. Mặc định dùng in-memory database và rollback sau mỗi test.
4.3. Phân trang và sắp xếp
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 là tham số đặc biệt — Spring Data tự nhận diện và không coi nó là điều kiện WHERE. Page<Order> trả về kèm metadata: tổng số phần tử, tổng số trang, nội dung trang hiện tại.
4.4. Khi nào tên method trở nên rối
// Khó đọc, dễ gõ sai từ khóa
List<Order> findByStatusAndCustomerEmailContainingIgnoreCaseAndCreatedAtAfterOrderByTotalAmountDesc(
OrderStatus status, String keyword, LocalDateTime after);
Tên method dài như trên vẫn chạy được, nhưng khó đọc, khó maintain, và compiler không giúp bạn kiểm tra lỗi cú pháp — Spring Data chỉ phát hiện lỗi khi khởi động context (fail-fast nhưng vẫn là runtime). Đây là lúc nên chuyển sang @Query.
5. @Query: kiểm soát câu lệnh trực tiếp
5.1. JPQL cơ bản
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 viết JPQL — ngôn ngữ truy vấn hướng entity, không phải SQL thuần (dùng Order o, o.status thay vì tên bảng/cột). @Param ánh xạ tên tham số trong query với tham số method, giúp tên biến linh hoạt, không phụ thuộc thứ tự.
5.2. Native SQL khi cần
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 cho phép viết SQL thật, dùng được cú pháp đặc thù của database như LIMIT. Đổi lại, bạn mất tính portable giữa các database khác nhau (ví dụ Oracle không hỗ trợ LIMIT).
Lưu ý: Native query trả về entity vẫn hoạt động nếu tên cột khớp với mapping, nhưng nếu SELECT ra DTO tùy ý, bạn cần thêm
@SqlResultSetMappinghoặc dùng projection interface — phức tạp hơn JPQL.
5.3. Update và Delete với @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 báo cho Spring Data biết đây là câu lệnh thay đổi dữ liệu (UPDATE/DELETE), không phải SELECT. Method trả về int là số dòng bị ảnh hưởng. Thiếu @Modifying sẽ gây lỗi InvalidDataAccessApiUsageException vì JPA mong đợi một ResultSet để map.
Lưu ý: Bulk update bằng
@Modifyingđi thẳng xuống database, không chạy qua entity lifecycle (không trigger@PreUpdate, không cập nhật cache của entity đang managed trong persistence context). Nếu service đang giữ instanceOrdercũ trong tay, dữ liệu trong bộ nhớ sẽ không đồng bộ — cầnclearAutomatically = truetrên@Modifyinghoặc tựentityManager.clear().
5.4. Projection: chỉ lấy field cần thiết
Khi không cần toàn bộ entity, dùng interface projection để giảm dữ liệu trả về:
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 tạo proxy implement OrderSummary lúc runtime, chỉ map đúng 3 field được SELECT. Cách này tránh load toàn bộ entity khi chỉ cần hiển thị danh sách tóm tắt — giảm tải cho cả query và bộ nhớ.
Test cho @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;
}
}
Test này xác nhận điều kiện totalAmount > minAmount hoạt động đúng — chỉ đơn hàng 500 được trả về, đơn hàng 50 bị loại.
6. So sánh query method và @Query
| Tiêu chí | Query method | @Query |
|---|---|---|
| Tốc độ viết | Nhanh cho điều kiện đơn giản | Cần viết JPQL/SQL thủ công |
| Độ phức tạp hỗ trợ | Hạn chế với JOIN, subquery | Linh hoạt, hỗ trợ mọi JPQL/SQL |
| Khả năng đọc | Tên dài khi nhiều điều kiện | Rõ ràng, tách biệt logic query |
| Kiểm tra lỗi | Lỗi khi context khởi động | Lỗi khi context khởi động (JPQL), lỗi khi chạy (native SQL) |
| Phù hợp | CRUD cơ bản, 1-3 điều kiện | Query phức tạp, JOIN, aggregate, projection |
Nguyên tắc chọn: bắt đầu bằng query method cho các trường hợp đơn giản. Khi tên method vượt quá 3-4 điều kiện hoặc cần JOIN/subquery, chuyển sang @Query để dễ đọc và kiểm soát.
7. Lỗi thường gặp
Quên @Param khi dùng tên tham số named parameter:
// Lỗi: thiếu @Param, Spring không biết map :status với tham số nào
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusRaw(OrderStatus status);
Cách sửa: thêm @Param("status") vào tham số, hoặc compile với flag -parameters để Spring tự lấy tên tham số từ bytecode (Spring Boot mặc định đã bật flag này từ Maven/Gradle plugin, nhưng vẫn nên ghi @Param rõ ràng để tránh phụ thuộc cấu hình build).
Dùng findAll() rồi filter bằng Java stream thay vì viết query:
// Không nên: tải toàn bộ bảng vào memory rồi mới lọc
List<Order> paidOrders = orderRepository.findAll().stream()
.filter(o -> o.getStatus() == OrderStatus.PAID)
.toList();
Cách này load toàn bộ dữ liệu từ database lên application, lãng phí băng thông và memory. Luôn đẩy điều kiện filter xuống database qua query method hoặc @Query.
Quên @Transactional khi gọi @Modifying ngoài phạm vi service có transaction:
@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status")
void deleteByStatusBulk(@Param("status") OrderStatus status);
Gọi method này trực tiếp mà không có transaction bao ngoài sẽ ném TransactionRequiredException. Luôn bọc trong method @Transactional ở tầng service, không gọi trực tiếp từ controller.
8. Kết luận
Query method phù hợp cho các truy vấn đơn giản, giúp code ngắn và dễ đọc khi điều kiện không quá 2-3 field. Khi logic phức tạp hơn — cần JOIN, subquery, bulk update, hoặc projection tùy biến — @Query là lựa chọn rõ ràng và dễ maintain hơn.
Nguyên tắc chung: ưu tiên đẩy logic lọc dữ liệu xuống database thay vì xử lý trong Java, luôn viết test cho mỗi query quan trọng, và cân nhắc dùng native SQL chỉ khi JPQL không đáp ứng được (ví dụ cần hàm đặc thù của database). Nắm vững ba kỹ thuật này, bạn đã đủ công cụ xử lý phần lớn bài toán truy xuất dữ liệu trong ứng dụng Spring Boot thực tế.