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.
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:
Stringtidak menjelaskan format, constraint, atau meaning;BigDecimaltanpa currency bukan money;- dua
LocalDatebelum tentu membentuk date range valid; UUIDtelanjang mudah tertukar antaraCaseId,OfficerId, danOrganizationId;- 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-skill | Kemampuan yang Diharapkan |
|---|---|
| Mengenali primitive obsession | Tahu kapan String, UUID, BigDecimal, atau LocalDate terlalu lemah |
| Membedakan entity vs value object | Tidak membuat table/entity untuk value yang tidak punya identity |
| Mendesain embeddable | Memetakan beberapa column sebagai satu konsep domain |
| Mendesain converter | Mengubah value object ke satu column tanpa bocor ke domain |
| Memilih enum strategy | Menghindari ordinal mapping dan menjaga evolusi status |
| Membuat typed ID | Mencegah tertukar antar identity berbeda |
| Menguji conversion dan mapping | Membuktikan round-trip domain-to-column benar |
| Memahami query implications | Tahu 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:
validTobisa sebelumvalidFrom;- 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
Assignmentmasih value object jika berisiOfficerentity? - 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:
| Need | Prefer |
|---|---|
| Portable simple value | Basic / converter |
| Multi-column domain value | Embeddable |
| Queryable structured JSON | Provider-specific JSON mapping atau native query |
| Heavy reporting on nested JSON | Normalize ke table |
| DB-specific operators needed | Provider-specific type/native query |
| Strict cross-DB portability | Avoid 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:
| Layer | Tanggung Jawab |
|---|---|
| API DTO validation | Format request dan pesan error client |
| Value object constructor | Domain invariant lokal |
| Entity method | State transition invariant |
| Database constraint | Integrity 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, bukandouble; - tentukan precision/scale eksplisit;
- normalize scale;
- currency wajib;
- jangan operasi antar currency tanpa conversion policy;
- conversion rate bukan bagian dari
Moneysederhana.
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, atauLocalDatemewakili 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/hashCodebenar? - Apakah
BigDecimaldinormalisasi? - 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
STRINGatau 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:
| Concept | Representation | Alasan |
|---|---|---|
CaseId | Typed ID / embeddable / converter | Mencegah UUID tertukar |
OrganizationId | Typed ID | Reference ke aggregate lain |
ViolationCode | Converter ke varchar | Single-column domain code |
RiskScore | Embeddable/record | Range dan behavior isHighRisk() |
PenaltyAmount | Embeddable Money | Amount + currency harus bersama |
ValidityPeriod | Embeddable DateRange | from <= to invariant |
RegulationArticle | Entity/reference data | Punya lifecycle, version, effective period |
EvidenceMetadata | Embeddable atau JSON/provider-specific | Tergantung 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;LocalDatepair;booleanflag.
Untuk setiap field, tanya:
| Field | Domain Concept | Basic / Embeddable / Converter / Entity | Invariant |
|---|---|---|---|
amount | Money | Embeddable | amount scale + currency |
status | CaseStatus | Enum string/code | allowed transition |
validFrom/validTo | DateRange | Embeddable | from <= to |
Refactor satu value yang paling sering menyebabkan bug.
Latihan 2 — Buat Converter
Buat ViolationCode + converter. Test:
- invalid code gagal di constructor;
- converter round-trip;
- JPQL query by
ViolationCodeberhasil; - native query behavior jelas.
Latihan 3 — Ganti Enum Ordinal
Jika ada enum ordinal:
- buat migration column string/code baru;
- backfill dari ordinal lama;
- ubah mapping;
- tambahkan check constraint;
- 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.
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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.