Build CoreOrdered learning track

Aggregate Boundaries and Object Graph Persistence

Learn Java Persistence, Database Integration, and JPA - Part 007

Aggregate boundaries, object graph persistence, cascade semantics, orphan removal, graph explosion, and production-safe domain lifecycle modelling with JPA and Hibernate.

21 min read4042 words
PrevNext
Lesson 0735 lesson track0719 Build Core
#java#jpa#jakarta-persistence#hibernate+7 more

Part 007 — Aggregate Boundaries and Object Graph Persistence

1. Tujuan Part Ini

Di Part 006 kita membahas association mapping: foreign key, owning side, inverse side, join table, dan relasi bidirectional.

Part ini naik satu level: bagaimana relasi-relasi itu membentuk object graph yang aman untuk dipersist.

Pertanyaan utamanya bukan lagi:

Annotation apa yang harus dipakai?

Tetapi:

Object mana yang benar-benar dimiliki oleh object lain, invariant apa yang harus dijaga bersama, dan operasi persistence apa yang boleh menyebar di dalam graph?

Ini penting karena JPA membuat developer mudah berpikir seperti ini:

entityManager.persist(caseFile);

lalu berharap seluruh graph di bawah caseFile otomatis aman.

Padahal yang sebenarnya terjadi bisa salah satu dari ini:

  • child ikut tersimpan padahal seharusnya tidak;
  • shared reference ikut terhapus ketika parent dihapus;
  • detached graph hasil JSON request menimpa data yang tidak dikirim client;
  • orphan removal menghapus row karena collection diganti sembarangan;
  • cascade MERGE memicu select besar dan stale overwrite;
  • transaksi kecil berubah menjadi operasi ratusan SQL karena graph terlalu luas;
  • aggregate boundary domain tidak sama dengan boundary object graph JPA.

Di level production, aggregate boundary adalah alat untuk mengendalikan correctness, performance, dan blast radius.

Jika boundary terlalu sempit, domain logic tercecer di service layer. Jika terlalu luas, setiap command membawa object graph besar, query membengkak, dan cascade menjadi tidak terkendali.


2. Kaufman Deconstruction: Skill yang Harus Dikuasai

Mengikuti pendekatan Josh Kaufman, skill “object graph persistence” kita pecah menjadi sub-skill kecil yang bisa dilatih:

Sub-skillKemampuan yang Diharapkan
Membedakan entity, aggregate root, child entity, value object, shared referenceTidak semua @Entity adalah aggregate root
Membaca lifecycle ownershipTahu mana object yang hidup dan mati bersama parent
Mendesain cascade secara selektifTidak memakai CascadeType.ALL sebagai default malas
Memakai orphan removal dengan amanTidak menghapus data karena collection replacement
Membatasi graph persistenceMencegah persist/merge/remove menyebar terlalu jauh
Menjaga invariant melalui method domainTidak membiarkan external code memodifikasi collection bebas
Menghindari detached graph mergeTidak memakai merge() sebagai object graph upsert universal
Menguji boundary dengan flush-clear-reloadMembuktikan mapping benar terhadap database, bukan terhadap memory

Target Part ini: setelah selesai, Anda harus bisa melihat sebuah model JPA dan mengatakan:

  • aggregate root-nya mana;
  • child mana yang owned;
  • association mana yang reference-only;
  • cascade mana yang valid;
  • orphan removal mana yang aman;
  • operasi command mana yang berpotensi mengubah graph terlalu luas;
  • test apa yang diperlukan untuk membuktikan boundary itu benar.

3. Mental Model: Object Graph Bukan Aggregate Boundary

Object graph adalah struktur pointer di memory.

Aggregate boundary adalah struktur ownership dan invariant dalam domain.

Keduanya sering tumpang tindih, tetapi tidak identik.

Pada contoh di atas:

  • CaseFile mungkin punya pointer ke Officer;
  • CaseFile mungkin punya pointer ke RegulatedOrganization;
  • tetapi itu tidak berarti Officer dan RegulatedOrganization dimiliki oleh CaseFile;
  • CaseNote dan Evidence mungkin benar-benar child dari CaseFile karena lifecycle-nya bergantung pada case.

Kesalahan umum:

@OneToMany(cascade = CascadeType.ALL)
private List<Officer> assignedOfficers;

Ini berbahaya jika Officer adalah master data atau user account. Menghapus case tidak boleh menghapus officer. Membuat case baru tidak boleh membuat officer baru secara implisit. Mengubah case tidak boleh merge semua officer yang kebetulan ada di graph.

Aturan mental:

Pointer bukan ownership. Association bukan aggregate. Cascade hanya valid jika lifecycle benar-benar dimiliki.


4. Vocabulary yang Harus Tepat

4.1 Entity

Entity adalah object dengan identity yang bertahan melewati perubahan state.

Contoh:

@Entity
class CaseFile {
    @Id
    private UUID id;
}

CaseFile tetap case yang sama walaupun status, officer, atau notes berubah.

4.2 Aggregate Root

Aggregate root adalah entity utama yang menjadi gerbang perubahan untuk object lain di dalam aggregate.

caseFile.addNote(authorId, "Inspection completed");
caseFile.attachEvidence(evidenceId, metadata);
caseFile.close(resolution);

External code tidak boleh sembarangan melakukan ini:

caseFile.getNotes().add(note); // buruk

karena itu melewati invariant.

4.3 Child Entity

Child entity punya identity, tetapi lifecycle dan invariant-nya bergantung pada parent.

Contoh:

  • CaseNote punya id, tetapi tidak bermakna tanpa CaseFile;
  • EvidenceItem punya id, tetapi kepemilikannya ada di case tertentu;
  • PenaltyLine punya id, tetapi bagian dari PenaltyDecision.

4.4 Value Object

Value object tidak punya identity persistence sendiri. Ia didefinisikan oleh value-nya.

Contoh:

  • Money;
  • DateRange;
  • Address;
  • RiskScore;
  • ViolationCode.

Part 008 akan membahas value object lebih dalam.

4.5 Shared Reference

Shared reference adalah entity yang direferensikan banyak aggregate dan lifecycle-nya tidak dimiliki oleh referencer.

Contoh:

  • Officer;
  • RegulatedOrganization;
  • RegulationArticle;
  • ProductCategory;
  • LicenseType.

Shared reference biasanya tidak menerima cascade dari aggregate yang mereferensikannya.


5. Aggregate Boundary sebagai Transaction Consistency Boundary

Aggregate bukan sekadar “kelompok class”. Aggregate adalah boundary konsistensi.

Dalam satu command, invariant di dalam aggregate harus benar secara atomik.

Contoh invariant CaseFile:

  • case tidak boleh ditutup jika masih ada mandatory evidence yang belum diverifikasi;
  • case tidak boleh pindah ke ESCALATED tanpa escalation reason;
  • note tidak boleh ditambahkan ke closed case kecuali note bertipe post-closure audit;
  • evidence tidak boleh dihapus setelah enforcement decision diterbitkan;
  • severity tidak boleh diturunkan tanpa reviewer approval.

Semua invariant itu lebih aman jika dijaga oleh root:

public void close(CaseResolution resolution, OfficerId closedBy, Instant now) {
    if (status == CaseStatus.CLOSED) {
        throw new CaseAlreadyClosedException(id);
    }

    if (!mandatoryEvidenceComplete()) {
        throw new CaseCannotBeClosedException("Mandatory evidence is incomplete");
    }

    this.status = CaseStatus.CLOSED;
    this.resolution = resolution;
    this.closedBy = closedBy;
    this.closedAt = now;
}

Bukan seperti ini:

caseFile.setStatus(CaseStatus.CLOSED);
caseFile.setClosedBy(userId);
caseFile.setClosedAt(now);

Setter publik membuat object graph mudah diubah tanpa invariant.

Persistence layer harus mendukung model ini, bukan merusaknya.


6. Mapping Aggregate Root dan Owned Children

Contoh model regulatory case:

@Entity
@Table(name = "case_file")
public class CaseFile {

    @Id
    private UUID id;

    @Version
    private long version;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private CaseStatus status;

    @OneToMany(
        mappedBy = "caseFile",
        cascade = CascadeType.PERSIST,
        orphanRemoval = true
    )
    private final List<CaseNote> notes = new ArrayList<>();

    protected CaseFile() {
    }

    public CaseFile(UUID id) {
        this.id = Objects.requireNonNull(id);
        this.status = CaseStatus.DRAFT;
    }

    public void addNote(OfficerId authorId, String text, Instant now) {
        if (status == CaseStatus.CLOSED) {
            throw new IllegalStateException("Closed case cannot receive ordinary notes");
        }

        CaseNote note = new CaseNote(UUID.randomUUID(), this, authorId, text, now);
        notes.add(note);
    }

    public void removeDraftNote(UUID noteId) {
        CaseNote note = notes.stream()
            .filter(n -> n.id().equals(noteId))
            .findFirst()
            .orElseThrow();

        if (!note.isDraft()) {
            throw new IllegalStateException("Only draft notes can be removed");
        }

        notes.remove(note);
        note.detachFromCase();
    }

    public List<CaseNote> notes() {
        return List.copyOf(notes);
    }
}

Child entity:

@Entity
@Table(name = "case_note")
public class CaseNote {

    @Id
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "case_file_id", nullable = false)
    private CaseFile caseFile;

    @Embedded
    private OfficerId authorId;

    @Column(nullable = false, length = 4000)
    private String text;

    @Column(nullable = false)
    private Instant createdAt;

    protected CaseNote() {
    }

    CaseNote(UUID id, CaseFile caseFile, OfficerId authorId, String text, Instant createdAt) {
        this.id = Objects.requireNonNull(id);
        this.caseFile = Objects.requireNonNull(caseFile);
        this.authorId = Objects.requireNonNull(authorId);
        this.text = requireText(text);
        this.createdAt = Objects.requireNonNull(createdAt);
    }

    UUID id() {
        return id;
    }

    boolean isDraft() {
        return true; // simplified for example
    }

    void detachFromCase() {
        this.caseFile = null;
    }
}

Ada beberapa keputusan penting di sini:

KeputusanAlasan
CaseFile root membuat CaseNoteChild lifecycle dikendalikan root
Constructor CaseNote package-privateMencegah external code membuat child invalid
Collection tidak diekspos mutableExternal code tidak bisa bypass invariant
mappedBy = "caseFile"FK ada di child
orphanRemoval = trueNote yang dilepas dari aggregate dihapus dari DB
cascade = PERSISTSaat case baru dibuat, notes baru boleh ikut tersimpan
Tidak langsung CascadeType.ALLMERGE, REMOVE, REFRESH, DETACH harus diputuskan eksplisit

7. Cascade Semantics: Jangan Jadikan ALL sebagai Refleks

JPA cascade berarti operasi persistence pada parent dipropagasikan ke associated entity.

Cascade bukan “relasi ini anak saya”. Cascade adalah “operasi persistence ini boleh menyebar ke object tersebut”.

Jenis umum:

CascadeEfekRisiko
PERSISTChild baru ikut disimpan ketika parent dipersistMenyimpan object yang sebenarnya shared reference
MERGEState detached child ikut disalin ke managed childStale overwrite, select besar, graph merge tidak terkendali
REMOVEChild ikut dihapus ketika parent dihapusMenghapus shared entity secara tidak sengaja
REFRESHChild ikut di-refresh dari DBPerubahan lokal child bisa hilang
DETACHChild ikut dilepas dari persistence contextBisa membuat graph detached lebih besar dari yang diharapkan
ALLSemua operasi di atasTerlalu agresif untuk banyak model production

7.1 Cascade PERSIST

PERSIST valid ketika parent bertanggung jawab membuat child baru.

Contoh valid:

@OneToMany(mappedBy = "caseFile", cascade = CascadeType.PERSIST)
private List<CaseNote> notes = new ArrayList<>();

Use case:

CaseFile caseFile = new CaseFile(caseId);
caseFile.addNote(authorId, "Initial intake", now);
entityManager.persist(caseFile);

Expected behavior:

insert into case_file (...);
insert into case_note (..., case_file_id);

PERSIST tidak berarti setiap update child akan butuh cascade. Setelah child managed, dirty checking akan mendeteksi perubahan pada child jika child ada di persistence context.

7.2 Cascade MERGE

MERGE sering paling berbahaya.

Masalahnya: merge() bukan “attach object ini lagi”. merge() menyalin state object detached ke managed instance. Jika graph besar ikut cascade merge, provider bisa melakukan select dan copy state ke banyak object.

Contoh berbahaya:

@PostMapping("/cases/{id}")
@Transactional
public CaseFile update(@RequestBody CaseFile detachedCase) {
    return entityManager.merge(detachedCase);
}

Ini buruk karena:

  • request body menjadi persistence graph;
  • field yang tidak dikirim client bisa menjadi null;
  • child collection bisa dianggap authoritative replacement;
  • stale client data bisa menimpa state terbaru;
  • cascade merge bisa menyebar ke object yang tidak dimaksud.

Pattern yang lebih aman:

@Transactional
public void addNote(UUID caseId, AddNoteCommand command) {
    CaseFile caseFile = caseRepository.getForUpdate(caseId);
    caseFile.addNote(command.authorId(), command.text(), clock.instant());
}

Load managed aggregate, panggil method domain, biarkan dirty checking bekerja.

7.3 Cascade REMOVE

REMOVE valid jika child tidak boleh hidup tanpa parent.

Contoh valid:

@OneToMany(mappedBy = "decision", cascade = CascadeType.REMOVE)
private List<PenaltyLine> lines = new ArrayList<>();

Jika PenaltyDecision dihapus, PenaltyLine ikut hilang.

Contoh berbahaya:

@ManyToOne(cascade = CascadeType.REMOVE)
private RegulatedOrganization organization;

Menghapus case tidak boleh menghapus organization.

Aturan praktis:

Jangan cascade remove dari child ke parent atau dari aggregate ke shared reference.

7.4 Cascade REFRESH dan DETACH

Dua cascade ini jarang perlu di application code biasa.

REFRESH akan membuang perubahan lokal dan reload dari database. Jika cascade refresh menyebar ke child, perubahan lokal child juga hilang.

DETACH membuat object keluar dari persistence context. Di aplikasi request/transaction scoped, explicit detach jarang diperlukan kecuali untuk optimasi memory atau batch processing tertentu.

7.5 Cascade ALL

CascadeType.ALL boleh dipakai hanya jika Anda benar-benar setuju dengan semua operasi:

  • persist bersama;
  • merge bersama;
  • remove bersama;
  • refresh bersama;
  • detach bersama.

Untuk owned child kecil yang lifecycle-nya sepenuhnya milik parent, ALL bisa masuk akal.

Tetapi default yang lebih aman untuk production adalah memilih cascade satu per satu.


8. Cascade Decision Matrix

Gunakan matrix ini saat review mapping.

RelationshipContohCascade yang Umumnya AmanCascade yang Harus Dicurigai
Parent ke owned childCaseFile -> CaseNotePERSIST, kadang REMOVE, kadang ALLMERGE jika API menerima detached graph
Parent ke value-like entity childOrder -> OrderLineALL + orphanRemoval sering validTidak ada jika lifecycle benar-benar owned
Child ke parentCaseNote -> CaseFileUmumnya tidak perlu cascadeREMOVE, ALL
Aggregate ke shared referenceCaseFile -> OrganizationUmumnya tidak perlu cascadePERSIST, MERGE, REMOVE, ALL
Many-to-many langsungUser -> RoleUmumnya tidak cascade removeREMOVE, ALL
Association entityUser -> UserRoleAssignmentPERSIST, orphan removal dari ownerCascade ke Role

Review question:

Jika parent dihapus, apakah associated object boleh ikut hilang secara domain dan compliance?

Jika jawabannya “tidak yakin”, jangan pakai cascade remove.


9. Orphan Removal: Lifecycle Deletion karena Terlepas dari Parent

orphanRemoval = true berarti child entity yang dilepas dari relationship akan dihapus.

Contoh:

@OneToMany(mappedBy = "caseFile", orphanRemoval = true)
private List<CaseNote> notes = new ArrayList<>();

Jika ini terjadi:

caseFile.removeDraftNote(noteId);

maka child note yang dilepas dari collection akan menjadi orphan dan dijadwalkan untuk delete.

Perbedaan penting:

MechanismTriggerEfek
CascadeType.REMOVEParent dihapusChild ikut dihapus
orphanRemoval = trueChild dilepas dari parent relationshipChild dihapus
DB ON DELETE CASCADERow parent dihapus di databaseDB menghapus row child

Ketiganya tidak sama.

9.1 Orphan Removal Valid untuk Private-Owned Child

Valid:

Order -> OrderLine
CaseFile -> DraftCaseNote
PenaltyDecision -> PenaltyLine

Tidak valid:

CaseFile -> Officer
CaseFile -> Organization
User -> Role

9.2 Collection Replacement Pitfall

Ini berbahaya:

public void setNotes(List<CaseNote> notes) {
    this.notes = notes;
}

Dengan orphanRemoval = true, mengganti collection reference bisa membuat provider bingung atau menganggap old collection tidak lagi direferensikan. Di Hibernate, collection persistent wrapper punya tracking internal. Mengganti wrapper dengan collection baru bisa memicu exception atau delete yang tidak diinginkan.

Pattern aman:

public void replaceDraftNotes(List<NoteDraft> drafts, OfficerId authorId, Instant now) {
    this.notes.removeIf(CaseNote::isDraft);

    for (NoteDraft draft : drafts) {
        this.addNote(authorId, draft.text(), now);
    }
}

Atau jika benar-benar ingin sinkronisasi by id:

public void syncNotes(List<NotePatch> patches, Instant now) {
    Map<UUID, CaseNote> existing = notes.stream()
        .collect(Collectors.toMap(CaseNote::id, Function.identity()));

    Set<UUID> incomingIds = patches.stream()
        .map(NotePatch::id)
        .filter(Objects::nonNull)
        .collect(Collectors.toSet());

    notes.removeIf(note -> note.isDraft() && !incomingIds.contains(note.id()));

    for (NotePatch patch : patches) {
        if (patch.id() == null) {
            addNote(patch.authorId(), patch.text(), now);
        } else {
            existing.get(patch.id()).editText(patch.text());
        }
    }
}

Jangan set collection langsung dari DTO.


10. Graph Explosion

Graph explosion terjadi ketika satu operasi persistence menyentuh graph jauh lebih besar dari intensi command.

Contoh:

CaseFile
  -> notes
  -> evidence
  -> assignedOfficers
  -> organization
  -> licenses
  -> previousCases
  -> decisions

Jika semua association diberi cascade dan eager fetch, operasi kecil seperti addNote() bisa berubah menjadi operasi besar.

Masalahnya bukan hanya jumlah SQL, tetapi juga blast radius:

  • object yang tidak relevan masuk persistence context;
  • dirty checking memeriksa lebih banyak entity;
  • lock duration lebih lama;
  • memory usage naik;
  • transaction conflict meningkat;
  • cache invalidation lebih luas;
  • stale merge risk lebih besar.

Aturan:

Command kecil harus punya graph persistence kecil.

Jika addCaseNote membutuhkan organization licenses dan previous cases, kemungkinan boundary Anda salah atau query Anda terlalu rakus.


11. Aggregate Boundary vs Read Model

Aggregate boundary didesain untuk write consistency, bukan untuk semua kebutuhan read.

Kesalahan umum: entity graph dibuat besar karena UI butuh layar detail lengkap.

Contoh layar detail case mungkin butuh:

  • case header;
  • organization profile;
  • assigned officers;
  • open tasks;
  • latest notes;
  • evidence count;
  • risk timeline;
  • related previous cases;
  • enforcement decision history.

Itu bukan berarti semua harus masuk aggregate CaseFile sebagai mutable graph.

Solusi:

  • aggregate kecil untuk command/write;
  • projection/DTO/read model untuk layar query;
  • fetch plan eksplisit untuk read use case;
  • repository method terpisah untuk command load vs detail load.
interface CaseRepository {
    CaseFile getAggregate(UUID caseId);
    CaseDetailView getDetailView(UUID caseId);
}

getAggregate() harus memuat graph minimal untuk menjaga invariant command.

getDetailView() boleh memakai projection, native query, atau join terkontrol.


12. Designing Command Methods Instead of Graph Setters

Aggregate root harus menyediakan operation yang bermakna domain.

Buruk:

caseFile.setStatus(CaseStatus.ESCALATED);
caseFile.setEscalationReason(reason);
caseFile.setEscalatedBy(userId);
caseFile.setEscalatedAt(now);

Lebih baik:

caseFile.escalate(reason, userId, now);

Kenapa?

Karena escalate() bisa menjaga invariant:

public void escalate(EscalationReason reason, OfficerId officerId, Instant now) {
    if (status == CaseStatus.CLOSED) {
        throw new IllegalStateException("Closed case cannot be escalated");
    }
    if (!riskAssessment.isHighRisk()) {
        throw new IllegalStateException("Only high-risk cases can be escalated");
    }
    this.status = CaseStatus.ESCALATED;
    this.escalationReason = Objects.requireNonNull(reason);
    this.escalatedBy = Objects.requireNonNull(officerId);
    this.escalatedAt = Objects.requireNonNull(now);
}

JPA dirty checking akan menyimpan field yang berubah. Anda tidak perlu memanggil save() berkali-kali jika entity managed di dalam transaksi.


13. Helper Methods untuk Bidirectional Relationship

Untuk relationship bidirectional, helper method harus menjaga dua sisi object graph.

public void addEvidence(EvidenceItem evidence) {
    requireMutableCase();
    evidence.attachTo(this);
    evidenceItems.add(evidence);
}

public void removeEvidence(UUID evidenceId) {
    EvidenceItem evidence = findEvidence(evidenceId);
    if (!evidence.canBeRemoved()) {
        throw new IllegalStateException("Evidence cannot be removed");
    }
    evidenceItems.remove(evidence);
    evidence.detachFromCase();
}

Child:

void attachTo(CaseFile caseFile) {
    this.caseFile = Objects.requireNonNull(caseFile);
}

void detachFromCase() {
    this.caseFile = null;
}

Jika FK non-nullable dan orphan removal aktif, detachFromCase() akan diikuti oleh delete saat flush. Jika orphan removal tidak aktif, detach child dengan FK non-nullable akan gagal constraint.

Itu bagus. Database constraint menjadi safety net.


14. Private Collection, Public Read-Only View

Jangan expose mutable collection dari aggregate.

Buruk:

public List<CaseNote> getNotes() {
    return notes;
}

External code bisa melakukan:

caseFile.getNotes().clear();

Lebih aman:

public List<CaseNote> notes() {
    return List.copyOf(notes);
}

Atau:

public Stream<CaseNote> notes() {
    return notes.stream();
}

Untuk JPA, collection field tetap mutable secara internal, tetapi mutation dilakukan melalui method domain.


15. DTO Tidak Boleh Menjadi Aggregate Graph

Inbound API DTO adalah representasi request, bukan persistence graph.

Buruk:

public record UpdateCaseRequest(
    UUID id,
    CaseStatus status,
    List<CaseNoteDto> notes,
    OrganizationDto organization
) {}

lalu:

CaseFile caseFile = mapper.toEntity(request);
entityManager.merge(caseFile);

Ini mencampur:

  • API contract;
  • persistence lifecycle;
  • domain invariant;
  • graph ownership;
  • authorization;
  • concurrency semantics.

Lebih aman:

public record AddCaseNoteRequest(String text) {}

@Transactional
public void addNote(UUID caseId, AddCaseNoteRequest request, User user) {
    CaseFile caseFile = caseRepository.getAggregate(caseId);
    caseFile.addNote(user.officerId(), request.text(), clock.instant());
}

Command kecil, aggregate load kecil, mutation eksplisit.


16. Avoiding Universal save(entity) Semantics

Spring Data JPA punya save() yang terlihat sederhana, tetapi semantics-nya bergantung pada entity baru atau existing. Di bawahnya bisa terjadi persist atau merge.

Di service command, hati-hati dengan pattern ini:

caseRepository.save(caseFile);

Jika caseFile adalah managed entity hasil load dalam transaksi, explicit save() sering tidak diperlukan.

Pattern yang lebih jelas:

@Transactional
public void closeCase(UUID caseId, CloseCaseCommand command) {
    CaseFile caseFile = caseRepository.getAggregate(caseId);
    caseFile.close(command.resolution(), command.closedBy(), clock.instant());
}

Tidak ada save() di akhir. Dirty checking menyimpan perubahan.

Untuk entity baru:

@Transactional
public UUID openCase(OpenCaseCommand command) {
    CaseFile caseFile = CaseFile.open(command.organizationId(), command.openedBy(), clock.instant());
    caseRepository.add(caseFile);
    return caseFile.id();
}

Repository bisa menyediakan method yang semantic-nya eksplisit:

void add(CaseFile caseFile);
CaseFile getAggregate(UUID id);

bukan hanya save() universal.


17. Aggregate References: Jangan Selalu Pakai Entity Association

Tidak semua relasi domain harus berupa entity association.

Contoh:

@ManyToOne(fetch = FetchType.LAZY)
private RegulatedOrganization organization;

Alternatif:

@Embedded
private OrganizationId organizationId;

Kapan cukup menyimpan id?

KondisiSimpan ID SajaEntity Association
Hanya butuh referensi identityYaTidak perlu
Tidak butuh join otomatisYaTidak perlu
Ingin aggregate tetap kecilYaTidak perlu
Sering navigasi object dalam commandMungkin tidakMungkin ya
Butuh FK constraint databaseTetap bisa pakai column + FKYa
Butuh cascadingTidakYa, jika lifecycle owned

Contoh ID value object:

@Embeddable
public record OrganizationId(
    @Column(name = "organization_id", nullable = false)
    UUID value
) {}

Di table:

alter table case_file
add constraint fk_case_file_organization
foreign key (organization_id)
references regulated_organization(id);

Anda tetap punya referential integrity tanpa memperbesar object graph.


18. Aggregate Size Heuristics

Tidak ada angka universal, tetapi ada sinyal boundary terlalu besar:

  • satu command perlu load ribuan child;
  • satu aggregate punya banyak collection mutable;
  • delete parent menghasilkan banyak delete cascade yang tidak mudah diprediksi;
  • merge parent melakukan select ke banyak table;
  • UI read detail menentukan desain aggregate;
  • aggregate sering diedit oleh banyak user di area berbeda dan sering conflict pada version yang sama;
  • setiap perubahan kecil menaikkan latency karena dirty checking graph besar;
  • test setup aggregate menjadi sulit karena harus membuat banyak object tidak relevan.

Heuristic yang berguna:

Aggregate harus cukup besar untuk menjaga invariant kuat, tetapi cukup kecil untuk dimuat, dikunci, dimodifikasi, dan diuji secara murah.

Jika child collection bisa tumbuh tak terbatas, jangan perlakukan seluruh collection sebagai bagian yang selalu harus dimuat untuk setiap command.

Contoh CaseFile.notes mungkin tumbuh ribuan row. Untuk command addNote, Anda tidak perlu load semua notes. Anda hanya perlu:

  • case exists;
  • case status allow note;
  • maybe note quota rule;
  • create new note.

Model alternatif:

@Transactional
public void addNote(UUID caseId, AddNoteCommand command) {
    CaseFile caseFile = caseRepository.getHeaderForCommand(caseId);
    caseFile.assertCanReceiveNote();

    CaseNote note = CaseNote.create(caseFile.id(), command.authorId(), command.text(), clock.instant());
    caseNoteRepository.add(note);
}

Ini memisahkan child repository jika collection terlalu besar untuk selalu dimuat.

Trade-off: invariant yang melibatkan seluruh collection harus dipindahkan ke query/constraint eksplisit.


19. Owned Child Besar: Collection atau Repository Terpisah?

Pertanyaan penting:

Apakah child harus selalu dimanipulasi melalui parent collection?

Untuk OrderLine, sering ya.

Untuk CaseNote, mungkin tidak jika notes bisa sangat banyak.

Untuk AuditEntry, hampir pasti tidak.

Child TypeBiasanya dalam Parent Collection?Alasan
OrderLineYaJumlah relatif terbatas, invariant total order membutuhkan lines
PenaltyLineYaBagian dari decision, jumlah terbatas
CaseNoteTergantungBisa tumbuh besar; command add note tidak perlu load semua notes
AuditEntryTidakAppend-only log, volume besar
DocumentAttachmentTergantungMetadata mungkin collection, binary content tidak
WorkflowHistoryTidakHistorical/event stream, bukan mutable aggregate child

JPA mapping harus mengikuti access pattern, bukan hanya relasi konseptual.


20. Database Constraint Tetap Wajib

Aggregate invariant di Java tidak menggantikan constraint database.

Contoh:

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "case_file_id", nullable = false)
private CaseFile caseFile;

DDL harus mendukung:

alter table case_note
add constraint fk_case_note_case_file
foreign key (case_file_id)
references case_file(id);

alter table case_note
alter column case_file_id set not null;

Jika rule unik:

Dalam satu case, hanya boleh ada satu active primary allegation.

Buat constraint:

create unique index uq_case_primary_allegation
on allegation(case_file_id)
where primary_flag = true and status = 'ACTIVE';

Java invariant bagus untuk error message dan flow control. Database constraint bagus untuk race condition dan data integrity final.


21. Transaction Boundary untuk Aggregate Mutation

Idealnya satu command mengubah satu aggregate root utama dalam satu transaksi.

Hal yang perlu dihindari:

@Transactional
public void complexOperation(...) {
    CaseFile caseFile = caseRepository.getAggregate(caseId);
    Organization organization = organizationRepository.get(orgId);
    Officer officer = officerRepository.get(officerId);

    caseFile.modify(...);
    organization.modify(...);
    officer.modify(...);
}

Bisa saja valid, tetapi artinya satu transaksi mengubah beberapa aggregate. Risiko:

  • lock lebih banyak;
  • failure rollback lebih besar;
  • invariant lintas aggregate lebih sulit;
  • concurrency conflict meningkat.

Untuk perubahan lintas aggregate, pertimbangkan:

  • domain event;
  • outbox;
  • saga/process manager;
  • eventual consistency;
  • database constraint bila harus synchronous.

Part 030 akan membahas outbox/inbox.


22. Merge Abuse: Anti-Pattern Object Graph Upsert

Anti-pattern:

@Transactional
public CaseFile update(CaseFile requestEntity) {
    return entityManager.merge(requestEntity);
}

Masalah:

  1. Entity menjadi API input.
  2. Detached graph dianggap authoritative.
  3. Missing child bisa dianggap deletion.
  4. Cascade merge bisa menyebar ke object yang tidak dimaksud.
  5. Optimistic locking bisa gagal terlambat atau tidak cukup melindungi field-level intent.
  6. Invariant domain bisa dilewati jika mapper mengisi field langsung.

Better pattern:

@Transactional
public void updateRiskRating(UUID caseId, UpdateRiskRatingCommand command) {
    CaseFile caseFile = caseRepository.getAggregate(caseId);
    caseFile.updateRiskRating(command.rating(), command.reason(), command.changedBy(), clock.instant());
}

Untuk patch child:

@Transactional
public void editDraftNote(UUID caseId, UUID noteId, EditNoteCommand command) {
    CaseFile caseFile = caseRepository.getAggregateWithDraftNotes(caseId);
    caseFile.editDraftNote(noteId, command.text(), command.editedBy(), clock.instant());
}

Load what you need. Mutate intentionally. Flush managed state.


23. Aggregate Boundary dan Optimistic Locking

Aggregate root biasanya punya @Version.

@Version
private long version;

Jika semua child modification harus conflict pada root version, Anda perlu memastikan root version ikut berubah ketika child berubah.

JPA tidak selalu menaikkan version parent hanya karena child collection berubah, tergantung mapping dan provider behavior. Jangan bergantung pada asumsi samar.

Pilihan:

  1. Let child punya version sendiri.
  2. Update parent timestamp/version field secara eksplisit saat child berubah.
  3. Pakai optimistic lock force increment saat command harus conflict pada root.
  4. Desain child sebagai aggregate terpisah jika conflict tidak perlu satu root.

Contoh explicit touch:

private Instant lastModifiedAt;

public void addNote(OfficerId authorId, String text, Instant now) {
    requireOpen();
    notes.add(new CaseNote(UUID.randomUUID(), this, authorId, text, now));
    this.lastModifiedAt = now;
}

Jika regulatory audit mengharuskan case version berubah saat note ditambahkan, buat itu eksplisit.


24. Soft Delete dan Orphan Removal

Jika child harus diaudit, orphan removal fisik mungkin salah.

Contoh note regulatory mungkin tidak boleh benar-benar dihapus.

Alih-alih:

@OneToMany(orphanRemoval = true)
private List<CaseNote> notes;

mungkin lebih tepat:

public void withdrawDraftNote(UUID noteId, OfficerId withdrawnBy, Instant now) {
    CaseNote note = findNote(noteId);
    note.withdraw(withdrawnBy, now);
}

Child tetap row, status berubah:

public void withdraw(OfficerId withdrawnBy, Instant now) {
    if (!isDraft()) {
        throw new IllegalStateException("Only draft note can be withdrawn");
    }
    this.status = NoteStatus.WITHDRAWN;
    this.withdrawnBy = withdrawnBy;
    this.withdrawnAt = now;
}

Di regulated systems, delete fisik sering harus dibatasi. Orphan removal cocok untuk private technical child, bukan selalu untuk audit-relevant child.


25. Review Checklist: Aggregate Boundary

Gunakan checklist ini saat review PR JPA entity.

25.1 Ownership

  • Apakah setiap association jelas owned atau reference-only?
  • Apakah child punya makna jika parent dihapus?
  • Apakah shared reference bebas dari cascade remove?
  • Apakah many-to-many langsung benar-benar diperlukan?

25.2 Mutation

  • Apakah collection diekspos mutable?
  • Apakah ada setter yang melewati invariant?
  • Apakah method domain menjaga dua sisi bidirectional association?
  • Apakah DTO langsung di-map ke entity graph?

25.3 Cascade

  • Apakah cascade dipilih eksplisit, bukan ALL refleks?
  • Apakah cascade merge benar-benar diperlukan?
  • Apakah cascade remove aman secara domain dan compliance?
  • Apakah orphan removal hanya dipakai untuk private-owned child?

25.4 Performance

  • Apakah command kecil memuat graph kecil?
  • Apakah child collection bisa tumbuh tak terbatas?
  • Apakah aggregate load method berbeda dari detail read method?
  • Apakah query count diuji?

25.5 Correctness

  • Apakah DB constraint mendukung invariant penting?
  • Apakah ada optimistic lock pada root atau child yang relevan?
  • Apakah test melakukan flush-clear-reload?
  • Apakah delete behavior diuji terhadap database sungguhan?

26. Testing Aggregate Persistence

Test persistence graph harus membuktikan behavior database, bukan sekadar object memory.

26.1 Test Cascade Persist

@Test
void persistingCaseShouldPersistOwnedNotes() {
    CaseFile caseFile = new CaseFile(UUID.randomUUID());
    caseFile.addNote(officerId, "Initial intake", now);

    entityManager.persist(caseFile);
    entityManager.flush();
    entityManager.clear();

    CaseFile reloaded = entityManager.find(CaseFile.class, caseFile.id());

    assertThat(reloaded.notes()).hasSize(1);
}

26.2 Test Orphan Removal

@Test
void removingDraftNoteShouldDeleteOwnedNote() {
    CaseFile caseFile = persistedCaseWithDraftNote();
    UUID noteId = caseFile.notes().getFirst().id();

    caseFile.removeDraftNote(noteId);

    entityManager.flush();
    entityManager.clear();

    CaseNote note = entityManager.find(CaseNote.class, noteId);
    assertThat(note).isNull();
}

26.3 Test Shared Reference Not Removed

@Test
void deletingCaseShouldNotDeleteOrganization() {
    CaseFile caseFile = persistedCaseWithOrganization();
    UUID organizationId = caseFile.organizationId().value();

    entityManager.remove(caseFile);
    entityManager.flush();
    entityManager.clear();

    RegulatedOrganization organization = entityManager.find(RegulatedOrganization.class, organizationId);
    assertThat(organization).isNotNull();
}

26.4 Test No Mutable Collection Escape

@Test
void notesCollectionShouldNotBeExternallyMutable() {
    CaseFile caseFile = new CaseFile(UUID.randomUUID());

    assertThatThrownBy(() -> caseFile.notes().add(fakeNote()))
        .isInstanceOf(UnsupportedOperationException.class);
}

Jika test ini gagal, aggregate invariant bisa dibypass.


27. Common Anti-Patterns

27.1 CascadeType.ALL Everywhere

@OneToMany(cascade = CascadeType.ALL)
private List<Something> things;

Tanpa ownership analysis, ini bom waktu.

27.2 Entity Graph as Request Body

@PostMapping
public CaseFile update(@RequestBody CaseFile caseFile) {
    return repository.save(caseFile);
}

Ini membuat client mengontrol persistence graph.

27.3 Bidirectional Association Without Helper Methods

caseFile.getNotes().add(note);

Tapi note.caseFile tidak diset. Memory terlihat benar dari parent side, database tidak.

27.4 Orphan Removal on Shared Child

@OneToMany(orphanRemoval = true)
private List<Officer> officers;

Jika officer shared, ini salah.

27.5 Huge Aggregate for UI Convenience

Aggregate dibuat besar karena UI detail page butuh semua data. Ini mencampur write model dan read model.

27.6 Blind Merge of Detached Collections

entityManager.merge(detachedParent);

Dengan collection child dari request, ini bisa menjadi overwrite/delete tak disengaja.


28. Production Failure Modes

Failure ModeGejalaRoot CausePencegahan
Accidental child deleteData child hilang setelah update parentOrphan removal + collection replacementMutate collection in-place, command method
Shared master deletedUser/role/org hilang saat parent dihapusCascade remove ke shared referenceJangan cascade remove ke shared entity
Massive SQL on small updateEndpoint add note lambatGraph terlalu besar, cascade/fetch berlebihanCommand-specific load
Stale overwritePerubahan user lain hilangDetached merge graphLoad managed entity + command mutation
Constraint failure at flushError muncul terlambatObject graph tidak sinkron dengan FKHelper method dua sisi + flush test
Optimistic conflict terlalu seringUser conflict untuk field tidak terkaitAggregate terlalu besarSplit aggregate atau child version
Audit violationRow penting terhapus fisikOrphan removal pada data auditSoft delete/status transition

29. Mini Case Study: Enforcement Case Module

Misalnya kita punya enforcement lifecycle:

Aggregate root: EnforcementCase.

Owned children:

  • Allegation jika allegation hanya hidup dalam case;
  • EvidenceItem jika metadata evidence bagian dari case;
  • CaseTask jika task tidak hidup tanpa case.

Reference-only:

  • Officer;
  • RegulatedOrganization;
  • RegulationArticle;
  • InspectionProgram.

Mapping sketch:

@Entity
@Table(name = "enforcement_case")
public class EnforcementCase {

    @Id
    private UUID id;

    @Version
    private long version;

    @Embedded
    private OrganizationId organizationId;

    @OneToMany(mappedBy = "enforcementCase", cascade = CascadeType.PERSIST, orphanRemoval = true)
    private final List<Allegation> allegations = new ArrayList<>();

    @OneToMany(mappedBy = "enforcementCase", cascade = CascadeType.PERSIST)
    private final List<EvidenceItem> evidenceItems = new ArrayList<>();

    public void addAllegation(RegulationArticleId articleId, String narrative) {
        requireDraftOrReview();
        allegations.add(Allegation.open(UUID.randomUUID(), this, articleId, narrative));
    }

    public void withdrawAllegation(UUID allegationId, OfficerId officerId, Instant now) {
        Allegation allegation = findAllegation(allegationId);
        allegation.withdraw(officerId, now);
    }
}

Perhatikan: withdrawAllegation() tidak remove dari collection jika allegation punya audit value. Ia mengubah status.

Jika allegation draft yang belum pernah dipublish boleh dihapus fisik, buat method spesifik:

public void removeDraftAllegation(UUID allegationId) {
    Allegation allegation = findAllegation(allegationId);
    if (!allegation.isDraft()) {
        throw new IllegalStateException("Only draft allegation can be physically removed");
    }
    allegations.remove(allegation);
    allegation.detachFromCase();
}

Domain semantics menentukan persistence semantics.


30. Latihan Deliberate Practice

Latihan 1 — Ownership Audit

Ambil satu model entity di codebase Anda. Untuk setiap association, isi table:

AssociationOwned / ReferenceCascade Saat IniCascade IdealRisiko
CaseFile.notesOwnedALLPERSIST + orphan?Merge graph
CaseFile.organizationReferenceMERGEnoneStale overwrite org
CaseFile.officersReferenceALLnoneDelete shared user

Refactor satu mapping yang paling berisiko.

Latihan 2 — Remove Public Collection Setter

Cari entity dengan:

public void setChildren(List<Child> children)

Ganti dengan:

public void addChild(...)
public void removeChild(...)
public List<Child> children()

Tambahkan invariant minimal.

Latihan 3 — Flush-Clear-Reload Test

Buat test untuk membuktikan:

  1. cascade persist child bekerja;
  2. orphan removal menghapus child yang benar;
  3. shared reference tidak terhapus;
  4. bidirectional helper mengisi FK dengan benar.

Latihan 4 — Split Read Model dari Aggregate

Jika ada repository method yang memuat aggregate besar untuk UI detail, buat projection method baru:

CaseDetailView findDetailView(UUID caseId);

Lalu ubah command service agar memakai:

CaseFile getAggregateForCommand(UUID caseId);

Bandingkan query count.


31. Ringkasan

Aggregate boundary adalah alat untuk mengendalikan object graph persistence. JPA memberi kemampuan cascade, orphan removal, dirty checking, dan relationship mapping, tetapi tidak tahu domain ownership Anda. Developer harus menentukan boundary itu secara eksplisit.

Prinsip utama:

Cascade mengikuti lifecycle ownership, bukan arah navigasi object.

Orphan removal hanya aman untuk private-owned child yang boleh dihapus saat dilepas dari parent.

Command kecil harus memuat dan mengubah graph kecil.

DTO bukan entity graph. Load managed aggregate, panggil method domain, flush perubahan yang disengaja.

Database constraint tetap menjadi safety net untuk integrity dan race condition.

Di Part 008, kita akan membahas value object, embeddable, dan attribute converter. Fokusnya adalah mengurangi primitive obsession, membuat model lebih ekspresif, dan memahami kapan value harus menjadi column group, converter, entity, atau database-specific type.


Referensi

  • Jakarta Persistence 3.2 Specification — lifecycle operations, relationship mapping, cascade operations, embeddables, persistence context semantics.
  • Hibernate ORM User Guide — cascade behavior, orphan removal, bidirectional association synchronization, persistent collection behavior.
  • Domain-Driven Design literature — aggregate root, entity, value object, consistency boundary.
  • Spring Data JPA Reference — repository abstraction, save semantics, transaction integration.
Lesson Recap

You just completed lesson 07 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.