Build CoreOrdered learning track

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.

13 min read2557 words
PrevNext
Lesson 2660 lesson track12–33 Build Core
#java#data-access#repository#domain-driven-design+5 more

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

AspectDAORepository
BahasaSQL/table/rowdomain/aggregate
Returnrow/projection/entity persistenceaggregate/domain object
Scopequery/update spesifikaggregate lifecycle
MappingResultSet -> RowRow/entity -> Domain
ErrorSQL/data access errordomain persistence error
Queryboleh banyak projectiondibatasi aggregate boundary
Transactionparticipatesparticipates
Userepository/query service/batchapplication 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 -> ReferencedAggregateMissing or 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:

  1. What is aggregate root?
  2. Which child tables are inside aggregate?
  3. Which operations need full aggregate?
  4. Which operations need intent-specific load?
  5. Which reads should be query service, not repository?
  6. What version protects aggregate?
  7. What constraints protect child invariants?
  8. What repository methods lock?
  9. What DAO methods are used underneath?
  10. 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

Lesson Recap

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.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.