Criteria API and Type-Safe Dynamic Queries
Learn Java Persistence, Database Integration, and JPA - Part 015
Deep dive into Criteria API and Specification pattern: programmatic query construction, composable predicates, dynamic filtering, joins, count queries, pagination boundaries, and anti-patterns.
Part 015 — Criteria API and Type-Safe Dynamic Queries
Criteria API sering dianggap sebagai "JPQL yang lebih panjang". Itu salah framing.
Criteria API adalah cara membangun query tree secara programmatic. Ia berguna ketika bentuk query tidak statis: filter opsional, kombinasi predicate, sorting dinamis, access-control predicate, tenant predicate, search form, dan reusable query fragments.
Namun Criteria API juga mudah disalahgunakan. Tanpa disiplin desain, ia berubah menjadi ratusan baris builder code yang lebih sulit dibaca daripada JPQL, lebih sulit diuji, dan lebih mudah menghasilkan join tersembunyi.
Part ini membahas:
- mental model Criteria sebagai abstract syntax tree query;
- komponen inti
CriteriaBuilder,CriteriaQuery,Root,Join,Path,Predicate,Expression, danSelection; - kapan Criteria lebih tepat daripada JPQL;
- bagaimana membuat dynamic query yang composable;
- bagaimana Spring Data JPA
Specificationmembungkus Criteria predicate; - bagaimana menghindari anti-pattern seperti monster specification, fetch join pagination trap, dan OR explosion;
- bagaimana memisahkan filter semantics, fetch plan, projection, sorting, dan authorization predicate.
1. Target Skill Berdasarkan Kaufman
Sub-skill yang ingin kita kuasai:
- membaca Criteria API sebagai query AST, bukan string concatenation;
- menerjemahkan JPQL sederhana ke struktur Criteria;
- membuat predicate opsional tanpa
ifchaos; - menyusun filter dinamis yang reusable;
- mengelola join secara eksplisit dan predictable;
- membuat count query yang benar untuk pagination;
- membedakan predicate specification dari fetch plan;
- mengenali kapan Criteria sudah bukan alat yang tepat;
- menulis test untuk query semantics, bukan hanya test repository happy path.
Target akhirnya:
Ketika melihat search screen dengan 20 filter opsional, kita bisa mendesain query layer yang tetap readable, testable, performant, dan tidak mencampur domain rule, security rule, pagination, fetch strategy, dan DTO projection secara sembarangan.
2. Kenapa Criteria API Ada?
JPQL bagus untuk query statis:
select c
from CaseRecord c
where c.status = :status
and c.createdAt >= :from
order by c.createdAt desc
Masalah muncul ketika filter bersifat opsional:
statusboleh kosong;fromdantoboleh kosong;assignedOfficerIdopsional;tenantIdwajib untuk security;keywordbisa mencari beberapa field;severitybisa multiple value;- sorting dipilih user;
- ada rule tambahan jika user bukan supervisor;
- query harus bisa dipakai untuk list screen dan export.
Pilihan buruk:
String jpql = "select c from CaseRecord c where 1=1";
if (status != null) {
jpql += " and c.status = '" + status + "'";
}
Masalahnya:
- raw string concatenation rentan injection;
- spasi dan alias mudah rusak;
- predicate sulit diuji per bagian;
- path property tidak checked saat compile;
- refactoring field sering baru ketahuan saat runtime;
- query menjadi procedural soup.
Criteria API menawarkan model yang lebih aman:
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<CaseRecord> query = cb.createQuery(CaseRecord.class);
Root<CaseRecord> root = query.from(CaseRecord.class);
List<Predicate> predicates = new ArrayList<>();
if (status != null) {
predicates.add(cb.equal(root.get("status"), status));
}
if (from != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), from));
}
query.select(root)
.where(predicates.toArray(Predicate[]::new))
.orderBy(cb.desc(root.get("createdAt")));
List<CaseRecord> results = entityManager.createQuery(query).getResultList();
Ini belum ideal, tetapi sudah lebih aman daripada string concatenation.
3. Mental Model: Criteria sebagai Query Tree
Criteria bukan "builder SQL". Criteria membangun object graph yang merepresentasikan query.
Yang penting:
CriteriaQuery<T>menentukan result type;Root<X>menentukan entity root diFROM;Path<Y>merepresentasikan access ke persistent attribute;Predicatemerepresentasikan kondisi boolean;Expression<T>merepresentasikan expression yang punya type;Selection<T>menentukan apa yang dikembalikan;- provider menerjemahkan tree ini menjadi SQL.
Di level mental model:
| JPQL Concept | Criteria API Concept |
|---|---|
select c | query.select(root) |
from CaseRecord c | Root<CaseRecord> root = query.from(CaseRecord.class) |
where c.status = :status | cb.equal(root.get("status"), status) |
join c.assignee a | root.join("assignee") |
order by c.createdAt desc | query.orderBy(cb.desc(root.get("createdAt"))) |
select new Dto(...) | cb.construct(Dto.class, ...) |
count(c) | cb.count(root) |
and, or, not | cb.and(...), cb.or(...), cb.not(...) |
4. Core Objects
4.1 CriteriaBuilder
CriteriaBuilder adalah factory untuk:
CriteriaQuery;CriteriaUpdate;CriteriaDelete;- predicate;
- expression;
- aggregate function;
- ordering;
- constructor projection.
Biasanya diperoleh dari EntityManager:
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
Jangan treat CriteriaBuilder sebagai stateful service. Ia bukan tempat menyimpan filter. Ia hanya factory.
4.2 CriteriaQuery<T>
CriteriaQuery<T> mendefinisikan query top-level dan result type.
CriteriaQuery<CaseRecord> query = cb.createQuery(CaseRecord.class);
Jika result-nya DTO:
CriteriaQuery<CaseSummary> query = cb.createQuery(CaseSummary.class);
Jika result-nya scalar:
CriteriaQuery<Long> query = cb.createQuery(Long.class);
Jika result-nya mixed:
CriteriaQuery<Tuple> query = cb.createTupleQuery();
Rule penting:
Result type harus dipikirkan dari use case. Jangan default mengembalikan entity jika screen hanya butuh lima column.
4.3 Root<T>
Root<T> adalah entity root dalam FROM clause.
Root<CaseRecord> caseRoot = query.from(CaseRecord.class);
Untuk query sederhana, biasanya hanya ada satu root. Multiple root berarti cross join kecuali diberi predicate korelasi. Hati-hati.
Bad smell:
Root<CaseRecord> c = query.from(CaseRecord.class);
Root<Officer> o = query.from(Officer.class);
query.where(cb.equal(c.get("assignee"), o));
Lebih baik gunakan association join jika relasinya memang ada:
Root<CaseRecord> c = query.from(CaseRecord.class);
Join<CaseRecord, Officer> o = c.join("assignee");
4.4 Path<T>
Path<T> merepresentasikan field/property persistent.
Path<CaseStatus> status = root.get("status");
Path<Instant> createdAt = root.get("createdAt");
Masalah string path:
root.get("cretaedAt") // typo baru ketahuan runtime
Static metamodel mengurangi risiko:
root.get(CaseRecord_.createdAt)
Namun static metamodel butuh annotation processing dan tidak selalu dipakai di semua codebase. Alternatifnya adalah query DSL library, tetapi di seri ini kita fokus ke JPA/Spring Data JPA built-in.
4.5 Predicate
Predicate adalah boolean expression.
Predicate active = cb.equal(root.get("status"), CaseStatus.ACTIVE);
Predicate recent = cb.greaterThan(root.get("createdAt"), from);
query.where(cb.and(active, recent));
Predicate bisa dibuat composable, tetapi jangan lupa ia terikat ke:
CriteriaBuilder;Root;CriteriaQuery;- join yang dibuat.
Karena itu predicate object tidak reusable lintas query instance. Yang reusable adalah function yang membangun predicate.
4.6 Join
Join dibuat dari root atau join lain:
Join<CaseRecord, Officer> officer = root.join("assignee", JoinType.LEFT);
Rule desain:
Join harus eksplisit, diberi alasan, dan dikendalikan. Dynamic query yang membuat join di banyak fragment bisa menghasilkan duplicate join atau SQL membengkak.
4.7 Selection
Selection menentukan result.
Entity result:
query.select(root);
Single scalar:
query.select(root.get("id"));
DTO constructor:
query.select(cb.construct(
CaseSummary.class,
root.get("id"),
root.get("caseNumber"),
root.get("status")
));
Tuple:
query.multiselect(
root.get("id").alias("id"),
root.get("caseNumber").alias("caseNumber")
);
5. Basic Criteria Query
Domain contoh:
@Entity
@Table(name = "case_record")
public class CaseRecord {
@Id
private UUID id;
@Column(nullable = false, unique = true)
private String caseNumber;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private CaseStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "assignee_id")
private Officer assignee;
@Column(nullable = false)
private UUID tenantId;
@Column(nullable = false)
private Instant createdAt;
@Version
private long version;
}
Query: cari active cases milik tenant tertentu.
public List<CaseRecord> findActiveCases(UUID tenantId) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<CaseRecord> query = cb.createQuery(CaseRecord.class);
Root<CaseRecord> root = query.from(CaseRecord.class);
query.select(root)
.where(
cb.equal(root.get("tenantId"), tenantId),
cb.equal(root.get("status"), CaseStatus.ACTIVE)
)
.orderBy(cb.desc(root.get("createdAt")));
return entityManager.createQuery(query)
.setMaxResults(100)
.getResultList();
}
SQL kira-kira:
select c.*
from case_record c
where c.tenant_id = ?
and c.status = ?
order by c.created_at desc
limit ?
Yang harus ditanya:
- Apakah index mendukung
(tenant_id, status, created_at)? - Apakah result entity akan dimodifikasi?
- Apakah screen butuh semua column?
- Apakah
assigneeakan diakses setelah query? - Apakah pagination stabil jika banyak row punya
created_atsama?
Criteria API tidak menghapus kebutuhan berpikir SQL.
6. Dynamic Filter Object
Mulai dari filter object yang eksplisit:
public record CaseSearchFilter(
UUID tenantId,
Set<CaseStatus> statuses,
UUID assigneeId,
Instant createdFrom,
Instant createdTo,
String keyword,
Boolean overdueOnly
) {}
Jangan oper raw request object langsung ke repository. Normalisasi dulu di service/application layer:
public CaseSearchFilter normalize(CaseSearchRequest request, UUID tenantId) {
return new CaseSearchFilter(
tenantId,
emptyToNull(request.statuses()),
request.assigneeId(),
request.createdFrom(),
request.createdTo(),
normalizeKeyword(request.keyword()),
Boolean.TRUE.equals(request.overdueOnly())
);
}
Kenapa?
- tenant/security context harus authoritative, bukan dari request;
- empty string harus jadi
nullatau normalized value; - date range harus divalidasi;
- status list harus dibatasi;
- keyword harus dipotong length-nya;
- sorting harus whitelist.
7. Predicate Builder Pattern
Bentuk awal:
public List<CaseRecord> search(CaseSearchFilter filter) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<CaseRecord> query = cb.createQuery(CaseRecord.class);
Root<CaseRecord> root = query.from(CaseRecord.class);
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("tenantId"), filter.tenantId()));
if (filter.statuses() != null && !filter.statuses().isEmpty()) {
predicates.add(root.get("status").in(filter.statuses()));
}
if (filter.assigneeId() != null) {
predicates.add(cb.equal(root.get("assignee").get("id"), filter.assigneeId()));
}
if (filter.createdFrom() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), filter.createdFrom()));
}
if (filter.createdTo() != null) {
predicates.add(cb.lessThan(root.get("createdAt"), filter.createdTo()));
}
query.select(root)
.where(predicates.toArray(Predicate[]::new))
.orderBy(cb.desc(root.get("createdAt")), cb.desc(root.get("id")));
return entityManager.createQuery(query)
.setMaxResults(100)
.getResultList();
}
Ini acceptable untuk query kecil. Namun jika dipakai di banyak tempat, fragment predicate sebaiknya diekstrak.
Contoh helper:
public final class CasePredicates {
private CasePredicates() {}
public static Optional<Predicate> hasStatuses(
Set<CaseStatus> statuses,
Root<CaseRecord> root
) {
if (statuses == null || statuses.isEmpty()) {
return Optional.empty();
}
return Optional.of(root.get("status").in(statuses));
}
public static Optional<Predicate> createdFrom(
Instant from,
CriteriaBuilder cb,
Root<CaseRecord> root
) {
if (from == null) {
return Optional.empty();
}
return Optional.of(cb.greaterThanOrEqualTo(root.get("createdAt"), from));
}
}
Usage:
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("tenantId"), filter.tenantId()));
CasePredicates.hasStatuses(filter.statuses(), root).ifPresent(predicates::add);
CasePredicates.createdFrom(filter.createdFrom(), cb, root).ifPresent(predicates::add);
Lebih penting dari syntax adalah separation:
- filter normalization di application layer;
- predicate construction di query layer;
- fetch plan di query method;
- authorization predicate harus eksplisit;
- pagination dan sorting tidak boleh terselip di arbitrary predicate fragment.
8. Null Discipline
Dynamic query sering rusak bukan karena Criteria API, tetapi karena semantics filter tidak jelas.
Contoh ambiguous:
status == null
Apakah artinya:
- jangan filter status?
- cari row dengan
status is null? - invalid request?
- default ke active?
Buat semantics eksplisit.
public sealed interface StatusFilter {
record Any() implements StatusFilter {}
record In(Set<CaseStatus> values) implements StatusFilter {}
record IsNull() implements StatusFilter {}
}
Lalu query builder:
public Predicate statusPredicate(
StatusFilter filter,
CriteriaBuilder cb,
Root<CaseRecord> root
) {
return switch (filter) {
case StatusFilter.Any ignored -> cb.conjunction();
case StatusFilter.In in -> root.get("status").in(in.values());
case StatusFilter.IsNull ignored -> cb.isNull(root.get("status"));
};
}
cb.conjunction() berarti predicate selalu true. Gunakan hati-hati. Jangan jadikan ia tempat menyembunyikan filter wajib yang hilang.
Bad:
Predicate tenantPredicate = tenantId == null
? cb.conjunction()
: cb.equal(root.get("tenantId"), tenantId);
Untuk tenant/security filter, tenantId == null harus error, bukan "no filter".
Good:
if (tenantId == null) {
throw new IllegalArgumentException("tenantId is required");
}
Predicate tenantPredicate = cb.equal(root.get("tenantId"), tenantId);
9. Keyword Search
Contoh keyword sederhana:
if (filter.keyword() != null) {
String like = "%" + filter.keyword().toLowerCase(Locale.ROOT) + "%";
Predicate byCaseNumber = cb.like(cb.lower(root.get("caseNumber")), like);
Predicate byTitle = cb.like(cb.lower(root.get("title")), like);
predicates.add(cb.or(byCaseNumber, byTitle));
}
Masalah:
lower(column)bisa menghambat index biasa;- leading wildcard
%keywordbiasanya tidak memanfaatkan B-tree index; - OR di banyak column bisa membuat query plan buruk;
- search semantics sering lebih cocok memakai full-text search.
Rule praktis:
| Kebutuhan | Pendekatan |
|---|---|
| Exact lookup | = dengan index |
| Prefix search | like 'abc%' dengan index yang sesuai |
| Contains search kecil | like '%abc%', batasi dataset |
| Full-text multi-field | database full-text/search engine |
| Typo tolerance/ranking | search engine atau specialized index |
Criteria API tidak membuat search mahal menjadi murah.
10. Join dalam Criteria
10.1 Many-to-one join
Join<CaseRecord, Officer> assignee = root.join("assignee", JoinType.LEFT);
if (filter.assigneeName() != null) {
predicates.add(cb.like(
cb.lower(assignee.get("name")),
"%" + filter.assigneeName().toLowerCase(Locale.ROOT) + "%"
));
}
Gunakan join jika filter/sort/projection membutuhkan table terkait.
Jangan join hanya karena "mungkin nanti dipakai".
10.2 Join reuse
Masalah umum:
Specification<CaseRecord> byOfficerName = (root, query, cb) -> {
Join<CaseRecord, Officer> officer = root.join("assignee", JoinType.LEFT);
return cb.like(officer.get("name"), "%andi%");
};
Specification<CaseRecord> byOfficerUnit = (root, query, cb) -> {
Join<CaseRecord, Officer> officer = root.join("assignee", JoinType.LEFT);
return cb.equal(officer.get("unit"), "ENFORCEMENT");
};
Jika digabung, provider bisa membuat dua join ke association yang sama. Kadang SQL masih benar, tapi bisa lebih berat dan membingungkan.
Solusi sederhana: centralize join creation pada query method atau gunakan helper join registry.
@SuppressWarnings("unchecked")
private static <Z, X> Join<Z, X> getOrCreateJoin(
From<Z, ?> from,
String attribute,
JoinType joinType
) {
for (Join<Z, ?> join : from.getJoins()) {
if (join.getAttribute().getName().equals(attribute)
&& join.getJoinType().equals(joinType)) {
return (Join<Z, X>) join;
}
}
return (Join<Z, X>) from.join(attribute, joinType);
}
Usage:
Join<CaseRecord, Officer> officer = getOrCreateJoin(root, "assignee", JoinType.LEFT);
Trade-off: helper ini provider-independent secara konsep, tetapi tetap perlu diuji karena Criteria tree dapat berbeda tergantung provider.
11. Sorting Dinamis
Jangan langsung menerima field sort dari request dan memasukkannya ke root.get(...).
Bad:
query.orderBy(cb.asc(root.get(request.sortBy())));
Masalah:
- runtime error jika field tidak valid;
- user bisa men-trigger join/sort mahal;
- sort by non-indexed column bisa membunuh performa;
- sort semantics bisa tidak stabil.
Gunakan whitelist:
public enum CaseSortKey {
CREATED_AT,
CASE_NUMBER,
STATUS
}
Mapper:
private Order toOrder(
CaseSortKey key,
SortDirection direction,
CriteriaBuilder cb,
Root<CaseRecord> root
) {
Expression<?> expression = switch (key) {
case CREATED_AT -> root.get("createdAt");
case CASE_NUMBER -> root.get("caseNumber");
case STATUS -> root.get("status");
};
return direction == SortDirection.ASC
? cb.asc(expression)
: cb.desc(expression);
}
Tambahkan tie-breaker stabil:
query.orderBy(
toOrder(sort.key(), sort.direction(), cb, root),
cb.desc(root.get("id"))
);
Kenapa tie-breaker penting?
Pagination dengan order tidak unik bisa menghasilkan row yang lompat/duplikat antar page ketika data berubah atau database memilih order internal berbeda.
12. Count Query untuk Pagination
Pagination butuh dua query:
- content query;
- count query.
Content query:
CriteriaQuery<CaseSummary> contentQuery = cb.createQuery(CaseSummary.class);
Root<CaseRecord> root = contentQuery.from(CaseRecord.class);
contentQuery.select(cb.construct(
CaseSummary.class,
root.get("id"),
root.get("caseNumber"),
root.get("status"),
root.get("createdAt")
))
.where(predicates.toArray(Predicate[]::new))
.orderBy(cb.desc(root.get("createdAt")), cb.desc(root.get("id")));
Count query:
CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);
Root<CaseRecord> countRoot = countQuery.from(CaseRecord.class);
List<Predicate> countPredicates = buildPredicates(filter, cb, countRoot);
countQuery.select(cb.count(countRoot))
.where(countPredicates.toArray(Predicate[]::new));
Important:
Jangan reuse
Predicateobject dari content query ke count query. Predicate terikat ke root/query asalnya.
Karena itu reusable unit harus berupa function:
@FunctionalInterface
public interface CriteriaPredicateFactory<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
Lalu:
private List<Predicate> buildPredicates(
CaseSearchFilter filter,
CriteriaBuilder cb,
Root<CaseRecord> root
) {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("tenantId"), filter.tenantId()));
// add optional predicates
return predicates;
}
13. distinct Tidak Sama Dengan Query Murah
Saat join collection, result entity bisa duplicate:
Root<CaseRecord> root = query.from(CaseRecord.class);
Join<CaseRecord, Violation> violations = root.join("violations");
query.select(root)
.distinct(true)
.where(cb.equal(violations.get("code"), "AML-001"));
distinct(true) bisa diperlukan secara semantics, tetapi ada konsekuensi:
- database mungkin melakukan sort/hash distinct;
- pagination dengan join collection bisa rumit;
- count query harus memakai
countDistinctjika semantics memang distinct root; - duplicate dapat muncul karena desain query/fetch plan yang salah.
Jangan pakai distinct sebagai obat umum.
Tanyakan:
- Apakah join collection memang diperlukan?
- Apakah bisa query id dulu lalu fetch detail?
- Apakah filter bisa memakai
existssubquery? - Apakah read model lebih tepat?
14. Subquery dan exists
Kadang lebih baik memakai subquery daripada join collection.
Contoh: cari case yang punya violation code tertentu.
CriteriaQuery<CaseRecord> query = cb.createQuery(CaseRecord.class);
Root<CaseRecord> root = query.from(CaseRecord.class);
Subquery<UUID> subquery = query.subquery(UUID.class);
Root<Violation> violation = subquery.from(Violation.class);
subquery.select(violation.get("caseRecord").get("id"))
.where(
cb.equal(violation.get("caseRecord"), root),
cb.equal(violation.get("code"), "AML-001")
);
query.select(root)
.where(cb.exists(subquery));
SQL kira-kira:
select c.*
from case_record c
where exists (
select 1
from violation v
where v.case_record_id = c.id
and v.code = ?
)
Keuntungan:
- menghindari duplicate root akibat join collection;
- semantics filter lebih jelas;
- sering lebih cocok untuk "has at least one child matching condition".
Namun performa tergantung database, index, dan optimizer. Selalu cek execution plan untuk query penting.
15. Criteria DTO Projection
DTO projection:
public record CaseSummary(
UUID id,
String caseNumber,
CaseStatus status,
Instant createdAt
) {}
Criteria:
CriteriaQuery<CaseSummary> query = cb.createQuery(CaseSummary.class);
Root<CaseRecord> root = query.from(CaseRecord.class);
query.select(cb.construct(
CaseSummary.class,
root.get("id"),
root.get("caseNumber"),
root.get("status"),
root.get("createdAt")
))
.where(cb.equal(root.get("tenantId"), tenantId));
Gunakan projection ketika:
- data hanya dibaca;
- screen butuh subset column;
- entity graph besar;
- result volume tinggi;
- tidak ingin managed entity dan dirty checking;
- API contract tidak sama dengan domain entity.
Projection akan dibahas lebih dalam di Part 016.
16. Criteria Bulk Update/Delete
Criteria API juga memiliki bulk update/delete:
CriteriaUpdate<CaseRecord> update = cb.createCriteriaUpdate(CaseRecord.class);
Root<CaseRecord> root = update.from(CaseRecord.class);
update.set(root.get("status"), CaseStatus.ARCHIVED)
.where(
cb.equal(root.get("tenantId"), tenantId),
cb.lessThan(root.get("createdAt"), cutoff)
);
int affected = entityManager.createQuery(update).executeUpdate();
Bulk operation penting untuk maintenance dan batch, tetapi berbahaya karena:
- bypass entity lifecycle callback;
- bypass dirty checking;
- tidak otomatis sinkron dengan managed entities di persistence context;
- bisa membuat first-level/second-level cache stale;
- tidak cocok untuk domain operation yang butuh invariant per aggregate.
Rule:
Setelah bulk update/delete, persistence context yang mungkin memuat entity terdampak harus dibersihkan atau tidak digunakan lagi untuk keputusan bisnis.
Praktis:
int affected = entityManager.createQuery(update).executeUpdate();
entityManager.clear();
Di Spring Data JPA, @Modifying(clearAutomatically = true) sering dipakai untuk alasan serupa, tetapi tetap pahami konsekuensinya.
17. Spring Data JPA Specification
Spring Data JPA Specification<T> adalah wrapper kecil di sekitar Criteria predicate.
Konsep dasarnya:
public interface Specification<T> {
Predicate toPredicate(
Root<T> root,
CriteriaQuery<?> query,
CriteriaBuilder criteriaBuilder
);
}
Repository:
public interface CaseRecordRepository
extends JpaRepository<CaseRecord, UUID>,
JpaSpecificationExecutor<CaseRecord> {
}
Specification:
public final class CaseSpecifications {
private CaseSpecifications() {}
public static Specification<CaseRecord> belongsToTenant(UUID tenantId) {
return (root, query, cb) -> cb.equal(root.get("tenantId"), tenantId);
}
public static Specification<CaseRecord> hasStatus(CaseStatus status) {
return (root, query, cb) -> status == null
? cb.conjunction()
: cb.equal(root.get("status"), status);
}
public static Specification<CaseRecord> createdAfter(Instant from) {
return (root, query, cb) -> from == null
? cb.conjunction()
: cb.greaterThanOrEqualTo(root.get("createdAt"), from);
}
}
Usage:
Specification<CaseRecord> spec = Specification
.where(CaseSpecifications.belongsToTenant(tenantId))
.and(CaseSpecifications.hasStatus(filter.status()))
.and(CaseSpecifications.createdAfter(filter.createdFrom()));
Page<CaseRecord> page = repository.findAll(spec, pageable);
Ini terlihat bersih. Namun ada jebakan.
18. Specification: Apa yang Boleh dan Tidak Boleh
Specification sebaiknya merepresentasikan predicate semantics.
Good:
belongsToTenant(tenantId)
hasStatus(status)
createdBetween(from, to)
assignedTo(officerId)
hasViolationCode(code)
Bad:
fetchEverythingNeededByDashboard()
applyDefaultSort()
limitTo100Rows()
selectSummaryDto()
joinAllAssociations()
Kenapa?
Specification memang menerima CriteriaQuery<?>, sehingga secara teknis bisa memodifikasi query:
return (root, query, cb) -> {
root.fetch("assignee", JoinType.LEFT);
query.distinct(true);
return cb.equal(root.get("status"), status);
};
Tapi ini membuat fragment predicate punya side effect ke fetch plan dan distinct behavior. Jika spec dipakai untuk count query, pagination, export, atau endpoint lain, side effect ini bisa menyebabkan bug.
Rule:
Treat Specification as predicate by default. Fetch plan dan projection harus diputuskan oleh query method/use case, bukan oleh arbitrary predicate fragment.
19. Specification Composition
Composable specification:
public static Specification<CaseRecord> search(CaseSearchFilter filter) {
return Specification
.where(belongsToTenant(filter.tenantId()))
.and(hasAnyStatus(filter.statuses()))
.and(assignedTo(filter.assigneeId()))
.and(createdFrom(filter.createdFrom()))
.and(createdBefore(filter.createdTo()))
.and(keyword(filter.keyword()));
}
Implementation:
public static Specification<CaseRecord> hasAnyStatus(Set<CaseStatus> statuses) {
return (root, query, cb) -> {
if (statuses == null || statuses.isEmpty()) {
return cb.conjunction();
}
return root.get("status").in(statuses);
};
}
public static Specification<CaseRecord> assignedTo(UUID officerId) {
return (root, query, cb) -> {
if (officerId == null) {
return cb.conjunction();
}
return cb.equal(root.get("assignee").get("id"), officerId);
};
}
Untuk filter opsional, cb.conjunction() okay. Untuk filter wajib seperti tenant, jangan silent.
public static Specification<CaseRecord> belongsToTenant(UUID tenantId) {
if (tenantId == null) {
throw new IllegalArgumentException("tenantId is required");
}
return (root, query, cb) -> cb.equal(root.get("tenantId"), tenantId);
}
20. Authorization Predicate
Pada sistem multi-tenant/regulatory, query predicate bukan hanya search filter. Ada juga authorization predicate.
Contoh:
public record CaseAccessScope(
UUID tenantId,
UUID userId,
boolean supervisor,
Set<UUID> allowedUnitIds
) {}
Spec:
public static Specification<CaseRecord> visibleTo(CaseAccessScope scope) {
return (root, query, cb) -> {
Predicate tenant = cb.equal(root.get("tenantId"), scope.tenantId());
if (scope.supervisor()) {
return tenant;
}
Predicate assignedToUser = cb.equal(root.get("assignee").get("id"), scope.userId());
Predicate inAllowedUnit = root.get("unitId").in(scope.allowedUnitIds());
return cb.and(tenant, cb.or(assignedToUser, inAllowedUnit));
};
}
Jangan campur access scope dari request user mentah. Access scope harus berasal dari authenticated principal/authorization service.
Bad:
visibleTo(request.tenantId(), request.userId())
Good:
CaseAccessScope scope = accessScopeResolver.resolve(currentUser);
Specification<CaseRecord> spec = visibleTo(scope).and(search(filter));
21. Query Object Pattern
Jika query sudah kompleks, jangan paksakan semua ke repository method.
Buat query object/application query service:
@Repository
public class CaseSearchQuery {
private final EntityManager entityManager;
public CaseSearchQuery(EntityManager entityManager) {
this.entityManager = entityManager;
}
public Page<CaseSummary> search(CaseSearchFilter filter, PageRequest pageRequest) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
List<CaseSummary> content = fetchContent(cb, filter, pageRequest);
long total = count(cb, filter);
return new PageImpl<>(content, pageRequest.toPageable(), total);
}
private List<CaseSummary> fetchContent(
CriteriaBuilder cb,
CaseSearchFilter filter,
PageRequest pageRequest
) {
// content query
}
private long count(CriteriaBuilder cb, CaseSearchFilter filter) {
// count query
}
}
Kapan memakai query object?
- projection kompleks;
- count query berbeda dari content query;
- perlu subquery/exists;
- perlu dynamic join registry;
- butuh query hint;
- repository interface mulai penuh method aneh;
- endpoint search menjadi domain penting.
Repository interface bagus untuk CRUD dan query sederhana. Untuk search/reporting kompleks, query object lebih maintainable.
22. Fetch Join di Specification: Trap
Contoh spec berbahaya:
public static Specification<CaseRecord> withAssignee() {
return (root, query, cb) -> {
root.fetch("assignee", JoinType.LEFT);
return cb.conjunction();
};
}
Lalu:
Page<CaseRecord> page = repository.findAll(
search(filter).and(withAssignee()),
pageable
);
Masalah:
- Spring Data JPA biasanya membuat count query juga;
- fetch join tidak meaningful untuk count query;
- join collection + pagination bisa menghasilkan result salah/mahal;
- spec punya side effect tersembunyi;
distinctsering ditambahkan tanpa memahami konsekuensi.
Lebih aman:
- pisahkan method untuk fetch plan;
- gunakan
@EntityGraphuntuk query tertentu; - gunakan DTO projection untuk list screen;
- query id page dulu, lalu fetch detail by ids jika perlu.
Pattern dua tahap:
Page<UUID> ids = caseIdSearchQuery.searchIds(filter, pageable);
List<CaseSummary> summaries = caseSummaryQuery.findByIdsPreservingOrder(ids.getContent());
Ini lebih verbose, tetapi sering lebih predictable untuk query besar.
23. Criteria vs Specification vs Query By Example vs JPQL
| Kebutuhan | Pilihan Umum |
|---|---|
| Query statis dan readable | JPQL / @Query |
| Dynamic predicate sederhana | Spring Data Specification |
| Dynamic query kompleks | Criteria in custom query object |
| Exact match dari contoh object sederhana | Query By Example |
| Read-only DTO list | JPQL constructor / Criteria construct / native projection |
| Vendor-specific SQL feature | Native SQL / jOOQ-style approach |
| Complex reporting | Read model / materialized view / database-specific query |
Rule:
Jangan memilih Criteria karena terlihat enterprise. Pilih Criteria ketika query structure memang harus dibangun programmatically.
24. Anti-Pattern: Monster Specification
Bad:
public static Specification<CaseRecord> everything(CaseSearchRequest request) {
return (root, query, cb) -> {
// 300 lines of if statements
// joins created everywhere
// fetches mixed with predicates
// sorting modified here
// security rule mixed with search rule
// projection impossible
};
}
Masalah:
- tidak bisa diuji per predicate;
- tidak reusable;
- count query bisa salah;
- filter semantics tersamar;
- join sulit dikontrol;
- developer takut mengubah;
- query plan bisa berubah karena perubahan kecil.
Refactor menjadi:
Specification<CaseRecord> spec = Specification
.where(visibleTo(scope))
.and(matchesSearchFilter(filter))
.and(notDeleted());
Dengan fragment kecil:
matchesStatus(filter.statuses())
createdWithin(filter.createdFrom(), filter.createdTo())
assignedTo(filter.assigneeId())
hasKeyword(filter.keyword())
Dan jika tetap kompleks, pindah ke custom query object.
25. Anti-Pattern: Dynamic OR Explosion
Contoh:
Predicate keywordPredicate = cb.or(
cb.like(cb.lower(root.get("caseNumber")), like),
cb.like(cb.lower(root.get("title")), like),
cb.like(cb.lower(root.get("description")), like),
cb.like(cb.lower(root.get("applicantName")), like),
cb.like(cb.lower(root.get("officerNote")), like)
);
Ini mungkin masih okay untuk data kecil. Untuk tabel besar, ini bisa membunuh index dan CPU.
Mitigasi:
- batasi field searchable;
- batasi panjang keyword;
- minimal keyword length;
- gunakan exact/prefix search jika cukup;
- buat search vector/full-text index;
- pindahkan ke search engine jika butuh ranking/fuzzy;
- ukur query plan dengan data representatif.
26. Anti-Pattern: Dynamic Sort Tanpa Index Budget
Sort bukan kosmetik. Sort adalah operasi database.
Jika UI mengizinkan sort by semua column, user bisa memaksa query mahal.
Desain sort:
public enum CaseSortKey {
CREATED_AT,
UPDATED_AT,
PRIORITY,
CASE_NUMBER
}
Untuk setiap sort key, dokumentasikan:
| Sort Key | Indexed? | Stable tie-breaker | Allowed for large page? |
|---|---|---|---|
CREATED_AT | yes | id | yes |
UPDATED_AT | yes | id | yes |
PRIORITY | partial | createdAt, id | yes with filter |
APPLICANT_NAME | no | id | no |
Kualitas query layer enterprise terlihat dari hal-hal seperti ini.
27. Anti-Pattern: root.get("association").get("id") Tanpa Paham SQL
Kode ini:
cb.equal(root.get("assignee").get("id"), assigneeId)
Sering diterjemahkan menjadi foreign key comparison tanpa join, tetapi tidak selalu untuk semua path/mapping. Jika butuh field milik associated entity selain id, join diperlukan.
Bandingkan:
// filter by FK/id
cb.equal(root.get("assignee").get("id"), assigneeId)
// filter by officer name
Join<CaseRecord, Officer> officer = root.join("assignee", JoinType.LEFT);
cb.like(cb.lower(officer.get("name")), like)
Selalu lihat SQL generated untuk query penting.
28. Anti-Pattern: Specification Mengembalikan null
Beberapa contoh lama mengembalikan null dari toPredicate untuk no-op filter.
return status == null ? null : cb.equal(root.get("status"), status);
Lebih eksplisit:
return status == null
? cb.conjunction()
: cb.equal(root.get("status"), status);
Atau composition helper yang hanya menambahkan spec jika value ada:
Specification<CaseRecord> spec = Specification.where(belongsToTenant(tenantId));
if (status != null) {
spec = spec.and(hasStatus(status));
}
Keduanya bisa diterima. Yang penting consistency.
29. Query Hints dan Read-Only Semantics
Criteria query tetap menghasilkan TypedQuery, sehingga bisa diberi hint:
TypedQuery<CaseSummary> typedQuery = entityManager.createQuery(query);
typedQuery.setHint("org.hibernate.readOnly", true);
typedQuery.setHint("jakarta.persistence.query.timeout", 2_000);
Hati-hati:
- hint provider-specific harus dibungkus agar tidak tersebar;
- timeout bukan pengganti index;
- read-only hint tidak mengubah semantics domain;
- query hint perlu diuji di provider/database target.
Untuk Spring:
@Transactional(readOnly = true)
public Page<CaseSummary> search(...) {
// query
}
readOnly = true adalah signal penting, tetapi bukan lisensi untuk mengabaikan managed entity side effect. Untuk list screen besar, DTO projection tetap lebih aman.
30. Testing Criteria/Specification
Jangan hanya test bahwa repository method tidak throw.
Test semantics:
@Test
void search_filters_by_tenant_and_status() {
// given
UUID tenantA = UUID.randomUUID();
UUID tenantB = UUID.randomUUID();
persistCase(tenantA, CaseStatus.ACTIVE);
persistCase(tenantA, CaseStatus.CLOSED);
persistCase(tenantB, CaseStatus.ACTIVE);
// when
var result = query.search(new CaseSearchFilter(
tenantA,
Set.of(CaseStatus.ACTIVE),
null,
null,
null,
null,
false
));
// then
assertThat(result).hasSize(1);
assertThat(result.getFirst().tenantId()).isEqualTo(tenantA);
assertThat(result.getFirst().status()).isEqualTo(CaseStatus.ACTIVE);
}
Test boundary:
- empty status list;
- null optional filter;
- required tenant missing;
- from > to;
- keyword too short;
- unauthorized scope;
- pagination stable;
- sorting tie-breaker;
- count matches content semantics;
- join collection does not duplicate root unexpectedly.
Gunakan database realistis via Testcontainers untuk query penting. H2/in-memory sering tidak cukup untuk query plan, dialect function, JSON type, locking, dan pagination behavior.
31. Observability untuk Dynamic Query
Untuk query dinamis, observability wajib.
Checklist:
- log SQL di test/integration environment;
- log bind parameter secara aman;
- capture query count per request;
- buat budget query untuk endpoint search;
- catat page size dan filter combination;
- monitor slow query dari database;
- simpan explain plan untuk query kritikal;
- alert jika endpoint search melewati latency budget.
Jangan debug dynamic query hanya dari kode Criteria. Lihat SQL yang benar-benar keluar.
32. Performance Heuristics
| Pattern | Risiko | Mitigasi |
|---|---|---|
| Banyak optional filter | Query plan tidak stabil | Query budget, index strategy, representative tests |
| OR keyword multi-column | Full scan | Full-text index/search engine |
| Join collection | Duplicate root, pagination trap | Exists subquery, two-step id query |
| Dynamic sort semua field | Sort mahal | Whitelist sort key |
| Specification dengan fetch | Count query rusak | Pisahkan fetch plan |
| DTO constructor panjang | Fragile constructor order | Record DTO + test query |
| Count query kompleks | Latency tinggi | Approx count, slice, keyset, delayed count |
| Bulk update | Persistence context stale | Clear context, isolate transaction |
33. Design Checklist
Sebelum memilih Criteria/Specification, jawab:
- Apakah query statis? Jika ya, JPQL mungkin lebih readable.
- Apakah dynamic hanya optional filter sederhana? Specification cukup.
- Apakah query butuh projection khusus? Pertimbangkan custom query object.
- Apakah query butuh join collection? Cek duplicate/pagination risk.
- Apakah sort berasal dari user? Whitelist.
- Apakah filter mandatory untuk security? Jangan silent no-op.
- Apakah count query sama mahalnya dengan content query? Pertimbangkan
Slice/keyset. - Apakah result akan dimodifikasi? Jika tidak, DTO/read model.
- Apakah query bergantung provider-specific function? Bungkus secara eksplisit.
- Apakah test memakai data cukup realistis?
34. Mini Case Study: Case Search Endpoint
Requirement:
- tenant isolated;
- user biasa hanya melihat case assigned atau unit-nya;
- supervisor melihat semua case tenant;
- filter status, assignee, created range, keyword;
- sort hanya
createdAt,priority,caseNumber; - return DTO summary;
- pagination stable;
- count query harus benar;
- tidak boleh fetch entity graph penuh.
Desain:
DTO:
public record CaseSummary(
UUID id,
String caseNumber,
CaseStatus status,
CasePriority priority,
String assigneeName,
Instant createdAt
) {}
Content query shape:
CriteriaQuery<CaseSummary> query = cb.createQuery(CaseSummary.class);
Root<CaseRecord> root = query.from(CaseRecord.class);
Join<CaseRecord, Officer> assignee = root.join("assignee", JoinType.LEFT);
query.select(cb.construct(
CaseSummary.class,
root.get("id"),
root.get("caseNumber"),
root.get("status"),
root.get("priority"),
assignee.get("name"),
root.get("createdAt")
))
.where(buildPredicates(filter, scope, cb, root, query).toArray(Predicate[]::new))
.orderBy(toOrders(pageRequest.sort(), cb, root));
Count query shape:
CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);
Root<CaseRecord> countRoot = countQuery.from(CaseRecord.class);
countQuery.select(cb.count(countRoot))
.where(buildPredicates(filter, scope, cb, countRoot, countQuery).toArray(Predicate[]::new));
Key decision:
- content query memakai join ke
assigneekarena projection butuhassigneeName; - count query tidak join
assigneekecuali filter butuh field assignee; - predicate builder harus bisa membuat join hanya ketika diperlukan;
- sort key whitelist;
- result DTO, bukan managed entity.
35. Deliberate Practice
Latihan 1 — JPQL ke Criteria:
Ubah query ini ke Criteria:
select c
from CaseRecord c
where c.tenantId = :tenantId
and c.status in :statuses
and c.createdAt >= :from
order by c.createdAt desc, c.id desc
Latihan 2 — Specification fragments:
Buat specification:
belongsToTenant(UUID tenantId);hasAnyStatus(Set<CaseStatus> statuses);assignedTo(UUID officerId);createdBetween(Instant from, Instant to);keyword(String keyword).
Latihan 3 — Count query correctness:
Buat content query dengan join ke child collection, lalu buat count query yang tidak duplicate root.
Latihan 4 — Security predicate:
Tambahkan visibleTo(CaseAccessScope scope) dan pastikan tenant predicate selalu ada.
Latihan 5 — Query plan review:
Jalankan query dengan data minimal 100k rows. Bandingkan performa:
- exact status filter;
- keyword contains search;
- join collection filter;
- exists subquery filter;
- sort by indexed vs non-indexed field.
36. Summary
Criteria API berguna ketika query harus dibangun secara programmatic. Tetapi Criteria bukan pengganti desain query.
Prinsip utama:
- Criteria adalah query tree builder;
- Specification adalah predicate composition tool;
- filter normalization harus terpisah dari query building;
- security predicate wajib eksplisit;
- fetch plan jangan disembunyikan di arbitrary specification;
- dynamic sort harus whitelist;
- count query harus dibangun ulang dengan root sendiri;
- DTO projection sering lebih tepat untuk list screen;
- join collection dan pagination adalah kombinasi berisiko;
- SQL generated tetap harus dibaca dan diuji.
Part berikutnya membahas projection, DTO, dan read model secara lebih dalam: kapan entity adalah pilihan yang salah untuk read path, bagaimana membuat query result yang ringan, dan bagaimana menjaga API contract tidak bocor dari persistence model.
You just completed lesson 15 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.