Lapisan dan dependency injection hanyalah perancah; kekuatan sejati aplikasi ada pada model domainnya — cara konsep bisnis diterjemahkan menjadi tipe di Go. Ketika model hanya berupa struct kosong berisi data yang diisi dan dimodifikasi dari luar, aturan bisnis menyebar ke semua tempat dan masing-masing menginterpretasikan sendiri. Pada chapter ini kita membedah empat blok bangunan domain, menjaga invariant “saldo tidak boleh negatif” lewat method yang mengembalikan error, dan memakai domain error agar kegagalan aturan bisnis bisa dikenali secara programatik.
Empat Blok Bangunan Domain #
Pemodelan domain klasik (dari DDD, Domain-Driven Design) mengenal empat blok bangunan yang peran di Go:
| Blok bangunan | Ciri utama | Contoh |
|---|---|---|
| Entity | Punya identitas yang tetap meski atribut berubah | Rekening bernomor REK-001 |
| Value object | Tanpa identitas, immutable, dibandingkan dari nilai | Uang senilai 100000 |
| Aggregate | Entity root yang menjaga invariant sekelompok data | Rekening sebagai gerbang tunggal |
| Domain service | Operasi domain yang tidak alami milik satu entity | Transfer antar dua rekening |
Perbedaan entity dan value object sering membingungkan. Dua Rekening dengan nomor sama adalah satu rekening yang sama — nomor adalah identitas. Sebaliknya dua Uang senilai seratus ribu tidak bisa dibedakan dan tidak ada alasan membedakannya; karena itu Uang dirancang immutable: setiap operasi mengembalikan nilai baru dan tidak pernah mengubah penerima.
Invariant: Saldo Tidak Boleh Negatif #
Invariant adalah kondisi yang harus selalu benar pada setiap titik waktu. Lawannya adalah model anemic — struct berisi field publik tanpa perilaku:
type RekeningAnemik struct {
Saldo int64 // publik: siapa pun bisa menulis -1000000
}
func tarik(rek *RekeningAnemik, nominal int64) {
rek.Saldo -= nominal // invariant dilanggar tanpa suara
}Fragmen di atas memindahkan tanggung jawab aturan ke setiap pemanggil tarik; satu pemanggil lupa memeriksa, saldo pun negatif. Versi yang sehat menutup field dan membuka method yang menegakkan invariant:
func (r *Rekening) Tarik(nominal Uang) error {
sisa, err := r.saldo.Kurang(nominal)
if err != nil {
return fmt.Errorf("tarik dari %s: %w", r.nomor, err)
}
r.saldo = sisa
return nil
}Method Tarik mengembalikan error ketika penarikan akan melanggar invariant, sehingga mustahil mengubah saldo menjadi negatif lewat jalur resmi. Diagram berikut merangkum posisi method sebagai penjaga gerbang aggregate:
Kode luar hanya bisa berinteraksi lewat method; field saldo tidak diekspor sehingga tidak ada jalan pintas.
Domain Error yang Bisa Diidentifikasi #
Aturan bisnis yang gagal adalah kejadian normal, bukan bug, sehingga pantas mendapat error khusus. Kita mendeklarasikan sentinel ErrSaldoTidakCukup lalu membungkusnya dengan %w di sepanjang rantai. Handler atau use case kemudian bisa membedakan “saldo kurang” (layak dibalas 422 atau pesan ramah) dari “database mati” (layak dibalas 500) menggunakan errors.Is, seperti yang kita latih pada chapter errors.Is dan errors.As
.
Demo: Rekening dengan Value Object Uang #
Berkas domain-demo.go menerapkan aggregate Rekening dengan value object Uang, lalu menguji jalur sukses dan jalur gagal:
package main
import (
"errors"
"fmt"
)
var ErrSaldoTidakCukup = errors.New("saldo tidak cukup")
// ===== Value object: Uang (immutable) =====
type Uang int64 // dalam Rupiah, tanpa sen
func (u Uang) Tambah(lain Uang) Uang {
return u + lain // nilai BARU; penerima tidak berubah
}
func (u Uang) Kurang(lain Uang) (Uang, error) {
if lain > u {
return 0, fmt.Errorf("%w: butuh %d, tersedia %d", ErrSaldoTidakCukup, lain, u)
}
return u - lain, nil
}
// ===== Aggregate root: Rekening =====
type Rekening struct {
nomor string
pemilik string
saldo Uang
}
func BukaRekening(nomor, pemilik string, setorAwal Uang) (*Rekening, error) {
if setorAwal < 0 {
return nil, errors.New("setoran awal tidak boleh negatif")
}
return &Rekening{nomor: nomor, pemilik: pemilik, saldo: setorAwal}, nil
}
func (r *Rekening) Setor(nominal Uang) {
r.saldo = r.saldo.Tambah(nominal)
}
func (r *Rekening) Tarik(nominal Uang) error {
sisa, err := r.saldo.Kurang(nominal)
if err != nil {
return fmt.Errorf("tarik dari %s: %w", r.nomor, err)
}
r.saldo = sisa
return nil
}
func (r Rekening) Saldo() Uang {
return r.saldo
}
func (r Rekening) Ringkas() string {
return fmt.Sprintf("%s (%s): Rp%d", r.nomor, r.pemilik, r.saldo)
}
func main() {
rek, err := BukaRekening("REK-001", "Budi", 100000)
if err != nil {
fmt.Println("gagal:", err)
return
}
rek.Setor(50000)
fmt.Println(rek.Ringkas())
if err := rek.Tarik(20000); err != nil {
fmt.Println("gagal:", err)
}
fmt.Println(rek.Ringkas())
err = rek.Tarik(500000)
fmt.Println("tarik Rp500.000:", err)
fmt.Println("saldo tetap:", rek.Ringkas())
fmt.Println("errors.Is(err, ErrSaldoTidakCukup):", errors.Is(err, ErrSaldoTidakCukup))
}Jalankan program dengan perintah berikut dari direktori chapter:
go run domain-demo.goOutput yang Dihasilkan:
REK-001 (Budi): Rp150000
REK-001 (Budi): Rp130000
tarik Rp500.000: tarik dari REK-001: saldo tidak cukup: butuh 500000, tersedia 130000
saldo tetap: REK-001 (Budi): Rp130000
errors.Is(err, ErrSaldoTidakCukup): trueMari kita telaah bagian-bagian penting program di atas:
- Baris 12 (
type Uang int64): value object dinyatakan sebagai named type sederhana. Tipenya membedakan rupiah dari bilangan biasa sehingga compiler menolak mencampurnya tanpa konversi eksplisit. - Baris 14-16 (
func (u Uang) Tambah ...): method value receiver mengembalikan nilai baru. Tidak ada satu pun methodUangyang mengubah penerima — sifat immutable dijaga lewat disiplin bentuk, bukan kata kunci. - Baris 18-23 (
func (u Uang) Kurang ...): pengurangan yang tidak valid mengembalikan error, bukan hasil negatif. SentinelErrSaldoTidakCukupdibungkus dengan%wagar identitasnya tetap bisa diperiksa dari lapisan luar. - Baris 33-38 (
func BukaRekening ...): constructor aggregate sekaligus penjaga invariant pertama: setoran awal negatif ditolak sebelum objek pernah ada. SetiapRekeningyang berhasil dibuat pasti sah. - Baris 44-51 (
func (r *Rekening) Tarik ...): satu-satunya cara mengurangi saldo. Validasi, pengubahan, dan pelaporan error terjadi dalam satu method sehingga invariant tidak pernah terlewati. - Baris 76-79 (
err = rek.Tarik(500000) ...): percobaan menarik melebihi saldo. Baris 77 membuktikan saldo tidak berubah, dan baris 78 membuktikan error masih dikenali sebagaiErrSaldoTidakCukupmeski sudah dibungkus dua lapis.
Domain yang sehat membuat aturan bisnis berpusat, tetapi aplikasi yang membesar menghadapi masalah baru: bagaimana membagi ratusan file menjadi modul yang batasnya tegas? Chapter berikutnya membangun modular monolith dari package-package dengan kontrak interface.
Referensi #
- Errors are values — filosofi error Go yang mendasari domain error.
- Effective Go — named type dan method receiver sebagai alat pemodelan.
- Package fmt
— pembungkusan error dengan
%w.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
domain-demo.go
Unduh berkaspackage main
import (
"errors"
"fmt"
)
var ErrSaldoTidakCukup = errors.New("saldo tidak cukup")
// ===== Value object: Uang (immutable) =====
type Uang int64 // dalam Rupiah, tanpa sen
func (u Uang) Tambah(lain Uang) Uang {
return u + lain // nilai BARU; penerima tidak berubah
}
func (u Uang) Kurang(lain Uang) (Uang, error) {
if lain > u {
return 0, fmt.Errorf("%w: butuh %d, tersedia %d", ErrSaldoTidakCukup, lain, u)
}
return u - lain, nil
}
// ===== Aggregate root: Rekening =====
type Rekening struct {
nomor string
pemilik string
saldo Uang
}
func BukaRekening(nomor, pemilik string, setorAwal Uang) (*Rekening, error) {
if setorAwal < 0 {
return nil, errors.New("setoran awal tidak boleh negatif")
}
return &Rekening{nomor: nomor, pemilik: pemilik, saldo: setorAwal}, nil
}
func (r *Rekening) Setor(nominal Uang) {
r.saldo = r.saldo.Tambah(nominal)
}
func (r *Rekening) Tarik(nominal Uang) error {
sisa, err := r.saldo.Kurang(nominal)
if err != nil {
return fmt.Errorf("tarik dari %s: %w", r.nomor, err)
}
r.saldo = sisa
return nil
}
func (r Rekening) Saldo() Uang {
return r.saldo
}
func (r Rekening) Ringkas() string {
return fmt.Sprintf("%s (%s): Rp%d", r.nomor, r.pemilik, r.saldo)
}
func main() {
rek, err := BukaRekening("REK-001", "Budi", 100000)
if err != nil {
fmt.Println("gagal:", err)
return
}
rek.Setor(50000)
fmt.Println(rek.Ringkas())
if err := rek.Tarik(20000); err != nil {
fmt.Println("gagal:", err)
}
fmt.Println(rek.Ringkas())
err = rek.Tarik(500000)
fmt.Println("tarik Rp500.000:", err)
fmt.Println("saldo tetap:", rek.Ringkas())
fmt.Println("errors.Is(err, ErrSaldoTidakCukup):", errors.Is(err, ErrSaldoTidakCukup))
}