Learn Java Persistence Part 027 Spring Data Jpa And Repository Abstractions
title: Learn Java Persistence, Database Integration, JPA, Hibernate ORM & EclipseLink - Part 027 description: Spring Data JPA as a repository abstraction: what it gives, what it hides, how query derivation, specifications, projections, transactions, auditing, locking, pagination, and custom repositories should be used in production-grade persistence design. series: learn-java-persistence seriesTitle: Learn Java Persistence, Database Integration, JPA, Hibernate ORM & EclipseLink order: 27 partTitle: Spring Data JPA and Repository Abstractions tags:
- java
- jakarta-persistence
- jpa
- spring-data-jpa
- repository
- specification
- projections
- pagination
- transaction
- architecture
- advanced
- series date: 2026-06-27
Part 027 — Spring Data JPA and Repository Abstractions
Target: setelah membaca part ini, kamu bisa memakai Spring Data JPA sebagai productivity layer tanpa kehilangan kontrol atas transaction boundary, query semantics, fetch plan, aggregate boundary, provider behavior, dan database performance.
Spring Data JPA bukan ORM baru. Ia adalah abstraction layer di atas Jakarta Persistence provider seperti Hibernate atau EclipseLink. Ia sangat produktif karena membuat repository interface, query method, pagination, projection, auditing, dan custom implementation menjadi konsisten.
Tetapi abstraction yang produktif bisa menjadi berbahaya jika developer lupa bahwa di bawahnya tetap ada:
- persistence context,
- flush,
- dirty checking,
- JPQL/SQL,
- transaction,
- lock,
- fetch plan,
- provider-specific behavior,
- database constraint.
Prinsip utama part ini:
Spring Data JPA boleh menyederhanakan kode akses data, tetapi tidak boleh menyembunyikan desain persistence.
1. Mental Model: Spring Data JPA sebagai Adapter, Bukan Domain Layer
Spring Data JPA berada di antara application service dan Jakarta Persistence provider.
Jadi ketika kamu menulis:
caseRepository.findByStatus(CaseStatus.UNDER_REVIEW);
itu bukan “method biasa”. Itu adalah deklarasi query yang akan diterjemahkan menjadi operasi persistence.
Lapisan yang terlibat:
| Layer | Tanggung Jawab |
|---|---|
| Repository interface | Kontrak akses data yang dilihat application service |
| Spring Data proxy | Membuat implementasi runtime untuk method repository |
| Query derivation / declared query | Menentukan JPQL/SQL yang dieksekusi |
EntityManager | Mengelola persistence context, query, flush, lifecycle |
| Provider | Menerjemahkan entity/query menjadi SQL |
| Database | Menjamin constraint, lock, isolation, index, durability |
Failure mode terjadi ketika developer memperlakukan repository method seperti collection in-memory.
Repository method bukan:
filter Java List
Repository method adalah:
query contract + transaction participation + flush interaction + fetch behavior + database execution plan
2. What Spring Data JPA Actually Provides
Spring Data JPA menyediakan repository support untuk Jakarta Persistence API. Secara praktis, ia menyediakan:
- repository interface implementation,
- CRUD method,
- pagination dan sorting,
- query method derivation,
@Queryuntuk JPQL/native SQL,- projection,
- specification,
- query by example,
- auditing,
- locking metadata,
- custom repository fragment,
- integration dengan Spring transaction management.
Tetapi Spring Data JPA tidak menghilangkan kebutuhan memahami JPA.
Ia tidak otomatis menyelesaikan:
- N+1 query,
- wrong aggregate boundary,
- wrong cascade,
- detached entity bugs,
- over-fetching,
- under-fetching,
- bad indexing,
- race condition,
- stale cache,
- schema drift.
Jangan menjadikan Spring Data JPA sebagai alasan untuk berhenti membaca SQL log.
3. Repository Interface Hierarchy
Spring Data menyediakan beberapa level repository abstraction.
Secara desain:
| Interface | Kapan Dipakai |
|---|---|
Repository<T, ID> | Ketika ingin expose method sangat terbatas |
CrudRepository<T, ID> | CRUD dasar, tetapi hati-hati terlalu banyak method terbuka |
ListCrudRepository<T, ID> | Varian yang mengembalikan List untuk beberapa operasi |
PagingAndSortingRepository<T, ID> | Query page/sort sederhana |
JpaRepository<T, ID> | Fitur JPA-specific seperti flush, batch delete, references |
JpaSpecificationExecutor<T> | Dynamic criteria query composition |
Untuk production system, default terbaik sering bukan “pakai JpaRepository untuk semua entity”, melainkan:
public interface CaseRepository extends Repository<EnforcementCase, CaseId> {
Optional<EnforcementCase> findById(CaseId id);
boolean existsByReferenceNo(CaseReferenceNo referenceNo);
@Query("""
select c
from EnforcementCase c
where c.status = :status
and c.assignedTeamId = :teamId
order by c.priority desc, c.createdAt asc
""")
List<EnforcementCase> findQueue(
@Param("status") CaseStatus status,
@Param("teamId") TeamId teamId,
Pageable pageable
);
EnforcementCase save(EnforcementCase entity);
}
Kenapa membatasi method?
Karena repository adalah contract. Jika semua caller bisa memanggil deleteAll, findAll, flush, atau saveAndFlush, kamu kehilangan governance.
Top 1% persistence engineer tidak hanya bertanya “bisa dipanggil atau tidak?”. Mereka bertanya:
Apakah method ini boleh menjadi bagian dari domain persistence contract?
4. Repository Bukan Pengganti Aggregate Boundary
Kesalahan umum:
interface CaseNoteRepository extends JpaRepository<CaseNote, Long> {}
interface CaseAttachmentRepository extends JpaRepository<CaseAttachment, Long> {}
interface CaseStatusHistoryRepository extends JpaRepository<CaseStatusHistory, Long> {}
interface EnforcementCaseRepository extends JpaRepository<EnforcementCase, Long> {}
Jika CaseNote, CaseAttachment, dan CaseStatusHistory adalah child dalam aggregate EnforcementCase, repository publik untuk semuanya bisa merusak invariant.
Contoh invariant:
Case note hanya boleh ditambah jika case belum CLOSED.
Status history harus selalu dibuat ketika case status berubah.
Attachment classified harus melewati virus scan dan access policy.
Jika child repository bisa dipanggil langsung, application service lain bisa bypass root invariant.
Desain yang lebih sehat:
public interface EnforcementCaseRepository extends Repository<EnforcementCase, CaseId> {
Optional<EnforcementCase> findById(CaseId id);
EnforcementCase save(EnforcementCase enforcementCase);
@Query("""
select c
from EnforcementCase c
where c.status = :status
order by c.createdAt asc
""")
Page<CaseQueueRow> findQueueRows(
@Param("status") CaseStatus status,
Pageable pageable
);
}
Child mutation dilakukan melalui aggregate root:
@Transactional
public void addNote(CaseId caseId, AddNoteCommand command) {
EnforcementCase c = caseRepository.findById(caseId)
.orElseThrow(() -> new CaseNotFoundException(caseId));
c.addNote(command.authorId(), command.body(), clock.instant());
// save() optional from pure JPA dirty checking perspective
// but explicit save keeps repository abstraction consistent
caseRepository.save(c);
}
Rule:
Repository publik sebaiknya mengikuti aggregate root, bukan setiap table.
5. save() Is Not Always Insert, Not Always Update
Spring Data JPA save() terlihat sederhana:
T saved = repository.save(entity);
Namun semantiknya bergantung pada apakah entity dianggap new atau existing.
Secara konsep, implementasi repository JPA akan memilih persist atau merge berdasarkan strategi deteksi new entity.
Konsekuensinya:
| Kondisi | Kemungkinan Operasi | Risiko |
|---|---|---|
| Entity baru tanpa id | persist | id generated setelah persist/flush tergantung strategy |
| Entity dengan id assigned | bisa dianggap existing | salah merge jika deteksi new buruk |
| Detached entity | merge | return value managed, argument tetap detached |
| Managed entity | tidak perlu save dari perspektif JPA | save bisa membingungkan intent |
Bug klasik:
EnforcementCase detached = request.toEntity();
caseRepository.save(detached);
detached.escalate(); // BUG: detached object may not be managed result
Lebih aman:
EnforcementCase managed = caseRepository.save(detached);
managed.escalate(...);
Tetapi desain yang lebih baik untuk update command adalah load-then-mutate:
@Transactional
public void escalate(CaseId caseId, EscalateCaseCommand command) {
EnforcementCase c = caseRepository.findById(caseId)
.orElseThrow(() -> new CaseNotFoundException(caseId));
c.escalate(command.reason(), command.actorId(), clock.instant());
}
Kenapa?
Karena update business command bukan “replace row”. Update business command adalah “mutate aggregate yang sudah ada sambil menjaga invariant”.
6. Query Derivation: Useful, But Keep It Boring
Spring Data JPA bisa membuat query dari nama method:
List<EnforcementCase> findByStatusAndAssignedTeamIdOrderByCreatedAtAsc(
CaseStatus status,
TeamId teamId
);
Ini bagus untuk query sederhana.
Namun query derivation menjadi buruk ketika nama method berubah menjadi DSL panjang:
findByStatusInAndAssignedTeamIdAndPriorityGreaterThanAndCreatedAtBeforeAndClosedAtIsNullOrderByPriorityDescCreatedAtAsc(...)
Rule praktis:
| Query | Teknik |
|---|---|
| 1–2 predicate sederhana | Derived method |
| Query domain penting | @Query dengan JPQL eksplisit |
| Query dinamis banyak filter | Specification / Criteria builder |
| Query read-heavy shape khusus | Projection / native SQL / view |
| Query performa kritikal | Explicit JPQL/native SQL + execution plan test |
Derived query cocok untuk:
boolean existsByReferenceNo(CaseReferenceNo referenceNo);
Optional<EnforcementCase> findByReferenceNo(CaseReferenceNo referenceNo);
List<EnforcementCase> findTop50ByStatusOrderByCreatedAtAsc(CaseStatus status);
Derived query kurang cocok untuk:
- complex join,
- conditional filter,
- query dengan fetch plan eksplisit,
- query yang harus dioptimasi dengan index tertentu,
- query yang punya meaning bisnis penting.
7. Declared JPQL with @Query
Untuk query yang punya makna bisnis, gunakan @Query agar intent terlihat.
public interface EnforcementCaseRepository extends Repository<EnforcementCase, CaseId> {
@Query("""
select c
from EnforcementCase c
join c.assignedTeam t
where c.status = :status
and t.id = :teamId
and c.riskScore >= :minimumRiskScore
order by c.riskScore desc, c.createdAt asc
""")
Page<EnforcementCase> findHighRiskQueue(
@Param("status") CaseStatus status,
@Param("teamId") TeamId teamId,
@Param("minimumRiskScore") int minimumRiskScore,
Pageable pageable
);
}
Keuntungan:
- query explicit,
- lebih mudah direview,
- lebih mudah dites,
- lebih mudah dikaitkan ke execution plan,
- menghindari method name yang terlalu panjang.
Risiko:
- tetap string-based,
- refactor field tidak selalu aman,
- count query untuk pagination kompleks bisa salah/mahal,
- fetch join dengan paging berbahaya,
- projection harus dijaga kompatibilitasnya.
Untuk paging dengan query kompleks, sering lebih baik tulis countQuery eksplisit.
@Query(
value = """
select c
from EnforcementCase c
where c.status = :status
and c.createdAt < :before
order by c.createdAt asc
""",
countQuery = """
select count(c)
from EnforcementCase c
where c.status = :status
and c.createdAt < :before
"""
)
Page<EnforcementCase> findBacklog(
@Param("status") CaseStatus status,
@Param("before") Instant before,
Pageable pageable
);
8. Native Query in Spring Data JPA
Native query boleh dipakai, tapi jangan menjadi default.
@Query(
value = """
select
c.id as caseId,
c.reference_no as referenceNo,
c.status as status,
c.risk_score as riskScore,
count(a.id) as openAlertCount
from enforcement_case c
left join alert a on a.case_id = c.id and a.status = 'OPEN'
where c.assigned_team_id = :teamId
group by c.id, c.reference_no, c.status, c.risk_score
order by c.risk_score desc
""",
nativeQuery = true
)
List<CaseQueueNativeRow> findQueueNative(@Param("teamId") UUID teamId);
Gunakan native SQL ketika:
- JPQL tidak mampu mengekspresikan query dengan jelas,
- membutuhkan database-specific function,
- butuh window function/CTE kompleks,
- query read model lebih dekat ke SQL daripada entity graph,
- performance plan harus sangat eksplisit.
Jangan gunakan native SQL untuk menutupi mapping yang buruk.
Decision rule:
JPQL first for entity-centric queries.
Projection/native SQL for read models.
Database-native feature for correctness/performance only when justified.
9. Projections: Jangan Selalu Load Entity
Spring Data JPA mendukung projection. Ini penting untuk read endpoint, list screen, report, dashboard, dan queue.
Masalah umum:
Page<EnforcementCase> page = caseRepository.findByStatus(UNDER_REVIEW, pageable);
return page.map(CaseListResponse::fromEntity);
Jika response hanya butuh 8 kolom, load entity penuh bisa menyebabkan:
- over-fetching,
- lazy initialization risk,
- accidental N+1,
- serialization leak,
- persistence context bloat.
Gunakan projection:
public record CaseQueueRow(
UUID id,
String referenceNo,
CaseStatus status,
int riskScore,
Instant createdAt
) {}
JPQL constructor projection:
@Query("""
select new com.acme.caseapp.CaseQueueRow(
c.id,
c.referenceNo,
c.status,
c.riskScore,
c.createdAt
)
from EnforcementCase c
where c.status = :status
order by c.riskScore desc, c.createdAt asc
""")
Page<CaseQueueRow> findQueueRows(
@Param("status") CaseStatus status,
Pageable pageable
);
Projection choice:
| Projection | Cocok Untuk | Risiko |
|---|---|---|
| Entity | command/update use case | over-fetching untuk read API |
| Interface projection | simple read model | runtime proxy, nested projection can trigger joins |
| Record/class DTO | explicit response model | constructor coupling |
| Native projection | SQL-heavy read model | alias/type mapping risk |
| Database view | stable reporting/read model | migration/versioning overhead |
Rule:
Command path load aggregate. Query path return projection.
10. Pagination: Offset Is Not Always Enough
Spring Data membuat paging mudah:
Page<CaseQueueRow> page = repository.findQueueRows(status, PageRequest.of(0, 50));
Tetapi pagination punya konsekuensi.
Page<T> biasanya butuh count query.
select data rows
select count(...)
Untuk list screen kecil, ini baik. Untuk table besar dengan filter kompleks, count query bisa sangat mahal.
Alternatif:
| Return Type | Arti |
|---|---|
Page<T> | Data + total count |
Slice<T> | Data + apakah ada next page |
List<T> | Data saja |
| keyset/cursor model | Stable pagination untuk dataset besar |
Untuk queue regulatory yang terus berubah, offset pagination bisa tidak stabil:
Page 1 loaded at 10:00:00
New high priority case inserted at 10:00:01
Page 2 loaded at 10:00:02
Rows can shift
Keyset pagination lebih stabil:
@Query("""
select new com.acme.caseapp.CaseQueueRow(
c.id, c.referenceNo, c.status, c.riskScore, c.createdAt
)
from EnforcementCase c
where c.status = :status
and (
c.riskScore < :lastRiskScore
or (c.riskScore = :lastRiskScore and c.createdAt > :lastCreatedAt)
or (c.riskScore = :lastRiskScore and c.createdAt = :lastCreatedAt and c.id > :lastId)
)
order by c.riskScore desc, c.createdAt asc, c.id asc
""")
List<CaseQueueRow> findNextQueuePage(
@Param("status") CaseStatus status,
@Param("lastRiskScore") int lastRiskScore,
@Param("lastCreatedAt") Instant lastCreatedAt,
@Param("lastId") UUID lastId,
Pageable limit
);
Key invariant:
Ordering untuk pagination harus deterministic.
Jangan order hanya berdasarkan kolom non-unique seperti createdAt.
Tambahkan tie-breaker seperti id.
11. Sorting: Whitelist, Jangan Terima Field Mentah
Anti-pattern:
@GetMapping("/cases")
Page<CaseQueueRow> search(@RequestParam String sort) {
return repository.search(PageRequest.of(0, 50, Sort.by(sort)));
}
Masalah:
- expose internal field name,
- field mungkin bukan indexed,
- nested property bisa menyebabkan join tak terduga,
- sort tidak sesuai domain semantics,
- raw sort menjadi contract API yang sulit diubah.
Lebih baik:
public enum CaseQueueSort {
RISK_DESC,
OLDEST_FIRST,
DEADLINE_ASC
}
public Sort toSort(CaseQueueSort sort) {
return switch (sort) {
case RISK_DESC -> Sort.by(
Sort.Order.desc("riskScore"),
Sort.Order.asc("createdAt"),
Sort.Order.asc("id")
);
case OLDEST_FIRST -> Sort.by(
Sort.Order.asc("createdAt"),
Sort.Order.asc("id")
);
case DEADLINE_ASC -> Sort.by(
Sort.Order.asc("dueAt"),
Sort.Order.asc("id")
);
};
}
Sort bukan UI detail. Sort adalah database execution contract.
12. Specification Pattern in Spring Data JPA
JpaSpecificationExecutor memungkinkan dynamic query composition di atas Criteria API.
public interface EnforcementCaseRepository
extends Repository<EnforcementCase, CaseId>,
JpaSpecificationExecutor<EnforcementCase> {
EnforcementCase save(EnforcementCase entity);
}
Specification:
public final class CaseSpecifications {
private CaseSpecifications() {}
public static Specification<EnforcementCase> hasStatus(CaseStatus status) {
return (root, query, cb) -> status == null
? cb.conjunction()
: cb.equal(root.get("status"), status);
}
public static Specification<EnforcementCase> assignedToTeam(UUID teamId) {
return (root, query, cb) -> teamId == null
? cb.conjunction()
: cb.equal(root.get("assignedTeamId"), teamId);
}
public static Specification<EnforcementCase> createdBetween(
Instant from,
Instant to
) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (from != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), from));
}
if (to != null) {
predicates.add(cb.lessThan(root.get("createdAt"), to));
}
return cb.and(predicates.toArray(Predicate[]::new));
};
}
}
Composition:
Specification<EnforcementCase> spec = Specification
.where(CaseSpecifications.hasStatus(filter.status()))
.and(CaseSpecifications.assignedToTeam(filter.teamId()))
.and(CaseSpecifications.createdBetween(filter.from(), filter.to()));
Page<EnforcementCase> result = repository.findAll(spec, pageable);
Specification cocok untuk:
- search screen,
- admin filtering,
- optional predicates,
- reusable predicates,
- controlled dynamic query.
Specification kurang cocok untuk:
- highly optimized reporting query,
- query yang butuh window function,
- query dengan fetch plan kompleks,
- projection-first read model jika API repository tidak mendukung dengan jelas,
- query yang harus sangat stabil dan direview sebagai SQL plan.
13. Specification Governance
Specification bisa menjadi anti-pattern jika semua predicate tersebar tanpa kontrol.
Anti-pattern:
Specification<EnforcementCase> spec = (root, query, cb) -> {
// 200 lines of dynamic branching
};
Lebih baik pisahkan tiga layer:
API Filter DTO
↓ validate + normalize
Domain Query Object
↓ convert
Specification Builder
↓ execute
Repository
Contoh:
public record CaseSearchCriteria(
Optional<CaseStatus> status,
Optional<UUID> teamId,
Optional<Instant> createdFrom,
Optional<Instant> createdTo,
CaseQueueSort sort
) {
public CaseSearchCriteria {
if (createdFrom.isPresent() && createdTo.isPresent()
&& !createdFrom.get().isBefore(createdTo.get())) {
throw new IllegalArgumentException("createdFrom must be before createdTo");
}
}
}
Repository sebaiknya tidak menerima request DTO mentah.
public Page<CaseQueueRow> search(CaseSearchCriteria criteria, Pageable pageable) {
Specification<EnforcementCase> spec = CaseSpecBuilder.from(criteria);
return repository.findAll(spec, pageable).map(CaseQueueRow::fromEntity);
}
Namun hati-hati: .map(CaseQueueRow::fromEntity) tetap load entity. Untuk read-heavy endpoint, custom repository dengan Criteria projection bisa lebih tepat.
14. Entity Graph in Spring Data JPA
Spring Data JPA mendukung entity graph melalui annotation repository method.
@EntityGraph(attributePaths = {"assignedOfficer", "classification"})
Optional<EnforcementCase> findDetailedById(CaseId id);
Gunakan untuk use case yang membutuhkan entity root dengan association tertentu.
Jangan gunakan satu findById global yang selalu fetch semua relasi.
Lebih baik:
Optional<EnforcementCase> findById(CaseId id);
@EntityGraph(attributePaths = {"notes", "attachments"})
@Query("select c from EnforcementCase c where c.id = :id")
Optional<EnforcementCase> findForCaseFileReview(@Param("id") CaseId id);
@EntityGraph(attributePaths = {"assignedOfficer", "riskAssessment"})
@Query("select c from EnforcementCase c where c.id = :id")
Optional<EnforcementCase> findForEscalationDecision(@Param("id") CaseId id);
Fetch plan harus dinamai berdasarkan use case, bukan struktur teknis.
Buruk:
findWithNotesAndAttachmentsAndOfficerAndTeamById
Lebih baik:
findForCaseFileReview
findForEscalationDecision
findForSupervisorDashboard
15. Locking Metadata
Spring Data JPA memungkinkan lock mode pada query method.
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("select c from EnforcementCase c where c.id = :id")
Optional<EnforcementCase> findForEscalationUpdate(@Param("id") CaseId id);
Pessimistic example:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
})
@Query("select q from WorkQueueItem q where q.id = :id")
Optional<WorkQueueItem> findForClaim(@Param("id") UUID id);
Review question:
- Apakah lock benar-benar diperlukan?
- Apakah lock mode sesuai failure mode?
- Apakah timeout dikontrol?
- Apakah index mendukung lock query?
- Apakah retry policy ada?
- Apakah deadlock dimonitor?
Jangan menambahkan @Lock karena “takut race condition”. Lock harus menjawab conflict scenario spesifik.
16. @Modifying: Bulk Update/Delete Bukan Entity Mutation
Spring Data JPA memakai @Modifying untuk query update/delete.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update EnforcementCase c
set c.status = :newStatus,
c.updatedAt = :now
where c.status = :oldStatus
and c.createdAt < :cutoff
""")
int expireOldCases(
@Param("oldStatus") CaseStatus oldStatus,
@Param("newStatus") CaseStatus newStatus,
@Param("cutoff") Instant cutoff,
@Param("now") Instant now
);
Bulk update bypasses normal entity lifecycle semantics:
- dirty checking tidak dipakai,
- entity callbacks bisa tidak berjalan seperti mutation biasa,
- persistence context bisa stale,
- optimistic version bisa tidak otomatis naik kecuali query mengubahnya,
- domain invariant bisa dibypass.
Gunakan bulk operation untuk:
- maintenance job,
- administrative batch,
- state transition massal yang sederhana,
- expiry/cleanup,
- performance-sensitive update yang sudah diproteksi constraint/test.
Jangan gunakan bulk update untuk command domain yang kompleks.
17. Transaction Boundary: Service First, Repository Second
Spring Data JPA punya transactional defaults. Tetapi boundary terbaik biasanya berada di application service.
@Service
public class CaseEscalationService {
private final EnforcementCaseRepository caseRepository;
private final OutboxRepository outboxRepository;
private final Clock clock;
@Transactional
public void escalate(CaseId caseId, EscalateCaseCommand command) {
EnforcementCase c = caseRepository.findForEscalationDecision(caseId)
.orElseThrow(() -> new CaseNotFoundException(caseId));
CaseEscalated event = c.escalate(
command.reason(),
command.actorId(),
clock.instant()
);
outboxRepository.save(OutboxMessage.from(event));
}
}
Kenapa service-level transaction?
Karena unit of work biasanya mencakup lebih dari satu repository call:
- load aggregate,
- validate invariant,
- mutate state,
- append history,
- write outbox,
- maybe update queue row,
- commit atomically.
Jika transaction hanya di repository method, kamu bisa punya beberapa persistence context/transaction yang tidak membentuk satu consistency boundary.
Rule:
Repository adalah persistence operation boundary. Service adalah business unit-of-work boundary.
18. Read-Only Transaction Is a Hint, Not a Guardrail
@Transactional(readOnly = true) berguna untuk query.
@Transactional(readOnly = true)
public Page<CaseQueueRow> search(CaseSearchCriteria criteria, Pageable pageable) {
return caseReadRepository.search(criteria, pageable);
}
Namun jangan mengira read-only transaction otomatis mencegah semua write di semua database/provider configuration.
Gunakan read-only sebagai:
- intent signal,
- provider optimization hint,
- driver/database hint jika didukung,
- review marker.
Jangan gunakan read-only sebagai security policy.
Security dan write permission harus dikontrol di application authorization dan database privilege/constraint bila perlu.
19. Custom Repository Fragment
Ketika query terlalu kompleks untuk derived method/@Query/Specification, buat custom repository fragment.
Interface:
public interface CaseSearchRepository {
Page<CaseQueueRow> search(CaseSearchCriteria criteria, Pageable pageable);
}
Main repository:
public interface EnforcementCaseRepository
extends Repository<EnforcementCase, CaseId>, CaseSearchRepository {
Optional<EnforcementCase> findById(CaseId id);
EnforcementCase save(EnforcementCase c);
}
Implementation:
@Repository
class CaseSearchRepositoryImpl implements CaseSearchRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public Page<CaseQueueRow> search(CaseSearchCriteria criteria, Pageable pageable) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<CaseQueueRow> query = cb.createQuery(CaseQueueRow.class);
Root<EnforcementCase> root = query.from(EnforcementCase.class);
List<Predicate> predicates = new ArrayList<>();
criteria.status().ifPresent(status ->
predicates.add(cb.equal(root.get("status"), status))
);
criteria.teamId().ifPresent(teamId ->
predicates.add(cb.equal(root.get("assignedTeamId"), teamId))
);
query.select(cb.construct(
CaseQueueRow.class,
root.get("id"),
root.get("referenceNo"),
root.get("status"),
root.get("riskScore"),
root.get("createdAt")
));
query.where(predicates.toArray(Predicate[]::new));
query.orderBy(toOrders(criteria.sort(), root, cb));
List<CaseQueueRow> rows = entityManager.createQuery(query)
.setFirstResult((int) pageable.getOffset())
.setMaxResults(pageable.getPageSize())
.getResultList();
long total = count(criteria);
return new PageImpl<>(rows, pageable, total);
}
}
Ini memberi kontrol penuh atas:
- projection,
- predicates,
- count query,
- hints,
- fetch plan,
- lock mode,
- pagination.
Custom fragment adalah escape hatch yang lebih sehat daripada menjejalkan semua query ke nama method panjang.
20. Auditing: Useful Metadata, Not Domain History
Spring Data JPA auditing bisa mengisi metadata seperti:
- created by,
- created date,
- last modified by,
- last modified date.
Contoh:
@Entity
@EntityListeners(AuditingEntityListener.class)
public class EnforcementCase {
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@CreatedBy
private UserId createdBy;
@LastModifiedBy
private UserId updatedBy;
}
Auditing cocok untuk technical metadata.
Tidak cukup untuk regulatory defensibility.
Untuk enforcement lifecycle, kamu biasanya butuh explicit domain history:
@Entity
public class CaseStatusHistory {
@Id
private UUID id;
@ManyToOne(optional = false)
private EnforcementCase enforcementCase;
@Enumerated(EnumType.STRING)
private CaseStatus fromStatus;
@Enumerated(EnumType.STRING)
private CaseStatus toStatus;
private String reason;
private UserId actorId;
private Instant changedAt;
}
Perbedaannya:
| Auditing | Domain History |
|---|---|
| Technical metadata | Business/legal evidence |
| Last change only | Full lifecycle trail |
| Generic | Domain-specific reason/actor/context |
| Bisa otomatis | Harus intentional |
Rule:
Jangan mengganti audit trail domain dengan auditing annotation generik.
21. Domain Events from Aggregate Roots
Spring Data mendukung publikasi event dari aggregate root melalui mekanisme domain events. Ini berguna, tetapi harus dipakai dengan hati-hati.
Contoh conceptual aggregate:
public class EnforcementCase extends AbstractAggregateRoot<EnforcementCase> {
public void escalate(EscalationReason reason, UserId actorId, Instant now) {
if (status != CaseStatus.UNDER_REVIEW) {
throw new IllegalStateException("Only under-review case can be escalated");
}
status = CaseStatus.ESCALATED;
escalatedAt = now;
registerEvent(new CaseEscalated(id, reason, actorId, now));
}
}
Caution:
- domain event publication timing matters,
- transaction commit timing matters,
- listener failure semantics must be known,
- external side effects should usually use outbox,
- event should not become hidden persistence side effect.
Untuk integration event ke Kafka/SQS/RabbitMQ, gunakan outbox, bukan publish langsung dari transaction tanpa durability boundary.
22. Null Handling and Optional
Repository method harus jelas soal absence.
Good:
Optional<EnforcementCase> findByReferenceNo(CaseReferenceNo referenceNo);
boolean existsByReferenceNo(CaseReferenceNo referenceNo);
Less good:
EnforcementCase findByReferenceNo(String referenceNo); // null? exception? unique?
Rule:
Optional<T>untuk single result yang boleh tidak ada.List<T>untuk multi result; empty list lebih baik daripada null.- Throw domain exception di service, bukan repository.
- Jangan pakai
Optionaluntuk collection. - Unique expectation harus didukung database unique constraint.
23. Exception Translation
Spring menerjemahkan banyak persistence exception menjadi hierarchy DataAccessException.
Ini membantu portability di service layer, tetapi jangan kehilangan detail operasional.
Contoh service:
try {
caseRepository.save(c);
} catch (DataIntegrityViolationException ex) {
throw new DuplicateCaseReferenceException(c.referenceNo(), ex);
}
Jangan leak exception provider ke API.
Namun untuk observability, log root cause:
- SQL state,
- constraint name,
- lock timeout/deadlock,
- optimistic lock conflict,
- connection acquisition failure.
Exception translation bukan pengganti domain error mapping.
24. Repository Method Naming as Architecture Documentation
Nama method repository harus menjelaskan use case persistence.
Buruk:
findByIdWithAllRelations
getCaseData
query1
searchComplex
Lebih baik:
findForEscalationDecision
findForCaseFileReview
findQueueRowsForSupervisor
claimNextAssignableCase
existsOpenCaseForSubject
Nama method yang baik mengungkap:
- intent,
- consistency need,
- read/write path,
- fetch plan,
- lock expectation bila ada.
25. Anti-Patterns
25.1 Repository per Table
Jika semua table punya repository publik, aggregate invariant bocor.
25.2 JpaRepository Everywhere
Membuka terlalu banyak method mutasi tanpa governance.
25.3 save() as Update Command
Menerima entity dari request lalu save() berarti mengganti state tanpa invariant load.
25.4 Derived Query Name as Business Logic
Method name raksasa sulit direview dan rawan salah.
25.5 Entity Return for Every Read Endpoint
Over-fetching dan serialization leak.
25.6 Pageable Without Deterministic Sort
Pagination tidak stabil.
25.7 Dynamic Sort from Raw Request Field
Membocorkan internal model dan memicu query tidak terkontrol.
25.8 @Transactional Only on Repository
Unit of work bisnis bisa terpecah.
25.9 @Modifying for Domain State Transitions
Bulk update membypass aggregate invariant.
25.10 Hidden Fetch Plan
Method tampak sederhana tetapi diam-diam memicu query besar/N+1.
26. Production Repository Checklist
Gunakan checklist ini saat review repository.
| Area | Pertanyaan |
|---|---|
| Aggregate | Apakah repository hanya expose aggregate root? |
| Contract | Apakah method name menjelaskan use case? |
| Query | Apakah query sederhana, eksplisit, dan bisa direview? |
| Fetch | Apakah fetch plan jelas? |
| Projection | Apakah read endpoint perlu entity penuh? |
| Transaction | Apakah service mengontrol unit-of-work? |
| Locking | Apakah lock mode sesuai conflict scenario? |
| Pagination | Apakah ordering deterministic? |
| Sorting | Apakah sort whitelisted? |
| Bulk update | Apakah stale persistence context ditangani? |
| Exception | Apakah persistence exception dipetakan ke domain error? |
| Test | Apakah SQL/log/plan/query count diuji? |
27. Lab: Regulatory Case Queue Repository
Kita desain repository untuk case queue.
Requirement:
- supervisor melihat queue by status/team,
- rows harus ringan,
- sorting terbatas,
- update case harus lewat aggregate root,
- claim case harus concurrency-safe,
- audit domain harus explicit.
Entity root:
@Entity
@Table(name = "enforcement_case")
public class EnforcementCase {
@Id
private UUID id;
@Version
private long version;
@Column(nullable = false, unique = true)
private String referenceNo;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private CaseStatus status;
@Column(nullable = false)
private UUID assignedTeamId;
@Column(nullable = false)
private int riskScore;
@Column(nullable = false)
private Instant createdAt;
private UUID assignedOfficerId;
public void claimBy(UserId officerId, Instant now) {
if (status != CaseStatus.UNDER_REVIEW) {
throw new IllegalStateException("Only under-review case can be claimed");
}
if (assignedOfficerId != null) {
throw new IllegalStateException("Case already assigned");
}
assignedOfficerId = officerId.value();
status = CaseStatus.ASSIGNED;
}
}
Projection:
public record CaseQueueRow(
UUID id,
String referenceNo,
CaseStatus status,
UUID assignedTeamId,
int riskScore,
Instant createdAt
) {}
Repository:
public interface EnforcementCaseRepository
extends Repository<EnforcementCase, UUID>, CaseQueueSearchRepository {
Optional<EnforcementCase> findById(UUID id);
@Lock(LockModeType.OPTIMISTIC)
@Query("select c from EnforcementCase c where c.id = :id")
Optional<EnforcementCase> findForUpdate(@Param("id") UUID id);
boolean existsByReferenceNo(String referenceNo);
EnforcementCase save(EnforcementCase enforcementCase);
}
Custom search:
public interface CaseQueueSearchRepository {
Page<CaseQueueRow> searchQueue(CaseQueueCriteria criteria, Pageable pageable);
}
Service:
@Service
public class CaseClaimService {
private final EnforcementCaseRepository repository;
private final Clock clock;
@Transactional
public void claim(UUID caseId, UserId officerId) {
EnforcementCase c = repository.findForUpdate(caseId)
.orElseThrow(() -> new CaseNotFoundException(caseId));
c.claimBy(officerId, clock.instant());
}
}
Why this is healthy:
- command path loads aggregate,
- query path returns projection,
- transaction boundary is service-level,
- concurrency uses version/lock intentionally,
- repository does not expose child tables,
- queue read model can be optimized independently.
28. Testing Spring Data JPA Repositories
Repository tests harus memakai database realistis, bukan hanya H2 mode default, jika production DB berbeda.
Test focus:
- query correctness,
- mapping correctness,
- pagination stability,
- projection mapping,
- lock behavior where feasible,
- transaction semantics,
- bulk update stale context behavior,
- query count for performance-sensitive methods,
- database constraint behavior.
Example with @DataJpaTest conceptually:
@DataJpaTest
class EnforcementCaseRepositoryTest {
@Autowired
EnforcementCaseRepository repository;
@Autowired
TestEntityManager entityManager;
@Test
void finds_queue_rows_with_deterministic_order() {
// arrange fixture
// flush and clear to avoid false positives from persistence context
entityManager.flush();
entityManager.clear();
Page<CaseQueueRow> rows = repository.searchQueue(
new CaseQueueCriteria(CaseStatus.UNDER_REVIEW, teamId),
PageRequest.of(0, 20)
);
assertThat(rows.getContent())
.extracting(CaseQueueRow::referenceNo)
.containsExactly("CASE-001", "CASE-002");
}
}
Important:
Always flush and clear in repository tests when you want to verify database behavior, not persistence-context illusion.
29. Observability
For production-grade Spring Data JPA, observe:
- SQL statements,
- query count per request,
- slow query logs,
- execution plans for critical queries,
- connection acquisition time,
- transaction duration,
- optimistic lock failures,
- deadlocks and lock timeouts,
- batch job row counts,
- repository method latency.
Map logs to repository method names. If a method called findQueueRowsForSupervisor produces 300 SQL statements, the name becomes a diagnosis anchor.
30. Decision Matrix
| Problem | Prefer |
|---|---|
| Simple lookup by unique field | Derived query |
| Business-critical query | @Query JPQL |
| Optional filters | Specification/custom Criteria |
| Read-only API list | DTO projection |
| Complex report | Native SQL/view |
| Update aggregate | Load aggregate + mutate |
| Mass expiry | @Modifying bulk update with clear/flush control |
| Concurrency conflict | @Version + retry or explicit lock |
| Queue claim | Locking or atomic database operation, tested under contention |
| Audit metadata | Spring Data auditing |
| Legal/domain history | Explicit history entity/event |
31. Final Mental Model
Spring Data JPA gives you a concise persistence contract surface.
But correctness still comes from:
- aggregate boundary,
- explicit transaction boundary,
- query clarity,
- fetch plan control,
- database constraints,
- provider understanding,
- testing against real database behavior.
The best Spring Data JPA code looks boring:
- repository interfaces are small,
- method names express use cases,
- complex queries are explicit,
- read paths use projections,
- command paths load aggregates,
- dynamic filters are structured,
- transaction boundary lives in service,
- provider/database escape hatches are isolated.
If repository code looks magical, it is probably hiding operational risk.
32. Kaufman Practice Drill
Untuk menguasai part ini, lakukan drill berikut.
Drill 1 — Repository Surface Reduction
Ambil repository existing yang extends JpaRepository. Kurangi public method surface dengan mengganti ke Repository<T, ID> dan expose hanya method yang benar-benar dipakai.
Target:
- caller tidak bisa
deleteAll, - caller tidak bisa
findAlltanpa alasan, - method name menjadi use-case driven.
Drill 2 — Entity Read to Projection
Ambil satu endpoint list yang return entity. Ubah ke DTO/record projection.
Measure:
- jumlah SQL,
- kolom yang dipilih,
- latency,
- memory allocation,
- serialization output.
Drill 3 — Derived Query Refactor
Ambil method derived query paling panjang. Refactor menjadi @Query atau Specification.
Review:
- readability,
- testability,
- execution plan,
- count query.
Drill 4 — Transaction Boundary Audit
Cari service yang memanggil beberapa repository tanpa @Transactional. Tentukan apakah operasi tersebut harus atomic.
Drill 5 — Flush/Clear Repository Test
Tambahkan flush() dan clear() pada repository test untuk memastikan test tidak lulus karena first-level cache.
33. Ringkasan
Spring Data JPA adalah abstraction yang sangat berguna, tetapi bukan pengganti pemahaman JPA.
Gunakan Spring Data JPA untuk:
- memperjelas repository contract,
- mengurangi boilerplate,
- membangun query sederhana dengan cepat,
- menyediakan projection/pagination/specification,
- mengintegrasikan transaction dan auditing.
Jangan gunakan Spring Data JPA untuk menyembunyikan:
- aggregate boundary yang salah,
- query yang tidak direview,
- fetch plan yang tidak jelas,
- transaction boundary yang kabur,
- bulk update yang membypass invariant,
- performance problem yang seharusnya terlihat di SQL.
Pada level top 1%, pertanyaannya bukan “bisa pakai repository?”. Pertanyaannya:
Apakah repository ini menyatakan kontrak persistence yang benar, minimal, observable, testable, dan stabil terhadap perubahan domain?
References
You just completed lesson 27 in deepen practice. 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.