Build CoreOrdered learning track

Value Objects, Embeddables, and Attribute Converters

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

Value objects, embeddables, embedded identifiers, attribute converters, enum mapping, type safety, and production-safe custom value modelling in JPA.

16 min read3047 words
PrevNext
Lesson 0835 lesson track0719 Build Core
#java#jpa#jakarta-persistence#hibernate+8 more

Part 008 — Value Objects, Embeddables, and Attribute Converters

1. Tujuan Part Ini

Part 007 membahas aggregate boundary dan object graph persistence. Sekarang kita masuk ke unit modelling yang lebih kecil tetapi sangat menentukan kualitas domain model: value object.

Banyak codebase JPA terlihat “enterprise”, tetapi masih penuh primitive obsession:

private String status;
private String currency;
private BigDecimal amount;
private String violationCode;
private String organizationId;
private LocalDate startDate;
private LocalDate endDate;

Secara teknis bisa jalan. Secara domain rapuh.

Masalahnya:

  • String tidak menjelaskan format, constraint, atau meaning;
  • BigDecimal tanpa currency bukan money;
  • dua LocalDate belum tentu membentuk date range valid;
  • UUID telanjang mudah tertukar antara CaseId, OfficerId, dan OrganizationId;
  • enum mapping salah bisa merusak data historis;
  • converter yang terlalu pintar bisa menyembunyikan query limitation;
  • embeddable yang mutable bisa membuat dirty checking dan equality membingungkan.

Target Part ini: Anda mampu memilih representasi persistence yang tepat untuk value domain:

  • kapan pakai basic field;
  • kapan pakai enum;
  • kapan pakai @Embeddable;
  • kapan pakai Java record sebagai embeddable;
  • kapan pakai @AttributeConverter;
  • kapan value harus menjadi entity;
  • kapan harus memakai database-specific type;
  • bagaimana mendesain value object yang type-safe, testable, dan tetap ramah JPA.

2. Kaufman Deconstruction: Skill yang Harus Dikuasai

Sub-skill value modelling:

Sub-skillKemampuan yang Diharapkan
Mengenali primitive obsessionTahu kapan String, UUID, BigDecimal, atau LocalDate terlalu lemah
Membedakan entity vs value objectTidak membuat table/entity untuk value yang tidak punya identity
Mendesain embeddableMemetakan beberapa column sebagai satu konsep domain
Mendesain converterMengubah value object ke satu column tanpa bocor ke domain
Memilih enum strategyMenghindari ordinal mapping dan menjaga evolusi status
Membuat typed IDMencegah tertukar antar identity berbeda
Menguji conversion dan mappingMembuktikan round-trip domain-to-column benar
Memahami query implicationsTahu kapan converter/embeddable mempermudah atau menyulitkan query

Value object bukan kosmetik OOP. Ia adalah cara membuat illegal state lebih sulit dibuat.


3. Mental Model: Value Object Tidak Punya Identity Persistence

Entity ditentukan oleh identity.

Value object ditentukan oleh value.

Money a = new Money(new BigDecimal("100.00"), Currency.getInstance("USD"));
Money b = new Money(new BigDecimal("100.00"), Currency.getInstance("USD"));

assert a.equals(b);

Dua Money dengan amount dan currency sama adalah value yang sama. Tidak perlu money_id.

Entity berbeda:

CaseFile c1 = caseRepository.get(caseId);
CaseFile c2 = caseRepository.get(caseId);

assert c1.sameIdentityAs(c2);

Walaupun field berbeda karena waktu load berbeda, identity-nya sama.

3.1 Persistence Consequence

Value object biasanya disimpan:

  • sebagai beberapa column di table owner (@Embeddable);
  • sebagai satu column melalui converter (@AttributeConverter);
  • sebagai basic field dengan validation;
  • sebagai database-specific type jika diperlukan.

Value object tidak seharusnya punya lifecycle repository sendiri kecuali ia sebenarnya entity/reference data.


4. Basic Mapping vs Value Object

Basic mapping:

@Column(name = "risk_score", nullable = false)
private int riskScore;

Masalah:

  • apakah range 0-100 atau 1-5?
  • apakah semakin tinggi semakin berisiko?
  • apakah ada severity label?
  • apakah score boleh berubah setelah case closed?

Value object:

@Embeddable
public record RiskScore(
    @Column(name = "risk_score", nullable = false)
    int value
) {
    public RiskScore {
        if (value < 0 || value > 100) {
            throw new IllegalArgumentException("Risk score must be between 0 and 100");
        }
    }

    public boolean isHighRisk() {
        return value >= 80;
    }
}

Entity:

@Embedded
private RiskScore riskScore;

Kelebihan:

  • invariant dekat dengan value;
  • domain method ekspresif;
  • invalid value sulit dibuat;
  • service layer tidak mengulang validasi angka;
  • code review lebih mudah.

5. @Embeddable: Column Group sebagai Satu Konsep

Embeddable adalah class/value type yang field-nya dipetakan sebagai bagian dari entity owner.

Contoh Money:

@Embeddable
public class Money {

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

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

    protected Money() {
    }

    public Money(BigDecimal amount, Currency currency) {
        this.amount = normalize(amount);
        this.currency = currency.getCurrencyCode();
    }

    public BigDecimal amount() {
        return amount;
    }

    public Currency currency() {
        return Currency.getInstance(currency);
    }

    public Money add(Money other) {
        requireSameCurrency(other);
        return new Money(amount.add(other.amount), currency());
    }

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

Owner:

@Entity
@Table(name = "penalty_decision")
public class PenaltyDecision {

    @Id
    private UUID id;

    @Embedded
    private Money amount;
}

Table:

create table penalty_decision (
    id uuid primary key,
    amount numeric(19,2) not null,
    currency char(3) not null
);

Money tidak punya table sendiri. Column-nya hidup di table owner.


6. Embeddable dengan Column Override

Jika entity punya dua Money, column name default akan bentrok.

@Embedded
private Money assessedAmount;

@Embedded
private Money paidAmount;

Gunakan @AttributeOverrides:

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

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

Kapan override wajib?

  • value object yang sama muncul lebih dari sekali di entity;
  • nested embeddable punya column name generik;
  • database naming convention berbeda dari nama field;
  • migration harus mempertahankan nama column lama.

7. Java Record sebagai Embeddable

Jakarta Persistence 3.2 menambahkan dukungan untuk Java record types sebagai embeddable classes. Ini cocok untuk value object immutable yang sederhana.

Contoh typed id:

@Embeddable
public record OrganizationId(
    @Column(name = "organization_id", nullable = false)
    UUID value
) {
    public OrganizationId {
        Objects.requireNonNull(value, "organization id must not be null");
    }

    public static OrganizationId newId() {
        return new OrganizationId(UUID.randomUUID());
    }
}

Entity:

@Embedded
private OrganizationId organizationId;

Keuntungan record:

  • immutable by default;
  • equality berdasarkan component;
  • constructor canonical cocok untuk invariant;
  • ringkas untuk value object kecil.

Keterbatasan yang perlu diperhatikan:

  • provider support harus sesuai versi yang mendukung Jakarta Persistence 3.2;
  • record tidak cocok untuk value yang perlu lazy association atau mutation internal;
  • beberapa framework mapper/proxy lama mungkin belum ideal;
  • nested record embeddable harus diuji terhadap provider dan database sungguhan.

Untuk codebase yang masih memakai provider lama, class embeddable biasa dengan protected no-arg constructor lebih aman.


8. Typed ID: Menghindari UUID yang Tertukar

Primitive obsession identity sering terlihat seperti ini:

public void assignOfficer(UUID caseId, UUID organizationId, UUID officerId) {
    // easy to swap arguments accidentally
}

Typed ID:

public record CaseId(UUID value) {}
public record OrganizationId(UUID value) {}
public record OfficerId(UUID value) {}

Service menjadi lebih aman:

public void assignOfficer(CaseId caseId, OrganizationId organizationId, OfficerId officerId) {
}

Untuk JPA, ada beberapa strategy.

8.1 Typed ID sebagai Embeddable

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

@Entity
@Table(name = "case_file")
public class CaseFile {
    @EmbeddedId
    private CaseId id;
}

Keuntungan:

  • type-safe di entity;
  • cocok untuk domain model kuat;
  • tidak perlu converter untuk ID field.

Trade-off:

  • repository generic type menjadi JpaRepository<CaseFile, CaseId>;
  • beberapa query/projection perlu akses id.value;
  • embedded id bisa lebih verbose.

8.2 Typed ID dengan AttributeConverter

public record CaseId(UUID value) {
    public CaseId {
        Objects.requireNonNull(value);
    }
}

@Converter(autoApply = true)
public class CaseIdConverter implements AttributeConverter<CaseId, UUID> {
    @Override
    public UUID convertToDatabaseColumn(CaseId attribute) {
        return attribute == null ? null : attribute.value();
    }

    @Override
    public CaseId convertToEntityAttribute(UUID dbData) {
        return dbData == null ? null : new CaseId(dbData);
    }
}

Entity:

@Id
@Column(name = "id", nullable = false)
private CaseId id;

Keuntungan:

  • column tetap UUID;
  • entity field type-safe;
  • cocok untuk single-column value.

Trade-off:

  • provider support untuk converter pada id harus diverifikasi;
  • auto-apply converter bisa mengejutkan jika banyak type mirip;
  • query parameter binding harus dites.

Untuk enterprise codebase, pilih satu convention dan konsisten.


9. @AttributeConverter: Domain Value ke Satu Column

Attribute converter mengubah value object ke database column type dan sebaliknya.

Contoh ViolationCode:

public record ViolationCode(String value) {
    public ViolationCode {
        if (value == null || !value.matches("[A-Z]{2,10}-[0-9]{3,6}")) {
            throw new IllegalArgumentException("Invalid violation code: " + value);
        }
    }
}

Converter:

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

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

    @Override
    public ViolationCode convertToEntityAttribute(String dbData) {
        return dbData == null ? null : new ViolationCode(dbData);
    }
}

Entity:

@Column(name = "violation_code", nullable = false, length = 32)
private ViolationCode violationCode;

Database:

violation_code varchar(32) not null

Converter cocok jika:

  • value disimpan dalam satu column;
  • conversion deterministic;
  • query terhadap underlying column masih sederhana;
  • value object tidak butuh beberapa column;
  • value object tidak punya identity.

10. Converter Auto Apply: Kuat tapi Harus Terkendali

autoApply = true membuat converter diterapkan otomatis untuk semua field dengan type tersebut.

@Converter(autoApply = true)
public class OfficerIdConverter implements AttributeConverter<OfficerId, UUID> {
    // ...
}

Kelebihan:

  • mengurangi annotation berulang;
  • convention kuat;
  • domain type selalu persistable.

Risiko:

  • converter aktif di tempat yang tidak diharapkan;
  • sulit override jika satu field butuh mapping berbeda;
  • developer baru tidak melihat annotation di field;
  • konflik jika dua converter untuk type sama.

Jika value type sangat spesifik seperti OfficerId, CaseId, ViolationCode, auto-apply masuk akal.

Jika type umum seperti Money, EmailAddress, atau JsonString, auto-apply harus dipikirkan ulang.


11. Converter Bukan Tempat Business Logic Berat

Converter harus tipis dan deterministic.

Baik:

return attribute == null ? null : attribute.value();

Buruk:

public String convertToDatabaseColumn(OfficerId attribute) {
    Officer officer = officerDirectoryClient.lookup(attribute);
    return officer.externalCode();
}

Kenapa buruk?

  • converter dipanggil oleh persistence provider, bukan application service;
  • bisa terjadi saat flush, query binding, dirty checking, atau hydration;
  • external call dari converter membuat persistence tidak predictable;
  • error handling sulit;
  • performance tidak terlihat.

Converter tidak boleh:

  • call remote service;
  • query database lain;
  • membaca security context;
  • memakai waktu sekarang;
  • melakukan encryption dengan side effect tanpa design khusus;
  • mengubah value secara non-deterministic.

12. Enum Mapping: Jangan Pakai Ordinal

Buruk:

@Enumerated(EnumType.ORDINAL)
private CaseStatus status;

Jika enum berubah urutan:

DRAFT, UNDER_REVIEW, CLOSED

menjadi:

DRAFT, TRIAGED, UNDER_REVIEW, CLOSED

data lama bisa terbaca sebagai status salah.

Lebih aman:

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

Trade-off:

  • butuh storage lebih besar;
  • rename enum constant menjadi migration data;
  • database constraint harus dijaga.

Lebih production-grade:

alter table case_file
add constraint ck_case_file_status
check (status in ('DRAFT', 'UNDER_REVIEW', 'ESCALATED', 'CLOSED'));

Jika external code harus stabil berbeda dari enum name, gunakan converter.

public enum CaseStatus {
    DRAFT("D"),
    UNDER_REVIEW("UR"),
    ESCALATED("E"),
    CLOSED("C");

    private final String dbCode;

    CaseStatus(String dbCode) {
        this.dbCode = dbCode;
    }

    public String dbCode() {
        return dbCode;
    }
}

Converter:

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

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

    @Override
    public CaseStatus convertToEntityAttribute(String dbData) {
        if (dbData == null) {
            return null;
        }
        return Arrays.stream(CaseStatus.values())
            .filter(s -> s.dbCode().equals(dbData))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("Unknown case status: " + dbData));
    }
}

Ini berguna jika database code harus pendek/stabil dan enum name boleh berubah internal.


13. DateRange sebagai Embeddable

Primitive version:

private LocalDate validFrom;
private LocalDate validTo;

Masalah:

  • validTo bisa sebelum validFrom;
  • inclusive/exclusive boundary tidak jelas;
  • overlap logic tersebar;
  • query logic raw dan duplikatif.

Value object:

@Embeddable
public class DateRange {

    @Column(name = "valid_from", nullable = false)
    private LocalDate from;

    @Column(name = "valid_to", nullable = false)
    private LocalDate to;

    protected DateRange() {
    }

    public DateRange(LocalDate from, LocalDate to) {
        this.from = Objects.requireNonNull(from);
        this.to = Objects.requireNonNull(to);
        if (to.isBefore(from)) {
            throw new IllegalArgumentException("Date range end must not be before start");
        }
    }

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

    public boolean overlaps(DateRange other) {
        return !this.to.isBefore(other.from) && !other.to.isBefore(this.from);
    }
}

Owner:

@Embedded
private DateRange validityPeriod;

DDL:

valid_from date not null,
valid_to date not null,
constraint ck_valid_period check (valid_to >= valid_from)

Java invariant dan database check saling melengkapi.


14. Address sebagai Embeddable: Hati-Hati dengan Meaning

Address sering terlihat sebagai value object:

@Embeddable
public class Address {
    private String line1;
    private String line2;
    private String city;
    private String postalCode;
    private String countryCode;
}

Namun pertanyaan domain penting:

  • Apakah address snapshot saat keputusan diterbitkan?
  • Apakah address berubah mengikuti organization profile?
  • Apakah address perlu history?
  • Apakah address diverifikasi oleh external registry?
  • Apakah address dipakai banyak entity sebagai shared reference?

Jika address adalah snapshot pada dokumen enforcement, embeddable cocok.

Jika address punya lifecycle, verification status, dan history sendiri, mungkin ia entity.

Rule:

Jangan memilih embeddable hanya karena class-nya kecil. Pilih embeddable jika identity dan lifecycle-nya memang milik owner.


15. Value Object yang Berisi Association

JPA membolehkan embeddable berisi mapping tertentu, termasuk association dalam beberapa skenario. Namun dari sisi desain, hati-hati.

Contoh:

@Embeddable
public class Assignment {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "assigned_officer_id")
    private Officer officer;

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

Ini bisa valid, tetapi membuat value object membawa entity reference.

Pertanyaan review:

  • Apakah Assignment masih value object jika berisi Officer entity?
  • Apakah cukup OfficerId?
  • Apakah fetch dan cascade akan mengejutkan?
  • Apakah equality value object harus membandingkan entity atau id?

Sering lebih aman:

@Embeddable
public record Assignment(
    @Embedded OfficerId officerId,
    @Column(name = "assigned_at", nullable = false) Instant assignedAt
) {}

Gunakan entity association hanya jika command benar-benar butuh navigasi object dan lifecycle/fetch behavior sudah jelas.


16. Collections of Value Objects

JPA mendukung element collection untuk basic atau embeddable value.

Contoh:

@ElementCollection
@CollectionTable(
    name = "case_tag",
    joinColumns = @JoinColumn(name = "case_file_id")
)
@Column(name = "tag", nullable = false, length = 64)
private Set<String> tags = new HashSet<>();

Dengan value object:

@ElementCollection
@CollectionTable(
    name = "case_violation_code",
    joinColumns = @JoinColumn(name = "case_file_id")
)
private Set<ViolationCode> violationCodes = new HashSet<>();

Jika pakai embeddable:

@ElementCollection
@CollectionTable(
    name = "case_period",
    joinColumns = @JoinColumn(name = "case_file_id")
)
private Set<DateRange> activePeriods = new HashSet<>();

Kapan element collection cocok?

  • value tidak punya identity;
  • lifecycle sepenuhnya milik owner;
  • jumlah relatif kecil;
  • tidak perlu update individual by id;
  • tidak perlu repository sendiri;
  • tidak sering di-query sebagai aggregate terpisah.

Kapan tidak cocok?

  • collection besar;
  • butuh audit per item;
  • item butuh status/lifecycle;
  • item sering diupdate sendiri;
  • item perlu foreign key dari table lain;
  • query reporting berat terhadap item.

Jika value collection tumbuh besar, pertimbangkan entity child.


17. Dirty Checking dan Immutability

Mutable embeddable:

caseFile.getRiskScore().setValue(90);

Ini buruk karena:

  • mutation tersembunyi;
  • invariant bisa dilewati;
  • dirty checking provider harus mendeteksi perubahan nested;
  • external code bisa memegang reference lama.

Lebih baik value object immutable:

caseFile.updateRiskScore(new RiskScore(90), reason, changedBy, now);

Entity mengganti value:

public void updateRiskScore(RiskScore newScore, String reason, OfficerId changedBy, Instant now) {
    if (status == CaseStatus.CLOSED) {
        throw new IllegalStateException("Closed case risk score cannot change");
    }
    this.riskScore = Objects.requireNonNull(newScore);
    this.riskScoreChangedAt = now;
    this.riskScoreChangedBy = changedBy;
    this.riskScoreChangeReason = requireReason(reason);
}

Mental model:

Entity mutable melalui method domain. Value object sebaiknya immutable dan diganti utuh.


18. Equality dan HashCode untuk Value Object

Value object equality harus berdasarkan semua value yang relevan.

Record otomatis membantu:

public record ViolationCode(String value) {}

Class biasa:

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

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

Hati-hati dengan BigDecimal:

new BigDecimal("1.0").equals(new BigDecimal("1.00")) // false

Jika domain menganggap sama, normalize scale di constructor.

private static BigDecimal normalize(BigDecimal amount) {
    return amount.setScale(2, RoundingMode.UNNECESSARY);
}

Untuk value object dalam Set, equality yang salah bisa menyebabkan duplicate atau delete/update collection yang mengejutkan.


19. Nullability Strategy

Value object bisa null atau tidak tergantung domain.

Buruk:

@Embedded
private DateRange suspensionPeriod;

Tidak jelas apakah null berarti:

  • belum ada suspension;
  • permanent suspension;
  • data belum dimigrasi;
  • optional karena draft;
  • unknown.

Lebih eksplisit:

private boolean suspended;

@Embedded
private DateRange suspensionPeriod;

atau state-specific:

public void suspend(DateRange period, OfficerId officerId, Instant now) {
    if (status != CaseStatus.ACTIVE) {
        throw new IllegalStateException("Only active case can be suspended");
    }
    this.status = CaseStatus.SUSPENDED;
    this.suspensionPeriod = Objects.requireNonNull(period);
}

Database constraint dapat dibuat conditional jika DB mendukung:

alter table case_file
add constraint ck_suspension_period_required
check (
    status <> 'SUSPENDED'
    or (suspension_from is not null and suspension_to is not null)
);

20. Querying Embeddables

Embeddable field bisa dipakai dalam JPQL path.

select c
from CaseFile c
where c.validityPeriod.from <= :date
  and c.validityPeriod.to >= :date

Ini bagus karena domain concept tetap terlihat.

Namun query tetap diterjemahkan ke column.

Jika embeddable sering digunakan di filter, pastikan index sesuai:

create index idx_case_file_validity_period
on case_file(valid_from, valid_to);

Value object tidak menghapus kebutuhan desain index.


21. Querying Converted Attributes

Attribute converter biasanya transparan untuk persistence, tetapi Anda harus menguji query parameter binding.

select c
from CaseFile c
where c.violationCode = :code

Parameter:

query.setParameter("code", new ViolationCode("AML-001"));

Provider harus mengonversi ViolationCode ke column value.

Hal yang perlu dites:

  • equality query;
  • IN query;
  • sorting;
  • criteria query;
  • native query jika digunakan;
  • repository derived query Spring Data;
  • projection.

Native query tidak selalu mendapatkan konversi yang sama otomatis. Jangan asumsikan.


22. When Value Should Become Entity

Value object terlalu dipaksakan jika:

  • butuh identity sendiri;
  • butuh lifecycle sendiri;
  • butuh audit/history sendiri;
  • direferensikan banyak aggregate;
  • punya permission sendiri;
  • perlu update independent;
  • perlu optimistic lock sendiri;
  • jumlahnya besar dan di-query langsung;
  • menjadi target foreign key dari banyak table.

Contoh ViolationCode bisa value object jika hanya kode string.

Tetapi RegulationArticle mungkin entity/reference data:

@Entity
@Table(name = "regulation_article")
public class RegulationArticle {
    @Id
    private RegulationArticleId id;

    private String code;
    private String title;
    private boolean active;
    private LocalDate effectiveFrom;
    private LocalDate effectiveTo;
}

CaseFile cukup menyimpan RegulationArticleId atau association reference tanpa cascade.


23. Database-Specific Types vs JPA Portability

Beberapa value lebih natural sebagai database-specific type:

  • PostgreSQL jsonb;
  • PostgreSQL array;
  • range type;
  • enum type;
  • inet/cidr;
  • geographic types;
  • encrypted column extension.

JPA standard basic/converter bisa cukup untuk banyak kasus, tetapi provider-specific mapping kadang lebih kuat.

Decision rule:

NeedPrefer
Portable simple valueBasic / converter
Multi-column domain valueEmbeddable
Queryable structured JSONProvider-specific JSON mapping atau native query
Heavy reporting on nested JSONNormalize ke table
DB-specific operators neededProvider-specific type/native query
Strict cross-DB portabilityAvoid DB-specific type

Jangan memakai JSON column hanya untuk menghindari modelling. JSON column cocok untuk semi-structured data, bukan alasan untuk membuang relational integrity.


24. Encryption dan Sensitive Values

Sensitive value seperti national id, license number, atau officer credential perlu modelling hati-hati.

Value object:

public record LicenseNumber(String value) {
    public LicenseNumber {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("License number is required");
        }
    }
}

Converter bisa melakukan encryption, tetapi desainnya harus matang:

  • deterministic atau randomized encryption?
  • perlu equality search atau tidak?
  • key rotation bagaimana?
  • audit log boleh melihat plaintext atau tidak?
  • error saat decrypt harus bagaimana?
  • apakah converter punya akses aman ke key management?

Untuk sistem serius, encryption sering lebih baik ditangani oleh infrastructure layer/provider-specific type/database feature, bukan converter sederhana yang memanggil service sembarangan.

Converter encryption boleh, tetapi jangan “diam-diam” tanpa threat model.


25. Bean Validation pada Value Object

Bean Validation bisa ditempatkan pada embeddable:

@Embeddable
public class ContactEmail {

    @Email
    @Column(name = "contact_email", nullable = false)
    private String value;

    protected ContactEmail() {
    }

    public ContactEmail(String value) {
        this.value = Objects.requireNonNull(value).trim().toLowerCase(Locale.ROOT);
    }
}

Namun jangan hanya bergantung pada annotation validation.

Constructor invariant tetap penting:

public ContactEmail(String value) {
    String normalized = Objects.requireNonNull(value).trim().toLowerCase(Locale.ROOT);
    if (normalized.isBlank()) {
        throw new IllegalArgumentException("Email must not be blank");
    }
    this.value = normalized;
}

Layering:

LayerTanggung Jawab
API DTO validationFormat request dan pesan error client
Value object constructorDomain invariant lokal
Entity methodState transition invariant
Database constraintIntegrity final dan race protection

26. Mapping Money dengan Akurat

Money adalah contoh klasik yang sering salah.

Buruk:

private BigDecimal amount;

Lebih baik:

@Embedded
private Money penaltyAmount;

Money design:

@Embeddable
public class Money {

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

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

    protected Money() {
    }

    public Money(BigDecimal amount, String currency) {
        this.amount = normalize(amount);
        this.currency = requireCurrency(currency);
    }

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

    public boolean isPositive() {
        return amount.signum() > 0;
    }

    private static BigDecimal normalize(BigDecimal amount) {
        Objects.requireNonNull(amount);
        return amount.setScale(2, RoundingMode.UNNECESSARY);
    }

    private static String requireCurrency(String currency) {
        Objects.requireNonNull(currency);
        Currency.getInstance(currency);
        return currency;
    }
}

Production considerations:

  • gunakan BigDecimal, bukan double;
  • tentukan precision/scale eksplisit;
  • normalize scale;
  • currency wajib;
  • jangan operasi antar currency tanpa conversion policy;
  • conversion rate bukan bagian dari Money sederhana.

27. Mapping Percentage, Ratio, dan Score

Primitive:

private BigDecimal interestRate;
private int confidenceScore;

Value object:

@Embeddable
public record Percentage(
    @Column(name = "percentage", nullable = false, precision = 5, scale = 2)
    BigDecimal value
) {
    public Percentage {
        Objects.requireNonNull(value);
        if (value.compareTo(BigDecimal.ZERO) < 0 || value.compareTo(new BigDecimal("100.00")) > 0) {
            throw new IllegalArgumentException("Percentage must be between 0 and 100");
        }
    }
}

Namun column name percentage terlalu generik jika dipakai berulang. Gunakan override di owner.

@Embedded
@AttributeOverride(name = "value", column = @Column(name = "confidence_percentage", precision = 5, scale = 2))
private Percentage confidence;

28. Value Object dan API Contract

Jangan expose JPA embeddable mentah sebagai API contract jika itu membuat coupling.

Internal entity:

@Embedded
private Money penaltyAmount;

Response DTO:

public record MoneyResponse(
    BigDecimal amount,
    String currency
) {}

Request DTO:

public record MoneyRequest(
    @NotNull BigDecimal amount,
    @NotBlank String currency
) {}

Mapping:

Money toDomain(MoneyRequest request) {
    return new Money(request.amount(), request.currency());
}

DTO validation memberi pesan client-friendly. Value object constructor menjaga domain safety. Keduanya bukan duplikasi sia-sia karena berada di boundary berbeda.


29. Value Object dan Migration

Mengubah primitive ke value object bukan hanya refactor Java. Ia bisa memengaruhi schema.

Contoh dari:

private BigDecimal penaltyAmount;

menjadi:

@Embedded
private Money penaltyAmount;

Migration:

alter table penalty_decision
add column penalty_currency char(3);

update penalty_decision
set penalty_currency = 'USD'
where penalty_currency is null;

alter table penalty_decision
alter column penalty_currency set not null;

Kemudian mapping:

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

Refactor domain harus disertai migration plan.


30. Testing Value Mapping

30.1 Round-Trip Embeddable Test

@Test
void shouldPersistAndLoadMoneyValue() {
    PenaltyDecision decision = new PenaltyDecision(
        UUID.randomUUID(),
        new Money(new BigDecimal("100.00"), "USD")
    );

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

    PenaltyDecision reloaded = entityManager.find(PenaltyDecision.class, decision.id());

    assertThat(reloaded.amount()).isEqualTo(new Money(new BigDecimal("100.00"), "USD"));
}

30.2 Converter Test

Unit test converter:

@Test
void violationCodeShouldRoundTrip() {
    ViolationCodeConverter converter = new ViolationCodeConverter();

    String db = converter.convertToDatabaseColumn(new ViolationCode("AML-001"));
    ViolationCode value = converter.convertToEntityAttribute(db);

    assertThat(value).isEqualTo(new ViolationCode("AML-001"));
}

Persistence test:

@Test
void shouldQueryByConvertedViolationCode() {
    CaseFile caseFile = persistedCaseWithViolationCode(new ViolationCode("AML-001"));
    entityManager.flush();
    entityManager.clear();

    List<CaseFile> result = entityManager.createQuery("""
        select c
        from CaseFile c
        where c.violationCode = :code
        """, CaseFile.class)
        .setParameter("code", new ViolationCode("AML-001"))
        .getResultList();

    assertThat(result).extracting(CaseFile::id).contains(caseFile.id());
}

30.3 Constraint Test

@Test
void invalidDateRangeShouldFailBeforePersistence() {
    assertThatThrownBy(() -> new DateRange(
        LocalDate.parse("2026-02-01"),
        LocalDate.parse("2026-01-01")
    )).isInstanceOf(IllegalArgumentException.class);
}

Database constraint tetap diuji melalui migration-backed integration test.


31. Review Checklist: Value Mapping

31.1 Domain Expressiveness

  • Apakah String, UUID, BigDecimal, atau LocalDate mewakili konsep domain yang lebih kuat?
  • Apakah value object punya invariant constructor?
  • Apakah method domain lebih ekspresif setelah value object dibuat?

31.2 Persistence Fit

  • Satu column atau beberapa column?
  • Jika satu column, apakah converter cukup?
  • Jika beberapa column, apakah embeddable lebih tepat?
  • Apakah value butuh identity sendiri sehingga harus entity?

31.3 Equality

  • Apakah equals/hashCode benar?
  • Apakah BigDecimal dinormalisasi?
  • Apakah value object aman dipakai dalam Set?

31.4 Query

  • Apakah field akan sering difilter?
  • Apakah converter mendukung query parameter binding?
  • Apakah embeddable column punya index?
  • Apakah native query butuh manual conversion?

31.5 Evolution

  • Apakah enum pakai STRING atau stable code?
  • Apakah rename enum membutuhkan migration?
  • Apakah column constraint menjaga allowed values?
  • Apakah schema migration sudah disiapkan?

32. Common Anti-Patterns

32.1 Primitive Obsession Everywhere

private String violationCode;
private String officerId;
private BigDecimal amount;

Domain meaning hilang.

32.2 Value Object dengan Setter Publik

money.setAmount(new BigDecimal("-10"));

Value object harus sulit dibuat invalid.

32.3 Enum Ordinal

@Enumerated(EnumType.ORDINAL)

Berbahaya untuk evolusi data.

32.4 Converter dengan Side Effect

Converter memanggil API, membaca security context, atau generate value random. Persistence menjadi tidak predictable.

32.5 Embeddable untuk Data yang Punya Lifecycle Sendiri

Jika butuh audit, permission, identity, repository, atau foreign key dari banyak table, kemungkinan bukan value object.

32.6 JSON Column sebagai Tempat Sampah

JSON dipakai untuk menghindari desain schema. Akhirnya tidak ada constraint, index, atau query model yang jelas.


33. Mini Case Study: Regulatory Enforcement Values

Dalam sistem enforcement, kita bisa punya value berikut:

ConceptRepresentationAlasan
CaseIdTyped ID / embeddable / converterMencegah UUID tertukar
OrganizationIdTyped IDReference ke aggregate lain
ViolationCodeConverter ke varcharSingle-column domain code
RiskScoreEmbeddable/recordRange dan behavior isHighRisk()
PenaltyAmountEmbeddable MoneyAmount + currency harus bersama
ValidityPeriodEmbeddable DateRangefrom <= to invariant
RegulationArticleEntity/reference dataPunya lifecycle, version, effective period
EvidenceMetadataEmbeddable atau JSON/provider-specificTergantung query dan schema stability

Model sketch:

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

    @EmbeddedId
    private CaseId id;

    @Embedded
    private OrganizationId organizationId;

    @Column(name = "primary_violation_code", nullable = false, length = 32)
    private ViolationCode primaryViolationCode;

    @Embedded
    @AttributeOverride(name = "value", column = @Column(name = "risk_score", nullable = false))
    private RiskScore riskScore;

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

    public void updateRiskScore(RiskScore newScore, OfficerId changedBy, Instant now) {
        requireOpen();
        this.riskScore = Objects.requireNonNull(newScore);
        this.riskScoreChangedBy = changedBy;
        this.riskScoreChangedAt = now;
    }
}

Notice: value object membuat entity lebih bermakna, bukan lebih rumit.


34. Latihan Deliberate Practice

Latihan 1 — Primitive Obsession Audit

Ambil satu entity production. Tandai field dengan type:

  • String;
  • UUID;
  • BigDecimal;
  • Integer;
  • LocalDate pair;
  • boolean flag.

Untuk setiap field, tanya:

FieldDomain ConceptBasic / Embeddable / Converter / EntityInvariant
amountMoneyEmbeddableamount scale + currency
statusCaseStatusEnum string/codeallowed transition
validFrom/validToDateRangeEmbeddablefrom <= to

Refactor satu value yang paling sering menyebabkan bug.

Latihan 2 — Buat Converter

Buat ViolationCode + converter. Test:

  1. invalid code gagal di constructor;
  2. converter round-trip;
  3. JPQL query by ViolationCode berhasil;
  4. native query behavior jelas.

Latihan 3 — Ganti Enum Ordinal

Jika ada enum ordinal:

  1. buat migration column string/code baru;
  2. backfill dari ordinal lama;
  3. ubah mapping;
  4. tambahkan check constraint;
  5. hapus ordinal lama setelah rollout aman.

Latihan 4 — Embeddable DateRange

Refactor startDate/endDate menjadi DateRange.

Tambahkan:

  • constructor invariant;
  • method contains();
  • method overlaps();
  • database check constraint;
  • query JPQL by range;
  • index review.

35. Ringkasan

Value object adalah salah satu cara paling efektif untuk membuat persistence model lebih aman dan ekspresif.

Prinsip utama:

Entity punya identity. Value object punya meaning dari value-nya.

Gunakan @Embeddable untuk value yang membutuhkan beberapa column dan lifecycle-nya milik owner. Gunakan @AttributeConverter untuk value yang disimpan dalam satu column. Gunakan enum string atau stable code, bukan ordinal. Gunakan typed ID untuk mencegah UUID tertukar. Buat value object immutable jika memungkinkan. Jangan letakkan business logic berat atau side effect di converter. Tetap dukung invariant Java dengan database constraint dan migration yang benar.

Di Part 009, kita akan membahas inheritance dan polymorphic persistence: kapan inheritance mapping membantu, kapan merusak query plan, dan bagaimana memilih antara single table, joined, table-per-class, mapped superclass, composition, atau explicit type field.


Referensi

  • Jakarta Persistence 3.2 Specification — embeddable classes, record embeddables, attribute converters, enum mapping, basic type mapping, element collections.
  • Hibernate ORM User Guide — embeddable mapping, custom type mapping, converter behavior, value collections, provider-specific type support.
  • Spring Data JPA Reference — repository query behavior, parameter binding, projection implications.
  • Domain-Driven Design literature — value object, entity identity, aggregate modelling.
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.