Series MapLesson 08 / 34
Build CoreOrdered learning track

Learn Java Persistence Part 008 Embeddables Value Objects Records

11 min read2178 words
PrevNext
Lesson 0834 lesson track0718 Build Core

title: Learn Java Persistence, Database Integration, JPA, Hibernate ORM & EclipseLink - Part 008 description: Embeddables, value objects, Java records, AttributeOverride, ElementCollection, dan strategi modelling nilai domain di Jakarta Persistence/JPA. series: learn-java-persistence seriesTitle: Learn Java Persistence, Database Integration, JPA, Hibernate ORM & EclipseLink order: 8 partTitle: Embeddables, Value Objects, and Java Records tags:

  • java
  • persistence
  • jpa
  • jakarta-persistence
  • hibernate
  • eclipselink
  • orm
  • embeddable
  • value-object
  • records
  • domain-modelling
  • series date: 2026-06-27

Embeddables, Value Objects, and Java Records

Target part ini: kamu mampu memodelkan konsep domain kecil tetapi penting sebagai value object yang aman, ekspresif, dan persistence-friendly menggunakan @Embeddable, @Embedded, @AttributeOverride, @ElementCollection, converter, dan Java records.

Part sebelumnya membahas field scalar. Tetapi entity yang berisi terlalu banyak primitive akan cepat membusuk:

private String caseNumber;
private String officerEmail;
private String regionCode;
private BigDecimal penaltyAmount;
private String penaltyCurrency;
private LocalDate periodStart;
private LocalDate periodEnd;

Masalahnya bukan hanya estetika. Primitive-heavy model membuat invariant tersebar:

  • format case number divalidasi di banyak tempat;
  • email bisa invalid setelah load;
  • amount dan currency bisa tidak konsisten;
  • period start bisa setelah period end;
  • region code bisa tidak masuk taxonomy;
  • equality semantic tidak jelas;
  • mapping terlihat sederhana tetapi domain tidak terlindungi.

Value object membantu mengunci semantic.


1. Kaufman Framing

1.1 Deconstruct the Skill

Menguasai embeddable/value object berarti mampu:

  1. membedakan entity dan value object;
  2. memilih antara scalar field, converter, embeddable, entity, dan reference table;
  3. membuat value object immutable atau minimal mutation-safe;
  4. memetakan single-column value object dengan AttributeConverter;
  5. memetakan multi-column value object dengan @Embeddable;
  6. memakai @AttributeOverride untuk reuse embeddable di beberapa context;
  7. memahami nullability multi-column embedded object;
  8. memakai Java records sebagai embeddable modern;
  9. memakai @ElementCollection tanpa menjadikannya pseudo-entity;
  10. menghindari embeddable yang menyembunyikan aggregate boundary buruk.

1.2 Learn Enough to Self-Correct

Pertanyaan self-correction:

  • Apakah konsep ini punya identity sendiri?
  • Apakah value ini dimiliki penuh oleh entity owner?
  • Apakah value ini bisa diganti sebagai satu kesatuan?
  • Apakah value ini perlu query independen?
  • Apakah value ini punya lifecycle terpisah?
  • Apakah mapping-nya satu column atau multi-column?
  • Apakah null berarti object tidak ada, atau field di dalam object tidak lengkap?
  • Apakah value object ini immutable?
  • Apakah equality berbasis semua value?

1.3 Practice Deliberately

Latihan utama: refactor entity primitive-heavy menjadi domain model yang punya value object.

Sebelum:

private BigDecimal penaltyAmount;
private String penaltyCurrency;
private LocalDate violationStartDate;
private LocalDate violationEndDate;
private String officerEmail;

Sesudah:

@Embedded
private Money recommendedPenalty;

@Embedded
private ViolationPeriod violationPeriod;

@Convert(converter = EmailAddressConverter.class)
private EmailAddress officerEmail;

Lalu buktikan:

  • DDL tetap masuk akal;
  • data bisa round-trip;
  • invariant diuji;
  • query tetap efisien;
  • object graph tidak meledak.

2. Mental Model: Entity Has Identity, Value Object Has Meaning

Entity dibedakan oleh identity. Value object dibedakan oleh nilai.

Contoh:

ConceptModel
EnforcementCaseEntity
InvestigatorEntity atau external reference
CaseNumberValue object, single column
MoneyValue object, multi-column
ViolationPeriodValue object, multi-column
AddressValue object, multi-column
CaseTagValue object, element collection jika kecil
ViolationTypeEnum atau reference entity/table
CaseDocumentEntity, karena lifecycle dan metadata sendiri

Rule sederhana:

Jika object bisa ditunjuk, diubah, diaudit, atau dikelola secara independen, mungkin entity.
Jika object hanya menjelaskan sesuatu milik owner dan diganti utuh, mungkin value object.

3. Value Object Properties

Value object yang baik punya karakteristik:

  1. tidak punya persistence identity sendiri;
  2. equality berbasis nilai;
  3. immutable atau mutation-controlled;
  4. valid sejak dibuat;
  5. kecil dan focused;
  6. tidak memanggil repository/service;
  7. tidak menyimpan reference ke aggregate root lain;
  8. bisa diuji tanpa database;
  9. mapping-nya jelas;
  10. aman untuk digunakan di collection.

Contoh value object murni:

public final class CaseNumber {

    private final String value;

    private CaseNumber(String value) {
        this.value = value;
    }

    public static CaseNumber of(String raw) {
        String normalized = raw == null ? null : raw.trim().toUpperCase(Locale.ROOT);
        if (normalized == null || !normalized.matches("EC-[0-9]{4}-[0-9]{6}")) {
            throw new IllegalArgumentException("Invalid case number: " + raw);
        }
        return new CaseNumber(normalized);
    }

    public String value() {
        return value;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof CaseNumber that)) {
            return false;
        }
        return value.equals(that.value);
    }

    @Override
    public int hashCode() {
        return value.hashCode();
    }

    @Override
    public String toString() {
        return value;
    }
}

Mapped with converter:

@Converter(autoApply = true)
public class CaseNumberConverter implements AttributeConverter<CaseNumber, String> {

    @Override
    public String convertToDatabaseColumn(CaseNumber attribute) {
        return attribute == null ? null : attribute.value();
    }

    @Override
    public CaseNumber convertToEntityAttribute(String dbData) {
        return dbData == null ? null : CaseNumber.of(dbData);
    }
}
@Column(name = "case_number", nullable = false, length = 40, updatable = false)
private CaseNumber caseNumber;

Ini bukan @Embeddable karena hanya satu column.


4. AttributeConverter vs Embeddable

Gunakan decision table berikut:

NeedRecommended Mapping
Single scalar representationAttributeConverter
Multi-column value@Embeddable
Independent lifecycleEntity
Managed taxonomy/reference dataReference table/entity
Queryable subfields@Embeddable atau entity, bukan opaque converter
Small owned list of values@ElementCollection
Large mutable child collectionEntity association
Provider/database-specific structured valueProvider-specific type, with boundary

Contoh single-column value object:

private EmailAddress officerEmail;

Converter cocok karena database hanya perlu satu column:

officer_email varchar(320) not null

Contoh multi-column value object:

private Money recommendedPenalty;

Embeddable cocok karena database perlu amount dan currency:

recommended_penalty_amount numeric(19,2)
recommended_penalty_currency varchar(3)

5. Basic @Embeddable and @Embedded

@Embeddable
public class Money {

    @Column(name = "amount", precision = 19, scale = 2, nullable = false)
    private BigDecimal amount;

    @Column(name = "currency", length = 3, nullable = false)
    private String currency;

    protected Money() {
    }

    private Money(BigDecimal amount, String currency) {
        this.amount = normalizeAmount(amount);
        this.currency = normalizeCurrency(currency);
    }

    public static Money of(BigDecimal amount, String currency) {
        return new Money(amount, currency);
    }

    public BigDecimal amount() {
        return amount;
    }

    public String currency() {
        return currency;
    }

    public Money plus(Money other) {
        requireSameCurrency(other);
        return Money.of(this.amount.add(other.amount), this.currency);
    }

    private void requireSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Money money)) {
            return false;
        }
        return amount.compareTo(money.amount) == 0 && currency.equals(money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount.stripTrailingZeros(), currency);
    }
}

Embedded in entity:

@Embedded
@AttributeOverrides({
    @AttributeOverride(
        name = "amount",
        column = @Column(name = "recommended_penalty_amount", precision = 19, scale = 2)
    ),
    @AttributeOverride(
        name = "currency",
        column = @Column(name = "recommended_penalty_currency", length = 3)
    )
})
private Money recommendedPenalty;

Money has no table. Its columns are flattened into owner table.


6. Column Name Collision and @AttributeOverride

Jika entity memakai Money dua kali:

@Embedded
private Money recommendedPenalty;

@Embedded
private Money finalPenalty;

Tanpa override, column amount dan currency bentrok.

Gunakan override:

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "amount", column = @Column(name = "recommended_penalty_amount", precision = 19, scale = 2)),
    @AttributeOverride(name = "currency", column = @Column(name = "recommended_penalty_currency", length = 3))
})
private Money recommendedPenalty;

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "amount", column = @Column(name = "final_penalty_amount", precision = 19, scale = 2)),
    @AttributeOverride(name = "currency", column = @Column(name = "final_penalty_currency", length = 3))
})
private Money finalPenalty;

Rule: embeddable reusable hampir selalu butuh override di owner yang berbeda agar column names punya business context.


7. Multi-Column Invariant

Embeddable paling bernilai saat mengunci invariant multi-field.

Sebelum:

private LocalDate violationStartDate;
private LocalDate violationEndDate;

Bug:

  • start bisa null, end tidak null;
  • end sebelum start;
  • durasi dihitung berbeda-beda;
  • timezone/business date rule tersebar.

Sesudah:

@Embeddable
public class ViolationPeriod {

    @Column(name = "start_date", nullable = false)
    private LocalDate startDate;

    @Column(name = "end_date", nullable = false)
    private LocalDate endDate;

    protected ViolationPeriod() {
    }

    private ViolationPeriod(LocalDate startDate, LocalDate endDate) {
        this.startDate = Objects.requireNonNull(startDate);
        this.endDate = Objects.requireNonNull(endDate);
        if (endDate.isBefore(startDate)) {
            throw new IllegalArgumentException("Violation period end date cannot be before start date");
        }
    }

    public static ViolationPeriod of(LocalDate startDate, LocalDate endDate) {
        return new ViolationPeriod(startDate, endDate);
    }

    public boolean contains(LocalDate date) {
        return !date.isBefore(startDate) && !date.isAfter(endDate);
    }

    public long daysInclusive() {
        return ChronoUnit.DAYS.between(startDate, endDate) + 1;
    }
}

Embedded:

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "startDate", column = @Column(name = "violation_start_date", nullable = false)),
    @AttributeOverride(name = "endDate", column = @Column(name = "violation_end_date", nullable = false))
})
private ViolationPeriod violationPeriod;

Database check constraint masih berguna:

check (violation_end_date >= violation_start_date)

Embeddable menjaga invariant di object model. Database menjaga invariant terhadap semua write path.


8. Nullability of Embedded Objects

Embedded object tidak punya row sendiri. Ia adalah sekumpulan column di owner table.

@Embedded
private Money recommendedPenalty;

Database columns:

recommended_penalty_amount
recommended_penalty_currency

Pertanyaan sulit:

  • apakah recommendedPenalty == null valid?
  • jika valid, apakah kedua column harus null?
  • apa arti amount null tetapi currency non-null?
  • apakah provider saat load akan membuat Money(null, null) atau null?
  • apakah constructor embeddable menerima null untuk provider hydration?

8.1 Optional Embeddable

Jika value optional:

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "amount", column = @Column(name = "recommended_penalty_amount", precision = 19, scale = 2)),
    @AttributeOverride(name = "currency", column = @Column(name = "recommended_penalty_currency", length = 3))
})
private Money recommendedPenalty;

Tambahkan database check:

check (
    (recommended_penalty_amount is null and recommended_penalty_currency is null)
    or
    (recommended_penalty_amount is not null and recommended_penalty_currency is not null)
)

Ini mencegah partial value.

8.2 Mandatory Embeddable

Jika value wajib:

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "amount", column = @Column(name = "recommended_penalty_amount", nullable = false, precision = 19, scale = 2)),
    @AttributeOverride(name = "currency", column = @Column(name = "recommended_penalty_currency", nullable = false, length = 3))
})
private Money recommendedPenalty;

Dan constructor owner harus mengharuskan value:

public EnforcementCase(..., Money recommendedPenalty) {
    this.recommendedPenalty = Objects.requireNonNull(recommendedPenalty);
}

9. Embeddable and Immutability

JPA historically menyukai no-arg constructor dan mutable field untuk hydration. Tetapi domain model lebih aman dengan immutability.

Strategi kompromi:

@Embeddable
@Access(AccessType.FIELD)
public class CaseReference {

    @Column(name = "source_system", nullable = false, length = 40)
    private String sourceSystem;

    @Column(name = "external_reference", nullable = false, length = 100)
    private String externalReference;

    protected CaseReference() {
    }

    private CaseReference(String sourceSystem, String externalReference) {
        this.sourceSystem = normalizeSourceSystem(sourceSystem);
        this.externalReference = requireExternalReference(externalReference);
    }

    public static CaseReference of(String sourceSystem, String externalReference) {
        return new CaseReference(sourceSystem, externalReference);
    }

    public String sourceSystem() {
        return sourceSystem;
    }

    public String externalReference() {
        return externalReference;
    }
}

Field tidak final, tetapi tidak ada setter publik. Mutation dikontrol lewat mengganti seluruh value object.

public void replaceExternalReference(CaseReference newReference) {
    this.caseReference = Objects.requireNonNull(newReference);
}

Ini biasanya cukup baik untuk JPA domain model.


10. Java Records as Embeddables

Jakarta Persistence 3.2 mendukung Java record sebagai embeddable. Ini cocok untuk value object yang immutable dan kecil.

@Embeddable
public record RiskScore(
    @Column(name = "risk_score_points", nullable = false)
    int points,

    @Column(name = "risk_band", nullable = false, length = 20)
    String band
) {
    public RiskScore {
        if (points < 0 || points > 1000) {
            throw new IllegalArgumentException("Risk score must be between 0 and 1000");
        }
        if (band == null || band.isBlank()) {
            throw new IllegalArgumentException("Risk band is required");
        }
    }
}

Embedded:

@Embedded
private RiskScore riskScore;

10.1 Why Records Matter

Records memberi:

  • canonical constructor;
  • final components;
  • value-based equals/hashCode;
  • concise declaration;
  • better semantic signal bahwa object adalah data value.

10.2 Practical Caution

Walaupun spec modern mendukung record embeddable, tetap verifikasi:

  • provider version;
  • build-time enhancement/weaving;
  • reflection/native-image config;
  • constructor validation saat hydration;
  • null handling;
  • framework serialization;
  • query projection compatibility.

Jangan memakai records karena hype. Pakai saat value object memang immutable, kecil, dan tidak butuh lifecycle mutable.


11. Nested Embeddables

Embeddable dapat mengandung embeddable lain.

@Embeddable
public class Address {

    @Column(name = "line1", nullable = false, length = 200)
    private String line1;

    @Column(name = "line2", length = 200)
    private String line2;

    @Embedded
    private PostalCode postalCode;
}
@Embeddable
public class PostalCode {

    @Column(name = "country_code", nullable = false, length = 2)
    private String countryCode;

    @Column(name = "postal_code", nullable = false, length = 20)
    private String value;
}

Owner override bisa menjadi panjang:

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "line1", column = @Column(name = "respondent_line1", length = 200)),
    @AttributeOverride(name = "line2", column = @Column(name = "respondent_line2", length = 200)),
    @AttributeOverride(name = "postalCode.countryCode", column = @Column(name = "respondent_country_code", length = 2)),
    @AttributeOverride(name = "postalCode.value", column = @Column(name = "respondent_postal_code", length = 20))
})
private Address respondentAddress;

Nested embeddable berguna, tetapi jangan sampai entity table menjadi terlalu lebar dan sulit dimigrasikan.


12. Associations Inside Embeddables

Embeddable dapat memiliki relationship, tergantung mapping dan provider support sesuai spec. Contoh:

@Embeddable
public class AssignmentSnapshot {

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "assigned_unit_id", nullable = false)
    private EnforcementUnit assignedUnit;

    @Column(name = "assigned_at", nullable = false)
    private Instant assignedAt;
}

Ini bisa berguna jika relationship adalah bagian dari value yang dimiliki owner.

Tetapi hati-hati:

  • embeddable mulai menarik object graph;
  • lazy loading bisa tersembunyi;
  • equality value object menjadi rumit jika menyertakan entity;
  • aggregate boundary bisa bocor;
  • serialization bisa memicu lazy association.

Rule: value object idealnya tidak menyimpan entity association kecuali alasan model sangat kuat.

Sering lebih aman menyimpan snapshot scalar:

@Embeddable
public class AssignmentSnapshot {

    @Column(name = "assigned_unit_code", nullable = false, length = 40)
    private String assignedUnitCode;

    @Column(name = "assigned_unit_name", nullable = false, length = 200)
    private String assignedUnitName;

    @Column(name = "assigned_at", nullable = false)
    private Instant assignedAt;
}

Snapshot ini lebih cocok untuk audit/regulatory defensibility.


13. @ElementCollection: Collection of Values

Jika entity memiliki kumpulan value object kecil tanpa identity sendiri, gunakan @ElementCollection.

@ElementCollection
@CollectionTable(
    name = "enforcement_case_tag",
    joinColumns = @JoinColumn(name = "case_id")
)
@Column(name = "tag", nullable = false, length = 50)
private Set<String> tags = new LinkedHashSet<>();

Untuk embeddable:

@ElementCollection
@CollectionTable(
    name = "case_external_reference",
    joinColumns = @JoinColumn(name = "case_id")
)
@AttributeOverrides({
    @AttributeOverride(name = "sourceSystem", column = @Column(name = "source_system", nullable = false, length = 40)),
    @AttributeOverride(name = "externalReference", column = @Column(name = "external_reference", nullable = false, length = 100))
})
private Set<CaseReference> externalReferences = new LinkedHashSet<>();

13.1 When ElementCollection Works Well

Cocok untuk:

  • tags kecil;
  • simple codes;
  • small immutable snapshots;
  • contact methods milik owner;
  • historical labels kecil;
  • value list yang tidak perlu repository sendiri.

13.2 When ElementCollection Hurts

Tidak cocok untuk:

  • collection besar;
  • child yang sering di-update individual;
  • child yang butuh audit sendiri;
  • child yang direferensikan entity lain;
  • child yang punya status/lifecycle;
  • child yang perlu query/pagination independen;
  • child yang perlu optimistic locking sendiri.

Jika kamu mulai butuh ID untuk item element collection, itu tanda ia mungkin entity.


14. Value Object Equality

Value object equality harus berdasarkan nilai.

Untuk class biasa:

@Override
public boolean equals(Object o) {
    if (this == o) {
        return true;
    }
    if (!(o instanceof CaseReference that)) {
        return false;
    }
    return sourceSystem.equals(that.sourceSystem)
        && externalReference.equals(that.externalReference);
}

@Override
public int hashCode() {
    return Objects.hash(sourceSystem, externalReference);
}

Untuk record, Java menghasilkan equality berdasarkan semua components.

@Embeddable
public record CaseReference(String sourceSystem, String externalReference) {
}

Caution: jika component mengandung BigDecimal, default equals sensitif terhadap scale.

new BigDecimal("10.0").equals(new BigDecimal("10.00")) // false

Untuk Money, implementasi equality mungkin perlu compareTo dan hashCode normalized.


15. Embeddable vs Entity: Boundary Examples

15.1 Address

Address bisa value object jika hanya menjelaskan respondent pada saat case dibuat.

@Embedded
private Address respondentAddress;

Address bisa entity jika:

  • dikelola independen;
  • punya verification lifecycle;
  • dipakai banyak party;
  • punya audit history;
  • punya geocoding process;
  • punya status deliverability.

15.2 Document

Document jarang embeddable karena biasanya punya:

  • upload lifecycle;
  • storage metadata;
  • hash;
  • content type;
  • size;
  • access control;
  • virus scan status;
  • retention rule;
  • audit trail.

Model as entity:

@Entity
public class CaseDocument {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private EnforcementCase enforcementCase;

    private String storageKey;
    private String sha256Hex;
    private String contentType;
    private long sizeBytes;
}

15.3 Money

Money biasanya value object. Tetapi exchange rate table, currency definition, dan accounting ledger entry adalah entity/reference data.


16. Embeddables in Querying

Embeddable fields dapat diakses lewat path expression.

select c
from EnforcementCase c
where c.violationPeriod.startDate <= :date
  and c.violationPeriod.endDate >= :date

Jika Money embedded:

select c
from EnforcementCase c
where c.recommendedPenalty.amount > :threshold
  and c.recommendedPenalty.currency = :currency

Ini keunggulan embeddable dibanding converter opaque. Subfield tetap queryable.

Tetapi index harus dibuat di database columns:

create index ix_case_violation_period
on enforcement_case (violation_start_date, violation_end_date);

create index ix_case_penalty
on enforcement_case (recommended_penalty_currency, recommended_penalty_amount);

JPA path expression tidak otomatis membuat query cepat.


17. Embeddables and Dirty Checking

Jika embedded object managed entity berubah, provider mendeteksi dirty state saat flush.

Mutable embeddable:

case.getViolationPeriod().setEndDate(newEndDate);

Ini mudah tetapi buruk untuk invariant.

Lebih aman:

case.changeViolationPeriod(ViolationPeriod.of(start, newEnd));

Owner mengganti object sebagai satu unit:

public void changeViolationPeriod(ViolationPeriod newPeriod) {
    if (status == CaseStatus.CLOSED) {
        throw new IllegalStateException("Closed case period cannot be changed");
    }
    this.violationPeriod = Objects.requireNonNull(newPeriod);
}

Dirty checking provider akan melihat field embedded berubah.

Untuk immutable embeddable/record, replacement-style update adalah default mental model.


18. Embeddables and Auditability

Value object tidak punya audit trail sendiri kecuali owner diaudit.

Jika kamu perlu tahu:

  • siapa mengubah address;
  • kapan satu contact method ditambahkan;
  • siapa menghapus external reference;
  • status verification tiap item;
  • historical value per item;

maka embeddable/element collection mungkin tidak cukup.

Gunakan entity atau audit table.

Regulatory rule: jika sesuatu memiliki evidentiary lifecycle, jangan sembunyikan sebagai value object kecil hanya karena mapping lebih mudah.


19. Full Example: EnforcementCase with Value Objects

@Entity
@Table(name = "enforcement_case")
@Access(AccessType.FIELD)
public class EnforcementCase {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "enforcement_case_seq")
    @SequenceGenerator(name = "enforcement_case_seq", sequenceName = "enforcement_case_seq", allocationSize = 50)
    private Long id;

    @Column(name = "case_number", nullable = false, length = 40, updatable = false)
    private CaseNumber caseNumber;

    @Column(name = "title", nullable = false, length = 200)
    private String title;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 40)
    private CaseStatus status;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "startDate", column = @Column(name = "violation_start_date", nullable = false)),
        @AttributeOverride(name = "endDate", column = @Column(name = "violation_end_date", nullable = false))
    })
    private ViolationPeriod violationPeriod;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "amount", column = @Column(name = "recommended_penalty_amount", precision = 19, scale = 2)),
        @AttributeOverride(name = "currency", column = @Column(name = "recommended_penalty_currency", length = 3))
    })
    private Money recommendedPenalty;

    @ElementCollection
    @CollectionTable(
        name = "enforcement_case_tag",
        joinColumns = @JoinColumn(name = "case_id")
    )
    @Column(name = "tag", nullable = false, length = 50)
    private Set<String> tags = new LinkedHashSet<>();

    protected EnforcementCase() {
    }

    public EnforcementCase(CaseNumber caseNumber, String title, ViolationPeriod violationPeriod) {
        this.caseNumber = Objects.requireNonNull(caseNumber);
        this.title = requireTitle(title);
        this.violationPeriod = Objects.requireNonNull(violationPeriod);
        this.status = CaseStatus.OPEN;
    }

    public void recommendPenalty(Money penalty) {
        if (status == CaseStatus.CLOSED) {
            throw new IllegalStateException("Cannot change penalty after case is closed");
        }
        this.recommendedPenalty = Objects.requireNonNull(penalty);
    }

    public void addTag(String tag) {
        this.tags.add(normalizeTag(tag));
    }
}

This model is more expressive than primitive-heavy mapping. Tetapi ia tetap harus diuji terhadap SQL dan schema.


20. DDL Shape

Possible schema:

create table enforcement_case (
    id bigint not null primary key,
    case_number varchar(40) not null,
    title varchar(200) not null,
    status varchar(40) not null,
    violation_start_date date not null,
    violation_end_date date not null,
    recommended_penalty_amount numeric(19, 2),
    recommended_penalty_currency varchar(3),
    constraint uk_enforcement_case_case_number unique (case_number),
    constraint ck_case_violation_period check (violation_end_date >= violation_start_date),
    constraint ck_case_recommended_penalty_complete check (
        (recommended_penalty_amount is null and recommended_penalty_currency is null)
        or
        (recommended_penalty_amount is not null and recommended_penalty_currency is not null)
    )
);

create table enforcement_case_tag (
    case_id bigint not null references enforcement_case(id),
    tag varchar(50) not null,
    primary key (case_id, tag)
);

Notice: embeddable menghasilkan column di owner table; element collection menghasilkan table tambahan.


21. Testing Embeddables

21.1 Pure Unit Test

@Test
void violationPeriodRejectsInvalidRange() {
    LocalDate start = LocalDate.of(2026, 6, 27);
    LocalDate end = LocalDate.of(2026, 6, 1);

    assertThatThrownBy(() -> ViolationPeriod.of(start, end))
        .isInstanceOf(IllegalArgumentException.class);
}

21.2 Persistence Round-Trip Test

@Test
void shouldPersistEmbeddedValueObjects() {
    EnforcementCase c = new EnforcementCase(
        CaseNumber.of("EC-2026-000123"),
        "Market conduct investigation",
        ViolationPeriod.of(
            LocalDate.of(2026, 1, 1),
            LocalDate.of(2026, 3, 31)
        )
    );

    c.recommendPenalty(Money.of(new BigDecimal("250000.00"), "USD"));
    c.addTag("market-abuse");

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

    EnforcementCase loaded = entityManager
        .createQuery(
            "select c from EnforcementCase c where c.caseNumber = :caseNumber",
            EnforcementCase.class
        )
        .setParameter("caseNumber", CaseNumber.of("EC-2026-000123"))
        .getSingleResult();

    assertThat(loaded.violationPeriod().daysInclusive()).isEqualTo(90);
    assertThat(loaded.recommendedPenalty()).isEqualTo(Money.of(new BigDecimal("250000.00"), "USD"));
    assertThat(loaded.tags()).contains("market-abuse");
}

21.3 Constraint Test

Test database constraints, not only Java constructor:

  • insert partial money columns using native SQL and expect failure;
  • insert invalid period and expect failure;
  • insert duplicate element collection value and expect failure;
  • load legacy invalid value and ensure converter/constructor behavior is understood.

22. Common Pitfalls

22.1 Embeddable with Hidden Identity

@Embeddable
public class CaseDocumentInfo {
    private String documentId;
    private String storageKey;
    private String status;
}

If documentId identifies a document with lifecycle, this is probably entity leakage.

22.2 Mutable Shared Value Object

Money penalty = Money.of(new BigDecimal("100.00"), "USD");
caseA.recommendPenalty(penalty);
caseB.recommendPenalty(penalty);
penalty.setAmount(new BigDecimal("999.00"));

Value objects should not be shared mutable state.

22.3 ElementCollection Abuse

@ElementCollection
private List<CaseAction> actions;

If actions have actor, timestamp, state transition, evidence, and audit lifecycle, they are not value collection. They are likely child entities or event log rows.

22.4 Converter Hiding Queryable Data

@Convert(converter = MoneyJsonConverter.class)
private Money penalty;

If you query by amount/currency, this hurts relational power.

22.5 Over-Nesting Embeddables

Too much nesting can make schema obscure and override annotations noisy.

Embeddables should simplify domain, not hide database shape.

22.6 Record Constructor Too Strict for Legacy Data

Record compact constructor validates on hydration. Good for invariants, dangerous if database contains legacy invalid rows.

Before migrating to record embeddables, scan data quality.


23. Design Heuristics

23.1 Use Converter for Scalar Semantic Type

CaseNumber -> varchar(40)
EmailAddress -> varchar(320)
RegionCode -> varchar(20)

23.2 Use Embeddable for Multi-Column Concept

Money -> amount + currency
Period -> start_date + end_date
Address -> line1 + city + postal_code + country
RiskScore -> points + band + model_version

23.3 Use Entity for Lifecycle Concept

CaseDocument
CaseAssignment
EnforcementAction
InvestigationTask
HearingSchedule

23.4 Use Reference Table for Managed Taxonomy

ViolationType
RegulatoryArticle
EnforcementUnit
Jurisdiction
ProductCategory

24. Regulatory Domain Modelling Examples

24.1 RiskScore as Embeddable

@Embeddable
public record RiskScore(
    @Column(name = "risk_score_points", nullable = false)
    int points,

    @Column(name = "risk_band", nullable = false, length = 20)
    String band,

    @Column(name = "risk_model_version", nullable = false, length = 40)
    String modelVersion
) {
    public RiskScore {
        if (points < 0 || points > 1000) {
            throw new IllegalArgumentException("Invalid risk score");
        }
        if (band == null || band.isBlank()) {
            throw new IllegalArgumentException("Risk band is required");
        }
        if (modelVersion == null || modelVersion.isBlank()) {
            throw new IllegalArgumentException("Risk model version is required");
        }
    }
}

Why embeddable?

  • score points, band, and model version form one value;
  • no independent lifecycle;
  • queryable by points/band;
  • useful snapshot for audit.

24.2 EscalationDecision as Entity, Not Embeddable

@Entity
public class EscalationDecision {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private EnforcementCase enforcementCase;

    private String decidedBy;
    private Instant decidedAt;
    private String reason;
}

Why entity?

  • has event/audit lifecycle;
  • may be independently reviewed;
  • may be referenced in appeal;
  • has actor and timestamp;
  • must not be overwritten as a simple value.

24.3 CaseReference as ElementCollection

@ElementCollection
@CollectionTable(name = "case_reference", joinColumns = @JoinColumn(name = "case_id"))
private Set<CaseReference> references = new LinkedHashSet<>();

Works if references are small, immutable, and owned. If references have verification status or workflow, promote to entity.


25. Architecture Review Checklist

For every proposed value object:

1. Does it have independent identity?
2. Is it wholly owned by the containing entity?
3. Can it be replaced as a whole?
4. Does it need its own audit trail?
5. Does it need lifecycle/status?
6. Does another aggregate reference it?
7. Is it single-column or multi-column?
8. Are subfields queryable?
9. Are all invariants enforceable in constructor/factory?
10. Are database constraints needed for cross-column invariants?
11. Does equality use all relevant fields?
12. Is mutability controlled?
13. Does provider support the desired style, especially records?
14. Does loading legacy data pass validation?
15. Is the DDL shape understandable to DBAs and analysts?

26. Mini Lab: Refactor Primitive Case Entity

Start:

@Entity
public class RegulatoryCase {
    @Id
    @GeneratedValue
    private Long id;

    private String caseNo;
    private String status;
    private BigDecimal amount;
    private String currency;
    private LocalDate start;
    private LocalDate end;
    private String sourceSystem;
    private String externalRef;
}

Refactor target:

@Entity
public class RegulatoryCase {
    @Id
    @GeneratedValue
    private Long id;

    @Column(name = "case_number", nullable = false, length = 40, updatable = false)
    private CaseNumber caseNumber;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 40)
    private CaseStatus status;

    @Embedded
    private Money exposure;

    @Embedded
    private ViolationPeriod violationPeriod;

    @Embedded
    private CaseReference sourceReference;
}

Your task:

  1. Decide which concepts use converter vs embeddable.
  2. Add column overrides with explicit names.
  3. Add constructor/factory validation.
  4. Add database check constraints.
  5. Add round-trip persistence test.
  6. Query by violationPeriod.startDate and exposure.currency.
  7. Observe generated SQL.
  8. Decide whether sourceReference should be element collection instead.

27. Key Takeaways

Embeddables and value objects are not “nice-to-have OOP style”. They are tools for making persistence models more defensible:

  • they reduce primitive obsession;
  • they centralize validation;
  • they make equality explicit;
  • they encode multi-column invariants;
  • they keep small owned concepts inside aggregate;
  • they preserve queryability better than opaque converter blobs;
  • records make immutable embeddables more natural in modern Jakarta Persistence.

But value objects can be misused. If a concept has identity, lifecycle, auditability, independent reference, or large collection behavior, model it as entity or reference data instead.

Mental rule:

Embeddable = value owned by entity, stored with owner, replaced as a unit.
Entity = thing with identity, lifecycle, referenceability, and independent history.
Converter = scalar value object with one column representation.
ElementCollection = small owned collection of values, not child workflow records.

In the next part, we move from values to relationships: association modelling core — one-to-one, many-to-one, one-to-many, many-to-many, owning side, inverse side, join column/table, and cardinality truth.

Lesson Recap

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