JpaRepository & Derived Queries — zero-code query từ tên method
Spring Data JPA parse tên method thành JPQL tự động lúc startup. Bài này bóc interface hierarchy CrudRepository→PagingAndSortingRepository→JpaRepository, cơ chế Spring Data parse findByStatusAndOwnerId thành JPQL rồi Hibernate dịch sang SQL, và khi nào derived query đủ — khi nào nên nhường chỗ cho @Query.
TL;DR: JpaRepository là interface đỉnh trong cây phân cấp Spring Data, kế thừa CrudRepository (save/find/delete) và PagingAndSortingRepository (Pageable/Sort). Khi bạn khai báo method findByStatusAndOwnerId, Spring Data parse tên method lúc startup thành một cây token (findBy → Status And OwnerId), sinh JPQL tương ứng, rồi Hibernate dịch JPQL sang SQL theo dialect. Không viết một dòng SQL. Giới hạn: method name dài vượt 4-5 điều kiện trở nên khó đọc — đó là tín hiệu chuyển sang @Query.
Bài EntityManager & JPQL đã giới thiệu cách Spring Data sinh query ở mức tổng quan. Bài này đào sâu một tier: derived query — query dẫn xuất từ tên method: Spring Data parse tên method (findByStatus…) thành JPQL, bạn không viết câu query nào. Zero-code nhưng có cơ chế parse riêng cần hiểu để tránh pitfall.
1. JpaRepository — cây phân cấp interface
Khi bạn viết extends JpaRepository<Task, Long>, bạn đang kế thừa toàn bộ cây Repository → CrudRepository → PagingAndSortingRepository → JpaRepository, mỗi tầng thêm một nhóm method:
| Interface | Thêm gì |
|---|---|
Repository | Marker — trống, Spring Data detect bằng nó để scan |
CrudRepository | 9 method cốt lõi: save, saveAll, findById, findAll, count, existsById, delete, deleteById, deleteAll |
PagingAndSortingRepository | findAll(Pageable) trả Page<T>, findAll(Sort) |
JpaRepository | flush, saveAndFlush, deleteInBatch, getReferenceById (reference không load ngay), findAll(Example) |
Trong thực tế, 99% case chỉ cần extends JpaRepository — nó là superset của 3 interface kia. Trường hợp duy nhất nên extends interface thấp hơn là khi muốn giới hạn API (tránh expose deleteAll ra service layer).
Proxy sinh tự động
Khi app khởi động, Spring Data quét tất cả interface extends Repository, rồi sinh proxy động implement chúng thông qua JdkDynamicAopProxy hoặc ProxyFactory. Proxy này uỷ quyền xuống SimpleJpaRepository — implementation mặc định — rồi SimpleJpaRepository gọi EntityManager JPA chuẩn:
Hệ quả quan trọng: bạn không viết implementation — proxy sinh ra lúc startup. Nếu Spring không thể tạo proxy (ví dụ entity class không có @Entity, hoặc kiểu ID sai), lỗi xuất hiện ngay khi app khởi động, không phải lúc gọi method.
2. Cơ chế bên dưới — parse tên method thành JPQL
Đây là phần cốt lõi của bài. Khi bạn khai báo:
List<Task> findByStatusAndOwnerId(TaskStatus status, Long ownerId);
Spring Data không ghi nhớ tên method như string. Thay vào đó, nó parse tên method thành cây token ngay lúc startup (trong PartTreeJpaQuery), sinh JPQL từ cây đó, và compile JPQL thành TypedQuery của EntityManager. Sau đó, mỗi lần method được gọi, TypedQuery đã compile được tái sử dụng — không parse lại.

Ba bước biến đổi:
- Parse tên method (
PartTree): táchfindBy(verb + subject),Status(property đầu tiên),And(toán tử logic),OwnerId(property thứ hai). Từ đây Spring biết cầnWHERE t.status = ? AND t.ownerId = ?. - Sinh JPQL (
QueryUtils): dựng câu JPQL dùng tên entity và tên field Java — không phải tên bảng hay cột SQL. - Hibernate dịch JPQL sang SQL theo
Dialectcủa database đang dùng (PostgreSQL, MySQL, H2 …). Tên column đến từ Hibernate naming strategy (ownerId→owner_id).
Vì sao thiết kế này đáng giá: khi đổi tên cột PostgreSQL trong migration Flyway, bạn chỉ cần sửa @Column(name = "...") trong entity — JPQL + tên method repository không thay đổi. Database concern nằm đúng trong entity mapping, không rò rỉ ra repository.
Ghép hai điều trên lại — proxy sinh lúc startup, query parse lúc startup — sẽ thấy toàn bộ phần đắt nằm gọn ở pha khởi động, còn mỗi lời gọi về sau chỉ còn chi phí bind tham số:

2.1 Grammar tên method đầy đủ
[verb] [Distinct]? [Top|First N]? By [property] [keyword] ... [OrderBy ...]
Verb hợp lệ:
| Verb | Ý nghĩa | Return type |
|---|---|---|
find…By | SELECT | Optional<T>, List<T>, Page<T>, Stream<T> |
count…By | SELECT COUNT | long |
exists…By | SELECT 1 | boolean |
delete…By | DELETE (cần @Modifying) | long, void |
Keyword property:
| Keyword | SQL tương ứng |
|---|---|
(không có) / Equals | = ? |
Not | != ? |
LessThan / LessThanEqual | < ? / <= ? |
GreaterThan / GreaterThanEqual | > ? / >= ? |
Between | BETWEEN ? AND ? |
In / NotIn | IN (?) / NOT IN (?) |
Containing | LIKE %?% |
StartingWith | LIKE ?% |
EndingWith | LIKE %? |
IgnoreCase | LOWER(col) = LOWER(?) |
IsNull / IsNotNull | IS NULL / IS NOT NULL |
True / False | = true / = false |
OrderBy | ORDER BY |
Ví dụ thực tế cho Task entity:
public interface TaskRepository extends JpaRepository<Task, Long> {
// Lookup don gian
Optional<Task> findByTitle(String title);
List<Task> findByStatus(TaskStatus status);
long countByStatus(TaskStatus status);
boolean existsByTitleAndProjectId(String title, Long projectId);
// Nhieu dieu kien
List<Task> findByStatusAndProjectId(TaskStatus status, Long projectId);
List<Task> findByDueDateBeforeAndStatusNot(Instant deadline, TaskStatus exclude);
// String matching
List<Task> findByTitleContainingIgnoreCase(String fragment); // LIKE %?% case-insensitive
List<Task> findByTitleStartingWith(String prefix); // LIKE ?%
// Null check
List<Task> findByAssigneeIsNull();
List<Task> findByAssigneeIsNotNull();
// In collection
List<Task> findByStatusIn(Collection<TaskStatus> statuses);
// Sort + limit
List<Task> findTop5ByProjectIdOrderByCreatedAtDesc(Long projectId);
List<Task> findDistinctByStatusOrderByDueDateAsc(TaskStatus status);
// Pageable
Page<Task> findByProjectId(Long projectId, Pageable pageable);
}
2.2 Xác minh SQL sinh ra
Bật log Hibernate để xem SQL thật, đặc biệt khi lần đầu viết derived query:
# application.yaml (dev profile)
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
Output trong console:
DEBUG o.h.SQL: select t1_0.id, t1_0.title, t1_0.status, t1_0.owner_id
from tasks t1_0
where t1_0.status=? and t1_0.owner_id=?
TRACE o.h.o.j.b: binding parameter [1] as [VARCHAR] - [ACTIVE]
TRACE o.h.o.j.b: binding parameter [2] as [BIGINT] - [42]
3. Khi nào derived query đủ — khi nào chuyển @Query
Derived query tốt cho query đơn giản, ít điều kiện. Ngưỡng thực tế:
| Số điều kiện | Khuyến nghị |
|---|---|
| 1-3 | Derived query — ngắn, tự documenting |
| 4-5 | Cân nhắc — method name bắt đầu dài |
| 6+ | Chuyển sang @Query JPQL |
Ba trường hợp derived query không đủ:
// CASE 1: Method name qua dai — kho doc
List<Task> findByStatusAndProjectIdAndAssigneeIsNotNullAndDueDateBeforeAndPriorityGreaterThan(
TaskStatus status, Long projectId, Instant deadline, int minPriority);
// -> Switch @Query JPQL
// CASE 2: Aggregate function (SUM, AVG, MAX) -- khong support derived
// SAI - khong compile:
// Integer sumPriorityByProjectId(Long projectId);
// -> Phai dung @Query
// CASE 3: JOIN entity lien ket voi dieu kien phuc tap
// -> @Query voi JOIN explicit ro rang hon
Grammar tên method chỉ sinh được SELECT entity / COUNT / EXISTS / DELETE. Method như sumPriorityByProjectId không compile — Spring Data báo No property 'sumPriority' found lúc startup. Mọi aggregate function phải viết bằng @Query("SELECT SUM(t.priority) FROM Task t …").
Khi vượt ngưỡng, chuyển sang @Query và Specification — xem bài tiếp theo: @Query, modifying & projection.
4. Pitfall phổ biến
Pitfall 1 — Tên property sai, lỗi lúc startup:
// SAI -- entity Task co field "ownerId" nhung method dung "userId"
List<Task> findByUserId(Long userId);
// Spring throw: No property 'userId' found for type 'Task'!
// App KHONG khoi dong duoc
// DUNG -- khop ten field trong entity
List<Task> findByOwnerId(Long ownerId);
Lỗi No property X found xuất hiện ngay lúc startup — đây là "fail fast" có chủ đích. Không cần chờ request đầu tiên để phát hiện tên method sai.
Pitfall 2 — findAll() không Pageable cho bảng lớn:
// SAI -- load toan bo table vao RAM
List<Task> all = repo.findAll();
// Table 1M row -> OOM
// DUNG -- phan trang
Page<Task> page = repo.findAll(PageRequest.of(0, 50, Sort.by("createdAt").descending()));
findAll() không giới hạn số row — bảng 1M row nghĩa là 1M entity load vào heap, app chết bằng OutOfMemoryError đúng lúc traffic cao. Quy tắc cho mọi list endpoint: luôn nhận Pageable từ controller xuống repository, hoặc tối thiểu thêm điều kiện findBy... giới hạn tập kết quả.
Pitfall 3 — Nhầm findBy trả Optional vs List:
// Neu co nhieu row khop ma return Optional -> throw IncorrectResultSizeDataAccessException
Optional<Task> findByStatus(TaskStatus status); // nguy hiem neu > 1 row
// DUNG -- neu co the nhieu row
List<Task> findByStatus(TaskStatus status);
// DUNG -- chi khi biet chinh xac 1 row (unique constraint)
Optional<Task> findByTitle(String title); // chi khi title la unique
Return type Optional khiến Spring Data gọi getSingleResult() — JPA yêu cầu đúng 1 row. Query khớp 2 row trở lên throw IncorrectResultSizeDataAccessException lúc runtime, không phải lúc startup, nên test với 1 row vẫn xanh. Chỉ dùng Optional khi có unique constraint đảm bảo tối đa 1 row.
5. Liên hệ các bài khác
- EntityManager & JPQL: nền tảng JPQL + 3 tier query của Spring Data (Tier 1 built-in, Tier 2 derived, Tier 3
@Query) — bài này là deep dive Tier 2; JPQL sinh ra cuối cùng đi vàoEntityManager, hiểu nó giúp debug khi derived query sinh SQL không như kỳ vọng. - @Query, modifying & projection: bước tiếp — khi derived query không đủ, viết JPQL/native tường minh bằng
@Query, và cách dùng projection DTO để chỉ select field cần.
Tóm tắt
JpaRepositorykế thừaCrudRepository→PagingAndSortingRepository→JpaRepository. DùngJpaRepositorymặc định cho mọi repository.- Spring Data sinh proxy tự động lúc startup — không cần viết implementation. Lỗi cấu hình lộ ra ngay khi khởi động (fail fast).
- Derived query hoạt động theo 3 bước: parse tên method (PartTree) → sinh JPQL (QueryUtils) → Hibernate dịch sang SQL theo dialect. Toàn bộ quá trình xảy ra lúc startup, không lặp lại mỗi request.
- Grammar:
[verb][Distinct]?[Top N]? By [property] [keyword] ... [OrderBy ...]. Keyword phổ biến:And,Or,Not,Between,In,Containing,IsNull,OrderBy. - Giới hạn: không hỗ trợ aggregate function, method name dài trên 4-5 điều kiện kém đọc. Khi đó chuyển
@Query. - Pitfall chính: sai tên property (fail lúc startup),
findAll()không Pageable (OOM),Optionalcho query có thể trả nhiều row.
Tự kiểm tra
- Q1Khi bạn khai báo
List<Task> findByStatusAndOwnerId(TaskStatus status, Long ownerId), Spring Data làm gì lúc startup? Mô tả 3 bước biến đổi từ tên method đến SQL thật. - Q2Tại sao Spring Data chọn parse tên method lúc startup thay vì lúc method được gọi lần đầu? Lợi ích thiết kế là gì?
- Q3Bạn có entity
Taskvới fieldassigneeId. Viết derived query để: (1) tìm task chưa có assignee, (2) tìm 5 task mới nhất của một project, (3) đếm task theo status. Viết đúng tên method và return type. - Q4Method sau gây lỗi gì và vì sao?
Optional<Task> findByProjectId(Long projectId). Sửa đúng. - Q5Khi nào nên chuyển từ derived query sang
@Query? Cho 3 ví dụ cụ thể với tên method thật.
Bài tiếp theo: @Query, modifying & projection
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên