Tiga jenis objek data yang sering dicampur: DTO untuk transfer antar boundary, Entity dengan identitas dan lifecycle, Value Object immutable yang self-validating — mapping antar lapisan, anemic vs rich domain model, dengan class-validator NestJS, struct Fiber, dan Form Request plus Castable Laravel — praktik Money dan Address

Sebagian besar kode backend kotor berawal dari satu kesalahan klasik: memakai satu bentuk objek untuk segalanya — biasanya ORM model — dari HTTP request sampai domain logic. Episode ini menegakkan disiplin tiga jenis objek: DTO, Entity, dan Value Object.
| Jenis | Identitas? | Mutable? | Berisi Logika? | Contoh |
|---|---|---|---|---|
| DTO | Tidak | Boleh | Tidak (data + validasi bentuk) | CreateOrderDto |
| Entity | Ya (id) | Ya (lifecycle) | Ya — perilaku domain | Order |
| Value Object | Tidak (dibedakan nilai) | Immutable | Ya — self-validating | Money, Email, Address |
Aturan alurnya sederhana namun ketat:
VO punya dua properti superpower: ia tidak bisa dalam keadaan tidak valid (validasi di konstruktor) dan aman dibagikan karena tak pernah berubah. Dua VO paling instruktif:
- int 1000 = Rp1.000 atau 1000 cent? -> Money(amount, currency)
- Penjumlahan lintas currency harus ditolak: Money(100,'IDR').add(Money(5,'USD')) -> error di konstruktor/add
- Pembulatan uang punya aturan domain -> logika hidup DI DALAM VO, bukan tersebar
Kenapa Email bukan string?
- Validasi format sekali di konstruktor -> seluruh sistem yakin Email selalu valid
- Type safety: parameter bertipe Email tak menerima "string apapun"Anemic: objek domain hanya getter/setter; semua logika di service luar. Mudah ditulis, tapi bisnis tersebar dan invarian mudah dilanggar. Rich: perilaku tinggal bersama datanya (order.addItem(), money.add()). Keseimbangan pragmatisnya: entity inti layak rich; objek pendukung CRUD polos boleh anemic. Kita dalami ulang lewat aggregate di episode 19.
export class CreateOrderDto {
@IsEmail() customerEmail!: string;
@IsArray() items!: OrderItemDto[];
}
export class Money { // VO
private constructor(
public readonly amountMinor: number,
public readonly currency: 'IDR' | 'USD',
) {
if (amountMinor < 0) throw new InvalidMoneyError();
}
static of(a: number, c: 'IDR' | 'USD') { return new Money(a, c); }
add(o: Money): Money {
if (o.currency !== this.currency) throw new CurrencyMismatchError();
return new Money(this.amountMinor + o.amountMinor, this.currency);
}
}Perhatikan idiom tiap ekosistem: NestJS deklaratif via decorator, Go eksplisit via konstruktor, Laravel menyatukan VO ke ORM lewat cast — tujuan akhirnya sama.
Target outline: model Money + Address sebagai VO dan alirkan DTO→entity→response DTO.
export class Order { // entity
private items: CartItem[] = [];
constructor(
public readonly id: string,
private readonly email: Email, // VO
private total: Money, // VO
) {}
addItem(item: CartItem): void { // perilaku menjaga invarian
this.total = this.total.add(item.price.multiply(item.qty));
this.items.push(item);
}
snapshot(): OrderResponseDto { // mapping keluar
return { id: this.id, total: this.total.amountMinor,
currency: this.total.currency };
}
}Tip
Mulai dari VO bernilai ekonomi tinggi dulu (Money, Email, Quantity). Mereka kecil, dampak bug-nya mahal, dan menjadi pintu masuk paling murah menuju rich domain.
Rangkuman episode ini:
Episode 9 mulai arsitektur layering sesungguhnya: Repository Pattern & DAO — abstraksi persistence yang membuat episode-4 latihan DIP kalian jadi fondasi nyata. Sampai jumpa!