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 bangunanCiri utamaContoh
EntityPunya identitas yang tetap meski atribut berubahRekening bernomor REK-001
Value objectTanpa identitas, immutable, dibandingkan dari nilaiUang senilai 100000
AggregateEntity root yang menjaga invariant sekelompok dataRekening sebagai gerbang tunggal
Domain serviceOperasi domain yang tidak alami milik satu entityTransfer 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:

go
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:

go
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:

go
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:

bash
go run domain-demo.go

Output yang Dihasilkan:

text
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): true

Mari kita telaah bagian-bagian penting program di atas:

  1. 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.
  2. Baris 14-16 (func (u Uang) Tambah ...): method value receiver mengembalikan nilai baru. Tidak ada satu pun method Uang yang mengubah penerima — sifat immutable dijaga lewat disiplin bentuk, bukan kata kunci.
  3. Baris 18-23 (func (u Uang) Kurang ...): pengurangan yang tidak valid mengembalikan error, bukan hasil negatif. Sentinel ErrSaldoTidakCukup dibungkus dengan %w agar identitasnya tetap bisa diperiksa dari lapisan luar.
  4. Baris 33-38 (func BukaRekening ...): constructor aggregate sekaligus penjaga invariant pertama: setoran awal negatif ditolak sebelum objek pernah ada. Setiap Rekening yang berhasil dibuat pasti sah.
  5. 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.
  6. Baris 76-79 (err = rek.Tarik(500000) ...): percobaan menarik melebihi saldo. Baris 77 membuktikan saldo tidak berubah, dan baris 78 membuktikan error masih dikenali sebagai ErrSaldoTidakCukup meski 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

domain-demo.go

Unduh berkas
go
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))
}