Repository Pattern Done Right
Learn Java Data Access Pattern In Action - Part 026
Repository pattern modern dalam Java: aggregate boundary, collection illusion, mutation semantics, persistence ignorance, DAO composition, transaction boundary, optimistic locking, query limitation, testing, dan anti-pattern generic repository.
Part 026 — Repository Pattern Done Right
Repository sering disalahpahami sebagai "DAO yang namanya lebih keren".
Itu salah.
Repository bukan wrapper CRUD generik. Repository adalah abstraction untuk mengambil dan menyimpan aggregate/domain object dengan boundary yang jelas.
DAO berbicara bahasa table/query.
Repository berbicara bahasa aggregate dan domain consistency.
Part ini membahas Repository Pattern yang benar dalam Java production system.
1. Core Thesis
Repository menjawab:
How does the application retrieve and persist domain aggregates?
Repository harus menyembunyikan detail persistence dari domain/application command, tetapi tidak menyembunyikan consistency semantics.
Repository yang baik:
- berorientasi aggregate;
- memiliki method sesuai use case/domain;
- tidak menjadi query dumping ground;
- tidak expose persistence entity ke API;
- menjaga optimistic version/save semantics;
- memakai DAO/ORM di bawah;
- berpartisipasi dalam use case transaction;
- tidak memulai workflow sendiri.
2. Repository vs DAO
| Aspect | DAO | Repository |
|---|---|---|
| Bahasa | SQL/table/row | domain/aggregate |
| Return | row/projection/entity persistence | aggregate/domain object |
| Scope | query/update spesifik | aggregate lifecycle |
| Mapping | ResultSet -> Row | Row/entity -> Domain |
| Error | SQL/data access error | domain persistence error |
| Query | boleh banyak projection | dibatasi aggregate boundary |
| Transaction | participates | participates |
| Use | repository/query service/batch | application command/domain use case |
DAO:
CaseFileRow findById(Connection c, TenantId tenantId, CaseFileId id);
int updateStatusWithVersion(Connection c, ...);
Repository:
Optional<CaseFile> findById(CaseFileId id);
void save(CaseFile caseFile);
3. Aggregate Boundary
Repository should usually be per aggregate root.
CaseFileRepository
OfficerRepository
SanctionReviewRepository
ApprovalProcessRepository
Aggregate root controls consistency boundary.
Example:
public interface CaseFileRepository {
Optional<CaseFile> findById(CaseFileId id);
Optional<CaseFile> findByIdForUpdate(CaseFileId id);
void save(CaseFile caseFile);
}
Avoid repository per table if domain aggregate spans multiple tables. That is DAO territory.
4. Collection Illusion
Repository often imitates collection:
Optional<CaseFile> findById(CaseFileId id);
void save(CaseFile caseFile);
void remove(CaseFile caseFile);
But the database is not a simple in-memory collection.
Repository method must still consider:
- transaction;
- isolation;
- version;
- lazy loading;
- partial load;
- concurrency conflict;
- tenant scope;
- deletion semantics;
- audit/outbox outside repository or coordinated by use case.
The collection illusion is useful but dangerous if taken literally.
5. Repository Should Not Be Generic CRUD
Bad:
interface Repository<T, ID> {
Optional<T> findById(ID id);
T save(T entity);
void delete(ID id);
List<T> findAll();
}
This erases domain semantics.
Better:
public interface CaseFileRepository {
Optional<CaseFile> loadForApproval(CaseFileId id);
Optional<CaseFile> loadForAssignment(CaseFileId id);
void saveApproved(CaseFile caseFile, ExpectedVersion expectedVersion);
void saveAssignmentChange(CaseFile caseFile, ExpectedVersion expectedVersion);
}
This may look less "clean", but it expresses real consistency needs.
A simple findById/save is okay for simple aggregate. But don't force all aggregates into generic CRUD.
6. Repository Method Should Reflect Load Intent
Different commands need different state.
Approval may need:
- case status;
- version;
- assigned reviewer;
- active sanctions;
- relevant policy snapshot.
Assignment may need:
- case status;
- current assignments;
- version.
Closure may need:
- active assignments;
- pending sanctions;
- document status.
Instead of one huge findById that loads everything, consider:
Optional<CaseFile> loadForApproval(CaseFileId id);
Optional<CaseFile> loadForAssignment(CaseFileId id);
Optional<CaseFile> loadForClosure(CaseFileId id);
This avoids:
- under-loading invariant;
- over-fetching huge graph;
- lazy loading surprises.
7. Partial Domain Object Is Dangerous
Do not return half-loaded aggregate that still has business methods.
Bad:
CaseFile caseFile = repository.findSummaryById(id);
caseFile.approve(...); // missing state needed for invariant
If object is partial, use a different type:
CaseFileSummary
CaseReference
CaseHeader
Aggregate object should be complete enough for its intended behavior.
8. Repository and Query Service Separation
Repository is not for every read query.
Dashboard/search/report should use query service/DAO/projection.
Bad:
caseFileRepository.findAllCasesForDashboard(filter, page);
caseFileRepository.exportQuarterlyRegulatoryReport(...);
caseFileRepository.searchByOfficerNameAndRiskLevel(...);
Better:
caseDashboardQuery.search(filter, page);
caseExportQuery.export(...);
caseSearchQuery.search(...);
Repository focuses on aggregate persistence.
Part 028/029 will go deeper on command-query separation and DTO projection.
9. Repository Implementation With DAO
public final class JdbcCaseFileRepository implements CaseFileRepository {
private final CurrentTenant currentTenant;
private final ConnectionProvider connectionProvider;
private final CaseFileDao caseFileDao;
private final CaseActionDao actionDao;
private final CaseFileMapper mapper;
@Override
public Optional<CaseFile> loadForApproval(CaseFileId id) {
Connection connection = connectionProvider.currentConnection();
TenantId tenantId = currentTenant.required();
Optional<CaseFileRow> row =
caseFileDao.findById(connection, tenantId, id);
if (row.isEmpty()) {
return Optional.empty();
}
List<CaseActionRow> actions =
actionDao.findRelevantForApproval(connection, tenantId, id);
return Optional.of(mapper.toDomainForApproval(row.get(), actions));
}
@Override
public void save(CaseFile caseFile) {
Connection connection = connectionProvider.currentConnection();
int updated = caseFileDao.updateWithVersion(
connection,
currentTenant.required(),
caseFile.id(),
caseFile.status(),
caseFile.version(),
caseFile.updatedAt()
);
if (updated == 0) {
throw new OptimisticConflict(caseFile.id(), caseFile.version());
}
}
}
Repository composes DAO and maps to domain.
10. Repository Implementation With JPA
public final class JpaCaseFileRepository implements CaseFileRepository {
private final EntityManager entityManager;
private final CaseFileEntityMapper mapper;
@Override
public Optional<CaseFile> findById(CaseFileId id) {
CaseFileEntity entity = entityManager.find(CaseFileEntity.class, id.value());
if (entity == null) {
return Optional.empty();
}
return Optional.of(mapper.toDomain(entity));
}
@Override
public void save(CaseFile caseFile) {
CaseFileEntity entity = entityManager.find(
CaseFileEntity.class,
caseFile.id().value()
);
if (entity == null) {
throw new AggregateNotFound(caseFile.id());
}
mapper.copyDomainToEntity(caseFile, entity);
}
}
With JPA, flush/commit triggers SQL and optimistic lock if @Version used.
Caveats:
- entity manager lifecycle;
- detached entity merge;
- lazy loading;
- flush timing;
- bulk update bypassing version;
- transaction required.
11. Persistence Ignorance
Domain object should not depend on JPA/JDBC.
Avoid in domain:
@Entity
public class CaseFile { ... }
This can be okay in simpler systems, but for complex domain, persistence annotations can influence design.
Better separation:
public final class CaseFile { ... } // domain
@Entity class CaseFileEntity { ... } // persistence
Trade-off:
- more mapping code;
- stronger domain purity;
- easier persistence changes;
- less lazy-loading leakage;
- more explicit aggregate construction.
Choose based on complexity.
12. Repository Save Semantics
save can mean many things. Be explicit.
Possible semantics:
- insert new aggregate;
- update existing aggregate;
- upsert;
- save dirty changes;
- replace state;
- persist new events;
- optimistic version update;
- cascade children;
- delete missing children.
Generic save may hide too much.
For critical aggregate, split:
void add(CaseFile newCase);
void save(CaseFile existingCase);
void remove(CaseFile caseFile);
Or command-specific:
void saveAfterApproval(CaseFile caseFile);
void saveAfterAssignmentChange(CaseFile caseFile);
13. Add vs Save
Add new aggregate:
public void add(CaseFile caseFile) {
caseFileDao.insert(connection, mapper.toInsertRow(caseFile));
}
Save existing aggregate:
public void save(CaseFile caseFile) {
caseFileDao.updateWithVersion(connection, mapper.toUpdateRow(caseFile));
}
Do not make save upsert silently unless domain wants that.
Upsert can hide duplicate create and stale update bugs.
14. Delete Semantics
Hard delete is rarely repository default for domain aggregate.
Options:
- soft delete;
- archive;
- status transition;
- tombstone;
- hard delete for technical data.
Repository method should reveal domain meaning:
void markClosed(CaseFile caseFile);
void archive(CaseFile caseFile);
void deleteDraft(CaseFile caseFile);
Avoid:
deleteById(id);
unless deletion is truly simple technical operation.
15. Repository and Optimistic Locking
Repository should protect aggregate update.
Domain:
public final class CaseFile {
private final CaseFileId id;
private final long version;
}
Repository update:
update case_file
set status = ?,
version = version + 1
where id = ?
and version = ?;
If no rows:
throw new OptimisticConflict(caseFile.id(), caseFile.version());
With JPA:
@Version
private long version;
Repository/application maps OptimisticLockException to domain conflict.
16. Repository and Pessimistic Locking
If repository has locking method, name it.
Optional<CaseFile> findByIdForUpdate(CaseFileId id);
or intent-based:
Optional<CaseFile> loadForAssignmentWithLock(CaseFileId id);
Document:
Locks aggregate root until transaction commit/rollback.
Caller must keep transaction short.
Avoid hidden pessimistic lock inside ordinary findById.
17. Repository and Aggregate Rehydration
Rehydration is loading existing state from persistence without firing creation behavior.
Domain:
public static CaseFile rehydrate(
CaseFileId id,
CaseNumber number,
CaseStatus status,
long version,
List<CaseAction> actions
) {
return new CaseFile(id, number, status, version, List.copyOf(actions));
}
Creation:
public static CaseFile openNew(CaseNumber number, UserId creator, Instant now) {
CaseFile caseFile = new CaseFile(...);
caseFile.recordEvent(new CaseOpened(...));
return caseFile;
}
Repository uses rehydrate, not openNew.
18. Repository and Domain Events
Domain may collect events:
caseFile.approve(...);
List<DomainEvent> events = caseFile.pullDomainEvents();
Repository should usually persist aggregate, not publish events.
Application service coordinates:
caseRepository.save(caseFile);
auditRepository.append(...);
outboxRepository.appendAll(events);
If repository automatically stores outbox events, document carefully. It can be convenient but may hide use case orchestration.
19. Repository Should Not Publish External Events Directly
Bad:
public void save(CaseFile caseFile) {
entityManager.persist(...);
kafka.send(...);
}
Repository should not call broker.
Use outbox in transaction, coordinated by application service or repository infrastructure with clear contract.
20. Repository and Audit
Should repository write audit?
Usually application service writes audit because audit needs actor/reason/command context.
Repository knows persistence state, not why user did action.
Bad:
repository.save(caseFile); // secretly writes audit with no reason
Better:
caseRepository.save(caseFile);
auditRepository.append(AuditRecord.from(command, caseFile));
For technical audit like entity version history, database trigger or repository infrastructure may work, but domain audit should be explicit.
21. Repository and Tenant Scope
Repository should enforce tenant context.
Options:
Explicit tenant parameter
Optional<CaseFile> findById(TenantId tenantId, CaseFileId id);
Tenant context injected
currentTenant.required()
Explicit is easier to review. Context is convenient.
Either way, SQL must include tenant predicate.
22. Repository and Authorization
Repository is not full authorization service, but can provide scoped methods.
Optional<CaseFile> findAssignableByUnit(
CaseFileId id,
UnitId unitId
);
Write predicate can enforce authorization race-safely.
Application service still owns business authorization decision.
23. Repository and Invariants
Domain object validates invariants in memory. Database enforces invariants with constraints/update predicates.
Repository bridges both.
Example:
caseFile.assignPrimaryOfficer(officerId);
caseRepository.save(caseFile);
Database unique constraint on active primary assignment still required if invariant can race.
Repository should not rely only on domain check if concurrent transactions can bypass.
24. Repository and Child Collections
Aggregate may have child collection.
Options for saving:
Full replacement
delete missing children
insert/update current children
Risky if collection large.
Change tracking in domain
Domain records added/removed child changes.
caseFile.pullChanges()
Repository persists deltas.
Dedicated child DAO operations
Use case coordinates child changes.
Choose based on complexity.
Avoid blindly deleting/reinserting huge child collection.
25. Save Deltas Example
Domain records changes:
public final class CaseFile {
private final List<CaseAssignment> assignments;
private final List<DomainChange> changes = new ArrayList<>();
public void assignOfficer(OfficerId officerId) {
CaseAssignment assignment = CaseAssignment.create(...);
assignments.add(assignment);
changes.add(new AssignmentAdded(assignment));
}
public List<DomainChange> pullChanges() {
List<DomainChange> copy = List.copyOf(changes);
changes.clear();
return copy;
}
}
Repository/application:
caseRepository.save(caseFile);
for (DomainChange change : caseFile.pullChanges()) {
changePersister.persist(change);
}
Be careful: pulling events/changes before transaction commit can lose them if commit fails unless object lifecycle is controlled.
26. Repository and Unit of Work
ORM provides unit of work/persistence context.
Manual JDBC repository may implement simple unit-of-work via application service transaction.
Do not build complex custom unit-of-work unless needed.
For many systems:
transaction boundary + repository save explicitly
is enough.
Part 031 will go deeper into Unit of Work.
27. Repository and Lazy Loading
Repository should not return aggregate that lazy-loads DB later unpredictably.
Bad:
caseFile.getAssignments() // triggers DB query
This hides I/O and transaction dependency.
Better:
- load required state explicitly;
- use separate query service for read;
- use intent-specific load method.
If using ORM lazy loading, be disciplined with transaction boundary and fetch plan.
28. Repository and N+1
Repository loading aggregate list can create N+1.
Bad:
List<CaseFile> cases = caseRepository.findOpenCases();
for (CaseFile c : cases) {
c.assignments().size(); // lazy load per case
}
Repository should not be used for large list read if only projection needed.
Use query service.
If loading multiple aggregates for command, use bulk load with explicit fetch strategy.
29. Repository Should Not Return Large Collections Casually
List<CaseFile> findAllOpenCases();
This can load huge object graph.
Prefer:
- query service projection;
- chunk reader;
- batch job DAO;
- bounded page;
- cursor.
Repository is for aggregates, not reports.
30. Repository and Specification
Repository can accept domain specification for aggregate queries, but beware complexity.
List<CaseFile> findMatching(CaseSpecification spec);
This can become generic query engine.
Use only if domain truly needs aggregate selection by business specification.
For UI filters/reporting, query service is usually better.
Part 027 covers Query Object and Specification.
31. Repository and Read Model
Do not force read model through repository.
Dashboard row:
public record CaseDashboardRow(...) {}
Query service:
CaseDashboardPage search(CaseDashboardFilter filter, PageRequest page);
This is not repository because it does not return aggregate.
This separation improves performance and avoids entity leak.
32. Repository and Transaction Boundary
Repository should join caller transaction.
Application service:
@Transactional
public ApproveCaseResult approve(ApproveCaseCommand command) {
CaseFile caseFile = caseRepository.loadForApproval(command.caseId())
.orElseThrow();
caseFile.approve(...);
caseRepository.save(caseFile);
auditRepository.append(...);
outboxRepository.append(...);
return result;
}
Repository should not commit independently.
33. Repository and REQUIRES_NEW
Repository method with REQUIRES_NEW is dangerous by default.
Example:
caseRepository.saveRequiresNew(caseFile);
auditRepository.append(audit); // fails
Case committed, audit missing.
Use REQUIRES_NEW only for explicit independent facts such as technical failure log, and name it accordingly.
34. Repository and Error Mapping
Repository maps persistence errors to domain/application-level persistence exceptions.
Examples:
- duplicate case number ->
DuplicateCaseNumber; - optimistic lock ->
CaseModifiedConcurrently; - unique active assignment ->
CaseAlreadyHasPrimaryOfficer; - FK missing ->
ReferencedAggregateMissingor application bug; - data mapping error -> infrastructure/data bug.
But repository should not swallow errors.
35. Repository and Not Found
Return Optional for optional load.
Optional<CaseFile> findById(CaseFileId id);
Application decides:
.orElseThrow(() -> new CaseNotFound(id));
For required internal load, repository may provide:
CaseFile getById(CaseFileId id);
But be consistent.
36. Repository and Identity
Aggregate identity should be domain type.
public record CaseFileId(UUID value) {}
Not raw UUID everywhere.
Benefits:
- type safety;
- method clarity;
- prevents passing officer ID to case repository.
DAO may use UUID internally, repository boundary should use domain ID.
37. Repository and New Aggregate ID
ID creation options:
- application-generated UUID/ULID-like ID;
- database-generated ID;
- domain-specific business number separate.
For idempotent create, application-generated ID or command-result mapping simplifies retry.
Repository add should not hide non-replayable generated identity if command needs idempotency.
38. Repository and Business Number
Business number generation is not just persistence detail if it has domain meaning.
Example:
CASE-2026-000001
Questions:
- must be gapless?
- scoped by tenant/year?
- generated before or after approval?
- retry-safe?
- audit requirement?
- concurrency protected?
- rollback gaps acceptable?
Repository may call number allocator, but application/domain should own semantics.
39. Repository and Testing
Repository tests should verify:
- aggregate rehydration;
- save updates expected rows;
- optimistic conflict;
- child collection persistence;
- constraint mapping;
- transaction participation;
- no partial domain object;
- tenant scope;
- lock method behavior;
- domain event/outbox coordination if repository handles it.
Use real DB.
40. Repository Test Example
@Test
void loadForApprovalRehydratesAggregate() {
CaseId caseId = fixture.underReviewCaseWithReviewer();
tx.execute(connection -> {
Optional<CaseFile> loaded = repository.loadForApproval(caseId);
assertThat(loaded).isPresent();
CaseFile caseFile = loaded.get();
assertThat(caseFile.status()).isEqualTo(CaseStatus.UNDER_REVIEW);
assertThat(caseFile.canApprove()).isTrue();
return null;
});
}
Test domain behavior after rehydration.
41. Optimistic Conflict Test
@Test
void saveDetectsConcurrentModification() {
CaseFile first = tx.execute(() -> repository.findById(caseId).orElseThrow());
CaseFile second = tx.execute(() -> repository.findById(caseId).orElseThrow());
first.approve(actor, "ok");
tx.execute(() -> {
repository.save(first);
return null;
});
second.reject(actor, "late");
assertThatThrownBy(() ->
tx.execute(() -> {
repository.save(second);
return null;
})
).isInstanceOf(OptimisticConflict.class);
}
With JPA, ensure separate persistence contexts/transactions.
42. Repository Transaction Participation Test
@Test
void repositorySaveRollsBackWithAuditFailure() {
assertThatThrownBy(() ->
tx.execute(() -> {
CaseFile caseFile = repository.findById(caseId).orElseThrow();
caseFile.approve(actor, reason);
repository.save(caseFile);
auditRepository.append(invalidAudit());
return null;
})
).isInstanceOf(DataAccessException.class);
assertThat(caseQuery.get(caseId).status())
.isEqualTo(CaseStatus.UNDER_REVIEW);
}
This proves repository did not commit by itself.
43. Repository and Fake Testing
For application service unit test, fake repository can be useful.
public final class InMemoryCaseFileRepository implements CaseFileRepository {
private final Map<CaseFileId, CaseFile> store = new HashMap<>();
@Override
public Optional<CaseFile> findById(CaseFileId id) {
return Optional.ofNullable(store.get(id));
}
@Override
public void save(CaseFile caseFile) {
store.put(caseFile.id(), caseFile);
}
}
But fake repository cannot prove:
- SQL correctness;
- transaction rollback;
- locking;
- optimistic version;
- constraint mapping;
- lazy loading;
- database isolation.
Use fake for domain/application unit tests; use integration tests for repository.
44. Repository and Hexagonal Architecture
Repository interface can live in domain/application layer.
Implementation lives in infrastructure.
application:
CaseFileRepository interface
infrastructure:
JdbcCaseFileRepository
JpaCaseFileRepository
This allows application use case to depend on abstraction.
But do not over-abstract every query. Read/query services can be infrastructure-facing if they are not domain behavior.
45. Repository and Package Design
Example:
casefile/
domain/
CaseFile.java
CaseStatus.java
CaseFileId.java
application/
ApproveCaseUseCase.java
CaseFileRepository.java
infrastructure/jdbc/
JdbcCaseFileRepository.java
CaseFileDao.java
CaseFileRow.java
CaseFileMapper.java
query/
CaseDashboardQuery.java
Keep domain clean. Keep SQL close to infrastructure.
46. Repository and Multiple Persistence Technologies
Repository can hide whether implementation uses:
- JDBC;
- JPA/Hibernate;
- jOOQ;
- MyBatis;
- stored procedure;
- document store.
But do not hide fundamental semantics like:
- transaction required;
- optimistic conflict;
- partial load;
- eventual consistency.
Abstraction should simplify, not lie.
47. Repository and Performance
Repository can be inefficient if it loads full aggregate for simple read.
Avoid:
CaseFile caseFile = repository.findById(id);
return CaseResponse.from(caseFile);
when response needs only projection.
Use query service.
Aggregate loading should be reserved for behavior/mutation.
48. Repository and Bulk Operations
Bulk update often should not go through aggregate repository.
Bad:
for each case:
CaseFile c = repository.findById(id)
c.expire()
repository.save(c)
For huge batch, use DAO/bulk operation with domain-equivalent rules, audit/outbox, idempotency, and chunking.
If domain invariant complex, process in chunks with aggregate load. But be intentional.
49. Repository and CQRS Without Overengineering
You don't need full CQRS infrastructure to separate:
- command repository for aggregates;
- query service for projections.
This simple separation gives most benefits.
CaseFileRepository -> command/domain
CaseDashboardQuery -> read projection
50. Repository Code Review Checklist
- Repository is aggregate-oriented.
- It does not expose persistence entity to API.
- It does not become generic CRUD dumping ground.
- Load methods match behavior intent.
- Partial aggregate not returned as full aggregate.
- Save semantics are explicit.
- Optimistic version/concurrency handled.
- Locking methods are explicit.
- Repository joins caller transaction.
- Audit/outbox coordination is clear.
- Query/report methods are separated.
- Tenant/scope enforced.
- Domain object remains persistence-aware only by choice, not accident.
- Tests cover rehydration, save, conflict, rollback.
51. Anti-Pattern: Repository Per Table
CaseFileRepository
CaseAssignmentRepository
CaseActionRepository
If these are aggregate child tables, they may be DAOs, not repositories.
Repository should align with aggregate root.
52. Anti-Pattern: findAll on Aggregate Repository
Unbounded aggregate load.
Use query service/chunk.
53. Anti-Pattern: Repository Returns JPA Entity to Controller
Entity leak.
Use application response DTO/projection.
54. Anti-Pattern: Repository Calls External Service
Repository is persistence abstraction. External service belongs application/integration layer.
55. Anti-Pattern: Save Silently Inserts or Updates
Upsert can hide domain errors.
Separate add/save unless upsert is truly intended.
56. Anti-Pattern: Repository Swallows Optimistic Conflict
Do not catch conflict and overwrite/retry silently.
Return conflict to application or retry only if operation safe.
57. Anti-Pattern: Lazy Aggregate That Requires Open Transaction Later
If aggregate behavior depends on DB later, repository boundary is broken.
Load required state upfront.
58. Mini Lab
Design repository for aggregate:
CaseFile
- status transition
- assignment change
- reviewer management
- closure
Questions:
- What is aggregate root?
- Which child tables are inside aggregate?
- Which operations need full aggregate?
- Which operations need intent-specific load?
- Which reads should be query service, not repository?
- What version protects aggregate?
- What constraints protect child invariants?
- What repository methods lock?
- What DAO methods are used underneath?
- What tests prove save semantics?
59. Summary
Repository pattern is about domain aggregate persistence, not generic CRUD.
A production-grade repository:
- aligns with aggregate root;
- returns domain objects, not rows;
- uses DAO/ORM underneath;
- has intent-specific load methods;
- avoids partial aggregate misuse;
- separates command from query projections;
- protects optimistic version;
- exposes lock semantics explicitly;
- joins caller transaction;
- does not publish external events;
- coordinates with audit/outbox through application service;
- avoids unbounded aggregate lists;
- is tested with real database and domain behavior.
Part berikutnya membahas Query Object and Specification Pattern: filter object, sorting, pagination, composable predicates, criteria, query ownership, and how to avoid both query-method explosion and generic query chaos.
60. References
- Jakarta Persistence Specification: https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2
- Hibernate ORM User Guide: https://docs.hibernate.org/stable/orm/userguide/html_single/
- Spring Data JPA Reference: https://docs.spring.io/spring-data/jpa/reference/
- Oracle Java SE JDBC
Connection: https://docs.oracle.com/en/java/javase/21/docs/api/java.sql/java/sql/Connection.html - jOOQ Manual: https://www.jooq.org/doc/latest/manual/
- MyBatis Documentation: https://mybatis.org/mybatis-3/
You just completed lesson 26 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.