Màn hình quản trị đơn hàng nào cũng có một khối bộ lọc: trạng thái, khoảng ngày, thành phố khách hàng, mã sản phẩm, tổng tiền tối thiểu. Người dùng chọn tổ hợp nào cũng được, và ở backend đoạn code sinh ra câu truy vấn thường biến thành một chuỗi if nối String JPQL hoặc một hàng chục phương thức findByStatusAndCityAndCreatedOnBetween… trong repository. Specification pattern giải quyết đúng chỗ đó: mỗi điều kiện nghiệp vụ là một đối tượng nhỏ, đặt tên rõ nghĩa, có thể ghép bằng and, or, not và tái sử dụng ở nhiều nơi. Bài này đi từ nguồn gốc của pattern, cách Spring Data JPA hiện thực hoá nó, tới các cạm bẫy khi phân trang và fetch join, với code chạy thật trên JDK 21.
Bài nối tiếp Repository pattern và Unit of Work với JPA: nếu repository là cửa vào của tập hợp đối tượng, thì Specification là cách mô tả "tập con nào" mà không làm repository phình ra. Chúng ta cũng sẽ dùng lại ý tưởng tiêm phụ thuộc ở bài Dependency Injection: tự viết IoC container để hiểu Spring khi để Spring sinh sẵn phần cài đặt repository.
Vấn đề mà Specification pattern giải quyết: truy vấn động nở ra
Giả sử bảng orders có 5 tiêu chí lọc tuỳ chọn. Số tổ hợp là 25 = 32. Có ba cách làm phổ biến và đều có giá phải trả:
- Derived query method (
findByStatusAndTotalAmountGreaterThanEqual): tên phương thức dài ra theo số tiêu chí, và mỗi tổ hợp cần một phương thức riêng. - Nối chuỗi JPQL: linh hoạt nhưng dễ lỗi cú pháp, dễ SQL/JPQL injection nếu quên tham số hoá, và không kiểm tra được lúc biên dịch.
- Gọi thẳng Criteria API trong service: an toàn hơn, nhưng logic nghiệp vụ như "đơn có giá trị cao" bị rải rác và copy-paste.
Điều ta cần từ Specification pattern là tách điều kiện (cái gì) khỏi thực thi truy vấn (như thế nào), đặt tên cho từng điều kiện bằng ngôn ngữ nghiệp vụ, rồi ghép chúng lại khi cần.
Nguồn gốc của Specification pattern: Evans và Fowler
Specification pattern được Eric Evans và Martin Fowler mô tả trong bài báo "Specifications" (xem bản PDF trên martinfowler.com) và sau đó nằm trong sách Domain-Driven Design của Evans. Ý tưởng cốt lõi: một specification là một predicate được đóng gói thành đối tượng, với phương thức trung tâm isSatisfiedBy(candidate) trả về true nếu ứng viên thoả điều kiện. Bài báo nêu ba mục đích dùng:
- Validation: kiểm tra một đối tượng có đáp ứng quy tắc hay không (ví dụ đơn có đủ điều kiện miễn phí vận chuyển).
- Selection: chọn các đối tượng trong một tập hợp thoả điều kiện.
- Building to order: mô tả đối tượng cần được tạo ra phải có đặc điểm gì.
Phần cài đặt tối thiểu của Specification pattern bằng Java chỉ cần một functional interface với ba phương thức mặc định. Đoạn dưới đây (file plain/PlainSpec.java) chạy hoàn toàn trên bộ nhớ, không cần database:
interface Spec<T> {
boolean isSatisfiedBy(T t);
default Spec<T> and(Spec<T> o) { return t -> isSatisfiedBy(t) && o.isSatisfiedBy(t); }
default Spec<T> or(Spec<T> o) { return t -> isSatisfiedBy(t) || o.isSatisfiedBy(t); }
default Spec<T> not() { return t -> !isSatisfiedBy(t); }
}
record Order(String code, String status, long total) {}
Spec<Order> paid = o -> o.status().equals("PAID");
Spec<Order> big = o -> o.total() >= 1_000_000;Chạy bằng java plain/PlainSpec.java với bốn đơn mẫu, kết quả thật:
paid and big : [DH001, DH003]
not paid : [DH002, DH004]
validate DH002 as paid-and-big: falseCùng một đối tượng paid.and(big) vừa dùng để lọc danh sách (selection) vừa dùng để kiểm tra một đơn đơn lẻ (validation). Đây là điểm mạnh của Specification pattern. Điểm yếu của bản in-memory này là với bảng hàng triệu dòng, ta không muốn tải hết vào RAM rồi mới lọc; ta muốn dịch specification thành mệnh đề WHERE và để database lọc. Đó chính là việc Spring Data JPA làm.
Specification pattern trong Spring Data JPA
Trong Spring Data JPA, Specification pattern được hiện thực bằng interface Specification<T>, có một phương thức trừu tượng: toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb). Thay vì trả về boolean, nó trả về một jakarta.persistence.criteria.Predicate, tức một mảnh của mệnh đề WHERE. Theo Javadoc của Specification 3.3.5, interface còn có sẵn các phương thức and, or (từ 2.0), not, where (từ 2.0) và allOf, anyOf (từ 3.0). Repository chỉ cần kế thừa thêm JpaSpecificationExecutor, như mô tả trong tài liệu chính thức về Specifications.
Môi trường của bài: Java 21, Spring Boot 3.3.5 (kéo theo Spring Data JPA 3.3.5, Hibernate 6.5.3.Final), H2 2.2.224 in-memory. Toàn bộ project Maven nằm ở code/s6-22/.
Mô hình dữ liệu và repository
Ba entity: Customer (tên, thành phố), Order (mã, trạng thái, tổng tiền, ngày tạo, khách hàng) và OrderItem (SKU, số lượng). Repository chỉ có hai dòng nhờ JpaSpecificationExecutor:
public interface OrderRepository
extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
}Interface này bổ sung các phương thức findAll(Specification), findAll(Specification, Pageable), findOne, count, exists và delete. Không cần viết thêm dòng nào cho từng tiêu chí.
Viết Specification pattern thành các mảnh nhỏ, đặt tên theo nghiệp vụ
Nguyên tắc của Specification pattern: mỗi specification là một static factory method trả về Specification<Order>, tên đọc như câu tiếng Anh nghiệp vụ.
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Order> totalAtLeast(BigDecimal min) {
return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("totalAmount"), min);
}
public static Specification<Order> createdBetween(LocalDate from, LocalDate to) {
return (root, query, cb) -> cb.between(root.get("createdOn"), from, to);
}
public static Specification<Order> customerInCity(String city) {
return (root, query, cb) ->
cb.equal(root.join("customer", JoinType.INNER).get("city"), city);
}Chuỗi "status", "totalAmount" là tên thuộc tính của entity, không phải tên cột. Nếu muốn kiểm tra lúc biên dịch, bạn có thể dùng JPA static metamodel (Order_.status) sinh bằng hibernate-jpamodelgen; bài này giữ chuỗi cho gọn.
Ghép điều kiện bằng and, or, not
Đây là phần làm cho Specification pattern đáng giá. Ta ghép như biểu thức logic và đọc được như yêu cầu nghiệp vụ:
Specification<Order> highPaidOrCancelled =
OrderSpecs.hasStatus(OrderStatus.PAID)
.and(OrderSpecs.totalAtLeast(new BigDecimal("2000000")))
.or(OrderSpecs.hasStatus(OrderStatus.CANCELLED));
orders.findAll(highPaidOrCancelled);
orders.findAll(Specification.not(OrderSpecs.hasStatus(OrderStatus.CANCELLED)));Để thử Specification pattern, dữ liệu mẫu ban đầu có 5 đơn (DH001–DH005); mục 10 bên dưới thêm đơn thứ sáu. Output thật khi chạy java -jar target/specification-demo-1.0.jar, kèm câu SQL mà Hibernate log ra (rút gọn danh sách cột):
--- 2) PAID va tong >= 1.000.000 ---
select ... from orders o1_0 where o1_0.status=? and o1_0.total_amount>=?
DH001 | PAID | 1200000
DH003 | PAID | 4800000
DH005 | PAID | 2500000
=> 3 don
--- 5) (PAID and >= 2.000.000) or CANCELLED ---
select ... from orders o1_0 where o1_0.status=? and o1_0.total_amount>=? or o1_0.status=?
DH003 | PAID | 4800000
DH004 | CANCELLED | 900000
DH005 | PAID | 2500000
=> 3 don
--- 6) not(CANCELLED) ---
select ... from orders o1_0 where o1_0.status<>?
=> 4 donHãy chú ý câu SQL của truy vấn 5: Hibernate in a and b or c không có ngoặc. Điều này đúng vì AND có độ ưu tiên cao hơn OR trong SQL, trùng với thứ tự ta ghép ((a and b) or c). Vì vậy hãy luôn đọc log SQL khi trộn and với or, và đặt tên cho specification tổ hợp để thứ tự ghép không bị đổi nhầm.
Specification pattern cho tiêu chí tuỳ chọn: gom vào đối tượng tìm kiếm
Với form lọc, Specification pattern kết hợp tốt với DTO: ta thường nhận một DTO có các trường có thể null. Cách gọn là để DTO tự dựng specification, bỏ qua mọi tiêu chí rỗng:
public record OrderSearch(OrderStatus status, BigDecimal minTotal, LocalDate from,
LocalDate to, String city, String sku) {
public Specification<Order> toSpecification() {
Specification<Order> spec = Specification.where(null);
if (status != null) spec = spec.and(OrderSpecs.hasStatus(status));
if (minTotal != null) spec = spec.and(OrderSpecs.totalAtLeast(minTotal));
if (from != null && to != null) spec = spec.and(OrderSpecs.createdBetween(from, to));
if (city != null) spec = spec.and(OrderSpecs.customerInCity(city));
if (sku != null) spec = spec.and(OrderSpecs.containsSku(sku));
return spec;
}
}Specification.where(null) trả về một specification không thêm điều kiện nào (Javadoc cam kết kết quả không bao giờ null), nên có thể nối and liên tiếp mà không phải kiểm tra null. Khi mọi tiêu chí rỗng, truy vấn không có WHERE và trả về cả 5 đơn. Kết hợp thật: đơn PAID, khách ở Ha Noi, trong tháng 10/2024:
--- 3) PAID, o Ha Noi, trong thang 10/2024 ---
select ... from orders o1_0 join customers c1_0 on c1_0.id=o1_0.customer_id
where o1_0.status=? and o1_0.created_on between ? and ? and c1_0.city=?
DH001 | PAID | 1200000
DH003 | PAID | 4800000
=> 2 donVới cách này, mọi giá trị đều đi qua tham số ? của JDBC, nên cách này miễn nhiễm với injection kiểu nối chuỗi. Lưu ý đặt các tên cột, tên sắp xếp lấy từ người dùng vào danh sách cho phép, vì Sort.by(chuỗiNgườiDùng) vẫn là đường vào cho tên thuộc tính tuỳ ý.
Specification pattern lọc theo bộ sưu tập: dùng EXISTS thay vì join
Muốn tìm các đơn có chứa SKU AO-01, phản xạ đầu tiên là join sang order_items. Nhưng join một-nhiều nhân bản dòng ở tầng SQL: đơn có hai dòng hàng khớp cho hai dòng kết quả, nên truy vấn count của phân trang đếm sai (ta sẽ đo ngay bên dưới). Dùng subquery EXISTS tránh được cả hai:
public static Specification<Order> containsSku(String sku) {
return (root, query, cb) -> {
Subquery<Long> sub = query.subquery(Long.class);
Root<OrderItem> item = sub.from(OrderItem.class);
sub.select(item.get("id"))
.where(cb.equal(item.get("order"), root), cb.equal(item.get("sku"), sku));
return cb.exists(sub);
};
}--- 4) Chua SKU AO-01 (EXISTS) ---
select ... from orders o1_0 where exists(select oi1_0.id from order_items oi1_0
where oi1_0.order_id=o1_0.id and oi1_0.sku=?)
DH001 | PAID | 1200000
DH002 | NEW | 350000
DH003 | PAID | 4800000
=> 3 donỞ dữ liệu mẫu ban đầu, mỗi đơn chỉ có tối đa một dòng AO-01 nên join thường cũng cho cùng kết quả; muốn thấy sự khác biệt phải có đơn với hai dòng cùng SKU. Ta thêm đơn DH006 (hai dòng AO-01, số lượng 1 và 2) rồi chạy hai phiên bản: một specification ngây thơ dùng join sang order_items, một dùng EXISTS.
public static Specification<Order> containsSkuJoin(String sku) {
return (root, query, cb) -> cb.equal(root.join("items", JoinType.INNER).get("sku"), sku);
}--- 10) Don co hai dong cung SKU: join va EXISTS ---
>> DH006 co hai dong hang cung SKU AO-01
--- 10a) join sang order_items (ngay tho) --- => 4 don (DH001, DH002, DH003, DH006)
--- 10b) EXISTS --- => 4 don (DH001, DH002, DH003, DH006)
select count(o1_0.id) from orders o1_0 join order_items i1_0 on o1_0.id=i1_0.order_id where i1_0.sku=?
>> dem bang join = 5
select count(o1_0.id) from orders o1_0 where exists(select oi1_0.id from order_items oi1_0 where oi1_0.order_id=o1_0.id and oi1_0.sku=?)
>> dem bang EXISTS = 4(Hai dòng 10a, 10b ở trên là phần tóm tắt từ output đầy đủ trong code/s6-22/run.log; hai số đếm là output nguyên văn.)
Điểm đáng chú ý: truy vấn lấy danh sách bằng join vẫn trả 4 đơn, vì ở phía bộ nhớ Hibernate (bản 6.5 trong demo) loại bỏ các thực thể gốc trùng nhau khi dựng danh sách; SQL thật vẫn trả hai dòng cho DH006, và hành vi này không nên coi là bảo đảm giữa các phiên bản. Cái bị nhân bản nằm ở truy vấn count: join cho 5 (DH006 bị đếm hai lần), còn EXISTS cho đúng 4. Vì findAll(spec, pageable) dùng truy vấn count để tính tổng số trang, join một-nhiều làm tổng phần tử sai. Quy tắc nhớ khi dùng Specification pattern: lọc qua quan hệ nhiều-một (khách hàng) thì join được; lọc qua quan hệ một-nhiều (dòng hàng) thì ưu tiên EXISTS.
Cạm bẫy 1 của Specification pattern: fetch join làm hỏng truy vấn count
Khi gọi findAll(spec, pageable), Spring Data chạy hai truy vấn: một lấy dữ liệu trang hiện tại, một count để tính tổng số phần tử. Cả hai đều áp dụng cùng specification, tức toPredicate được gọi hai lần với hai CriteriaQuery khác nhau. Một Specification pattern gọi root.fetch("customer") vô điều kiện sẽ cố gắng fetch join trong cả truy vấn count, nơi không có entity nào trong select list. Ta thử phiên bản ngây thơ (fetchCustomerNaive) và chạy thật:
--- 9) Fetch join ngay tho + Page: truy van count se the nao? ---
select ... from orders o1_0 join customers c1_0 on c1_0.id=o1_0.customer_id where 1=1 offset ? rows fetch first ? rows only
Loi: InvalidDataAccessApiUsageException -> Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmSingularJoin(demo.spec.Order(15).customer(16) : customer)]Truy vấn dữ liệu chạy được, nhưng truy vấn count thất bại với Hibernate 6.5.3. Cách sửa chuẩn là kiểm tra kiểu kết quả của truy vấn trước khi fetch, như trong phiên bản đúng:
public static Specification<Order> fetchCustomer() {
return (root, query, cb) -> {
if (query.getResultType() != Long.class && query.getResultType() != long.class) {
root.fetch("customer", JoinType.INNER);
}
return cb.conjunction();
};
}Kết quả khi phân trang trang đầu (2 đơn mỗi trang, sắp xếp theo ngày giảm dần, lọc đơn PAID):
--- 7) Phan trang + fetch join: 1 truy van data + 1 truy van count ---
select o1_0.id,...,c1_0.id,c1_0.city,c1_0.name,... from orders o1_0
join customers c1_0 on c1_0.id=o1_0.customer_id
where 1=1 and o1_0.status=? order by o1_0.created_on desc offset ? rows fetch first ? rows only
select count(o1_0.id) from orders o1_0 where 1=1 and o1_0.status=?
Trang 0, tong 3 don, 2 trang
DH005 | Tran Binh
DH003 | Le ChiTruy vấn dữ liệu có join customers, truy vấn count thì không: đúng ý định. Một cảnh báo khác: không fetch join một-nhiều cùng với phân trang. Khi đó Hibernate phải phân trang trong bộ nhớ (cảnh báo HHH90003004) vì không thể áp offset/limit lên tập đã bị nhân bản. Với quan hệ một-nhiều, hãy lấy trang ID bằng specification trước, rồi nạp quan hệ bằng truy vấn thứ hai.
Cạm bẫy 2 của Specification pattern: N+1 khi không fetch
Quan hệ customer khai báo LAZY. Nếu specification chỉ lọc mà không fetch, rồi vòng lặp gọi order.getCustomer().getName(), mỗi đơn kích hoạt thêm một truy vấn. Log thật của ca đó (3 đơn PAID, chạy trong một transaction):
--- 8) Khong fetch join: truy cap customer trong transaction -> N+1 ---
select ... from orders o1_0 where o1_0.status=?
select c1_0.id,c1_0.city,c1_0.name from customers c1_0 where c1_0.id=?
DH001 | Nguyen An
select c1_0.id,c1_0.city,c1_0.name from customers c1_0 where c1_0.id=?
DH003 | Le Chi
select c1_0.id,c1_0.city,c1_0.name from customers c1_0 where c1_0.id=?
DH005 | Tran BinhMột truy vấn gốc cộng ba truy vấn con (log trên có ba khách khác nhau): số truy vấn tối đa là 1 cộng số khách hàng khác nhau trong trang (persistence context chỉ nạp mỗi khách một lần), nên trang 50 đơn của 50 khách khác nhau là 51 truy vấn. Có ba hướng xử lý: fetch join trong specification như trên, @EntityGraph trên phương thức repository, hoặc dùng projection chỉ lấy các cột cần cho màn hình danh sách. Nếu không có transaction hay open-in-view (ta đã tắt ở application.properties), truy cập lazy ngoài transaction còn ném LazyInitializationException.
Specification pattern so với các lựa chọn khác
Không có công cụ nào thắng mọi tình huống, và Specification pattern cũng vậy. Bảng so sánh dưới đây giúp chọn đúng:
| Cách làm | Phù hợp khi | Hạn chế |
|---|---|---|
| Derived query method | 1–3 tiêu chí cố định | Tên dài, không ghép động được |
@Query JPQL | Truy vấn cố định, cần tối ưu tay | Tiêu chí tuỳ chọn phải viết :p is null or … |
| Specification pattern (Criteria) | Bộ lọc động, nhiều tổ hợp, điều kiện tái sử dụng | API Criteria dài dòng, khó đọc SQL sinh ra |
| Querydsl / jOOQ | Truy vấn phức tạp, cần kiểm tra kiểu mạnh | Thêm thư viện, bước sinh code |
| Native SQL / báo cáo | Aggregation, window function, báo cáo | Mất tính di động và ghép nối |
Hạn chế quan trọng nhất của Specification pattern kiểu Criteria là nó chỉ biểu diễn mệnh đề WHERE (và vài thao tác join/fetch qua root). Các truy vấn có GROUP BY, hàm cửa sổ hay thống kê nhiều bảng nên viết bằng SQL hoặc jOOQ. Tránh lạm dụng bằng cách tạo specification cho từng điều kiện đơn giản chỉ dùng một lần; nếu điều kiện không được tái sử dụng hay ghép, một derived query rõ ràng còn dễ đọc hơn.
Kiểm thử Specification pattern
Vì mỗi mảnh của Specification pattern nhỏ và độc lập, ta kiểm thử bằng @DataJpaTest với H2: nạp vài bản ghi biên (đúng bằng ngưỡng 1.000.000, ngày đầu và ngày cuối khoảng) rồi khẳng định tập kết quả. Bài kiểm thử nên bao gồm ba loại: từng specification riêng lẻ, các tổ hợp nghiệp vụ quan trọng (ví dụ "đơn giá trị cao"), và truy vấn Page để bắt lỗi fetch join ở truy vấn count như trên. Đừng kiểm thử bằng cách so sánh chuỗi SQL, vì Hibernate đổi định dạng giữa các phiên bản (alias o1_0 chỉ có từ Hibernate 6).
Lỗi thường gặp khi dùng Specification pattern
- Ghép or/and nhầm thứ tự trong Specification pattern: kiểm tra log SQL, đặt tên specification tổ hợp như
isHighValueOrCancelled()để chứa luôn thứ tự ghép. - Trùng join: hai specification cùng gọi
root.join("customer")sẽ sinh hai join. Viết một hàm tiện ích tìm join đã có trongroot.getJoins()hoặc gom điều kiện về một specification. - Fetch join trong truy vấn count: luôn kiểm tra
query.getResultType(). - Sắp xếp theo thuộc tính không tồn tại: whitelist tên cột sắp xếp từ API.
- Specification pattern phụ thuộc trạng thái ngoài: ví dụ lấy
LocalDate.now()bên trong; hãy truyền tham số hoặcClockđể test được. - Quên chỉ mục: Specification pattern chỉ sinh SQL, không tạo chỉ mục. Tiêu chí lọc phổ biến (status, created_on, customer_id) cần index; phân tích kế hoạch thực thi bằng
EXPLAIN(xem tài liệu MySQL về EXPLAIN), còn cách bật slow log để tìm truy vấn chậm có trong bài Tầng Database: MySQL đa tenant trên VPS cấu hình nhỏ.
Checklist áp dụng Specification pattern
- Mỗi điều kiện nghiệp vụ là một Specification pattern nhỏ, tên rõ nghĩa, nằm trong lớp
XxxSpecs. - Dùng
Specification.where(null)hoặcallOfđể ghép điều kiện tuỳ chọn. - Lọc quan hệ một-nhiều bằng
EXISTS; không fetch join một-nhiều khi phân trang. - Bảo vệ truy vấn count: bỏ qua fetch khi
resultTypelàLong. - Tránh N+1 bằng fetch join có kiểm soát,
@EntityGraphhoặc projection. - Đưa mọi giá trị người dùng qua tham số; whitelist tên cột sắp xếp.
- Kiểm thử bằng
@DataJpaTestvới dữ liệu biên và truy vấnPage.
Tóm lại, Specification pattern biến các bộ lọc động thành những viên gạch đặt tên, ghép được và kiểm thử được, đúng với tinh thần "predicate là đối tượng" của Evans và Fowler. Spring Data JPA cho bạn bộ máy sẵn có, còn việc của ta là giữ specification nhỏ, kiểm soát join và fetch, và biết lúc nào nên chuyển sang công cụ khác.
Bài tiếp theo
Khi các điều kiện nghiệp vụ bắt đầu có ý nghĩa riêng, câu hỏi tiếp theo là mô hình hoá chúng ở tầng miền: đối tượng nào có định danh, đối tượng nào chỉ là giá trị. Mời bạn đọc tiếp DDD trong Java: Entity, Value Object và Aggregate.



