Immutable Object Pattern #
Semua teknik sinkronisasi yang telah kita pelajari — mutex, channel, atomic, sync.Once — punya satu asumsi yang sama: data bisa berubah, dan kita perlu melindungi perubahan itu. Tapi ada pendekatan yang lebih radikal dan sering lebih elegan: buat data yang tidak bisa berubah sama sekali. Jika sebuah objek tidak pernah dimodifikasi setelah dibuat, tidak ada race condition yang mungkin terjadi — karena race condition adalah konflik antara operasi baca dan tulis, dan operasi tulis tidak pernah ada. Tidak perlu mutex. Tidak perlu lock. Tidak perlu atomic. Data bisa dibagikan ke ribuan goroutine dan dibaca semua sekaligus tanpa koordinasi apapun. Ini adalah inti dari Immutable Object Pattern: menghilangkan kebutuhan sinkronisasi dengan menghilangkan akar masalahnya — mutability itu sendiri.
Apa itu Immutable Object Pattern? #
Immutable Object Pattern adalah pendekatan desain di mana sebuah objek tidak dapat diubah setelah dibuat. Seluruh state-nya ditentukan di constructor, tidak ada setter, dan tidak ada method yang memodifikasi field internal.
flowchart LR
Constructor([NewX constructor]) -->|set semua field| Obj[(Immutable Object)]
Obj -->|hanya baca| G1([Goroutine 1])
Obj -->|hanya baca| G2([Goroutine 2])
Obj -->|hanya baca| G3([Goroutine 3])
Obj -->|hanya baca| G4([Goroutine 4])
Note[Tidak ada lock\ntidak ada koordinasi\ntidak ada race condition]
Tiga karakteristik yang mendefinisikan objek immutable:
| Karakteristik | Penjelasan |
|---|---|
| State final | Semua field diinisialisasi di constructor, tidak berubah setelahnya |
| Tidak ada setter | Tidak ada method SetX(), Update(), atau modifikasi field langsung |
| Transformasi menghasilkan objek baru | “Perubahan” dilakukan dengan membuat objek baru yang berbeda |
Di Go, immutability adalah convention-based — tidak ada keyword final atau const untuk struct. Kita menegakkannya melalui disiplin desain: field private, tidak ada setter, dan constructor yang jelas.
Mengapa Immutability Menyelesaikan Masalah Concurrency #
Untuk memahami kekuatan immutability, perlu dipahami terlebih dahulu apa yang menyebabkan race condition.
// ANTI-PATTERN: mutable shared state — sumber race condition
type MutableConfig struct {
Host string // ✗ field public — bisa diubah siapapun
Port int
Timeout int
}
func runWorkers(cfg *MutableConfig) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// Goroutine membaca cfg.Host sementara goroutine lain mungkin menulis
fmt.Println(cfg.Host, cfg.Port) // ✗ data race jika ada writer concurrent
}(i)
}
// Goroutine lain memodifikasi config
go func() {
cfg.Host = "new-host" // ✗ write concurrent = race condition
cfg.Port = 9090
}()
wg.Wait()
}
// BENAR: immutable object — tidak ada race condition secara definisi
type ImmutableConfig struct {
host string // ✓ field private — tidak bisa diubah dari luar
port int
timeout int
}
func NewImmutableConfig(host string, port, timeout int) *ImmutableConfig {
return &ImmutableConfig{host: host, port: port, timeout: timeout}
}
func (c *ImmutableConfig) Host() string { return c.host }
func (c *ImmutableConfig) Port() int { return c.port }
func (c *ImmutableConfig) Timeout() int { return c.timeout }
func runWorkersImmutable(cfg *ImmutableConfig) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println(cfg.Host(), cfg.Port()) // ✓ aman — tidak ada writer concurrent
}(i)
}
wg.Wait()
}
Perbedaannya bukan hanya soal style — ini adalah perbedaan fundamental dalam jaminan keamanan:
flowchart TD
subgraph MUT["Mutable Object"]
MW([Writer goroutine]) -->|write| MD[(Data)]
MR1([Reader 1]) -->|read| MD
MR2([Reader 2]) -->|read| MD
RC[❌ Race Condition\npossible]
end
subgraph IMM["Immutable Object"]
IC([Constructor]) -->|set once| ID[(Data)]
IR1([Reader 1]) -->|read| ID
IR2([Reader 2]) -->|read| ID
IR3([Reader 3]) -->|read| ID
OK[✓ Always safe\nno coordination needed]
end
Implementasi: Value Object #
Value Object adalah contoh paling klasik dari Immutable Object — tipe seperti Money, Coordinate, atau DateRange yang merepresentasikan nilai, bukan entitas yang berubah.
package main
import (
"fmt"
"math"
)
// Money adalah value object immutable yang merepresentasikan jumlah uang
type Money struct {
amount int64 // dalam satuan terkecil (misal: sen)
currency string
}
func NewMoney(amount int64, currency string) Money {
if currency == "" {
panic("currency tidak boleh kosong")
}
return Money{amount: amount, currency: currency}
}
// Getter methods — hanya baca, tidak mengubah state
func (m Money) Amount() int64 { return m.amount }
func (m Money) Currency() string { return m.currency }
func (m Money) String() string {
return fmt.Sprintf("%s %.2f", m.currency, float64(m.amount)/100)
}
// Add menghasilkan Money baru — tidak mengubah m
func (m Money) Add(other Money) Money {
if m.currency != other.currency {
panic(fmt.Sprintf("tidak bisa menambah %s dengan %s", m.currency, other.currency))
}
return Money{amount: m.amount + other.amount, currency: m.currency}
}
// Subtract menghasilkan Money baru — tidak mengubah m
func (m Money) Subtract(other Money) Money {
if m.currency != other.currency {
panic(fmt.Sprintf("tidak bisa mengurangi %s dengan %s", m.currency, other.currency))
}
return Money{amount: m.amount - other.amount, currency: m.currency}
}
// Multiply menghasilkan Money baru
func (m Money) Multiply(factor float64) Money {
return Money{
amount: int64(math.Round(float64(m.amount) * factor)),
currency: m.currency,
}
}
// Coordinate adalah value object immutable untuk posisi geografis
type Coordinate struct {
lat float64
lng float64
}
func NewCoordinate(lat, lng float64) Coordinate {
if lat < -90 || lat > 90 {
panic("latitude harus antara -90 dan 90")
}
if lng < -180 || lng > 180 {
panic("longitude harus antara -180 dan 180")
}
return Coordinate{lat: lat, lng: lng}
}
func (c Coordinate) Lat() float64 { return c.lat }
func (c Coordinate) Lng() float64 { return c.lng }
// DistanceTo menghitung jarak tanpa mengubah state
func (c Coordinate) DistanceTo(other Coordinate) float64 {
// Haversine formula (simplified)
dlat := (other.lat - c.lat) * math.Pi / 180
dlng := (other.lng - c.lng) * math.Pi / 180
a := math.Sin(dlat/2)*math.Sin(dlat/2) +
math.Cos(c.lat*math.Pi/180)*math.Cos(other.lat*math.Pi/180)*
math.Sin(dlng/2)*math.Sin(dlng/2)
return 6371 * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) // km
}
func main() {
// Money: dioperasikan tanpa mutex meski di goroutine berbeda
price := NewMoney(10000, "IDR") // Rp 100.00
tax := NewMoney(1100, "IDR") // Rp 11.00
total := price.Add(tax) // objek baru — price dan tax tidak berubah
fmt.Println("Price:", price)
fmt.Println("Tax:", tax)
fmt.Println("Total:", total)
// Bisa dibagikan ke banyak goroutine tanpa lock
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
discounted := total.Multiply(0.9) // setiap goroutine dapat objek baru
fmt.Printf("[Worker %d] Discounted: %s\n", id, discounted)
}(i)
}
wg.Wait()
}
Perhatikan bahwa Money dideklarasikan sebagai value type (bukan pointer) — ini membuat setiap assignment otomatis membuat salinan, memperkuat immutability secara natural.
Masalah Reference Type: Jebakan Tersembunyi #
Go memiliki beberapa tipe yang bersifat reference secara internal: slice, map, dan pointer. Menyimpan tipe-tipe ini di dalam immutable object tanpa penanganan khusus bisa merusak immutability secara diam-diam.
// ANTI-PATTERN: slice sebagai field langsung — immutability bocor
type BrokenPermissions struct {
roles []string // ✗ slice adalah reference — caller bisa memodifikasi isinya
}
func NewBrokenPermissions(roles []string) *BrokenPermissions {
return &BrokenPermissions{roles: roles} // ✗ menyimpan reference ke slice asli
}
func (p *BrokenPermissions) Roles() []string {
return p.roles // ✗ mengembalikan reference — caller bisa memodifikasi
}
// Dampaknya:
roles := []string{"admin", "user"}
perms := NewBrokenPermissions(roles)
roles[0] = "superadmin" // ✗ memodifikasi roles[] juga memodifikasi perms.roles!
returned := perms.Roles()
returned[1] = "guest" // ✗ memodifikasi perms.roles dari luar!
// BENAR: selalu copy slice saat masuk dan saat keluar
type SafePermissions struct {
roles []string
}
func NewSafePermissions(roles []string) *SafePermissions {
// Copy saat masuk — isolasi dari slice asli
copied := make([]string, len(roles))
copy(copied, roles)
return &SafePermissions{roles: copied} // ✓ menyimpan salinan
}
func (p *SafePermissions) Roles() []string {
// Copy saat keluar — mencegah modifikasi dari luar
result := make([]string, len(p.roles))
copy(result, p.roles)
return result // ✓ mengembalikan salinan
}
func (p *SafePermissions) HasRole(role string) bool {
for _, r := range p.roles {
if r == role {
return true
}
}
return false
}
Aturan yang sama berlaku untuk map dan nilai pointer di dalam struct:
// ANTI-PATTERN: map sebagai field langsung
type BrokenHeaders struct {
headers map[string]string // ✗ map adalah reference
}
func NewBrokenHeaders(h map[string]string) *BrokenHeaders {
return &BrokenHeaders{headers: h} // ✗ menyimpan reference
}
// BENAR: copy map saat masuk
type SafeHeaders struct {
headers map[string]string
}
func NewSafeHeaders(h map[string]string) *SafeHeaders {
copied := make(map[string]string, len(h))
for k, v := range h {
copied[k] = v // ✓ copy setiap entry
}
return &SafeHeaders{headers: copied}
}
func (h *SafeHeaders) Get(key string) (string, bool) {
val, ok := h.headers[key]
return val, ok // string adalah value type — aman dikembalikan langsung
}
func (h *SafeHeaders) All() map[string]string {
// Copy saat keluar
result := make(map[string]string, len(h.headers))
for k, v := range h.headers {
result[k] = v
}
return result // ✓ mengembalikan salinan
}
Pola WithX: Transformasi Immutable #
Ketika kita perlu “mengubah” objek immutable — misalnya user yang berganti nama, atau config yang diperbarui — solusinya adalah membuat objek baru dengan nilai yang berbeda. Ini sering disebut pola WithX.
package main
import "fmt"
// RequestContext adalah immutable object yang dibawa sepanjang request lifecycle
type RequestContext struct {
requestID string
userID int
traceID string
metadata map[string]string
}
func NewRequestContext(requestID string) *RequestContext {
return &RequestContext{
requestID: requestID,
metadata: make(map[string]string),
}
}
// Getter methods
func (r *RequestContext) RequestID() string { return r.requestID }
func (r *RequestContext) UserID() int { return r.userID }
func (r *RequestContext) TraceID() string { return r.traceID }
func (r *RequestContext) Metadata(key string) string {
return r.metadata[key]
}
// WithUserID mengembalikan RequestContext baru dengan userID berbeda
// r tidak dimodifikasi sama sekali
func (r *RequestContext) WithUserID(userID int) *RequestContext {
return &RequestContext{
requestID: r.requestID,
userID: userID, // nilai baru
traceID: r.traceID,
metadata: r.copyMetadata(),
}
}
// WithTraceID mengembalikan RequestContext baru dengan traceID berbeda
func (r *RequestContext) WithTraceID(traceID string) *RequestContext {
return &RequestContext{
requestID: r.requestID,
userID: r.userID,
traceID: traceID, // nilai baru
metadata: r.copyMetadata(),
}
}
// WithMetadata mengembalikan RequestContext baru dengan satu metadata tambahan
func (r *RequestContext) WithMetadata(key, value string) *RequestContext {
newMeta := r.copyMetadata()
newMeta[key] = value
return &RequestContext{
requestID: r.requestID,
userID: r.userID,
traceID: r.traceID,
metadata: newMeta, // map baru dengan entry tambahan
}
}
func (r *RequestContext) copyMetadata() map[string]string {
if r.metadata == nil {
return make(map[string]string)
}
copied := make(map[string]string, len(r.metadata))
for k, v := range r.metadata {
copied[k] = v
}
return copied
}
func main() {
// Bangun context secara bertahap — setiap langkah menghasilkan objek baru
ctx := NewRequestContext("req-abc-123").
WithUserID(42).
WithTraceID("trace-xyz-789").
WithMetadata("source", "mobile").
WithMetadata("version", "2.1.0")
fmt.Printf("RequestID: %s\n", ctx.RequestID())
fmt.Printf("UserID: %d\n", ctx.UserID())
fmt.Printf("TraceID: %s\n", ctx.TraceID())
fmt.Printf("Source: %s\n", ctx.Metadata("source"))
// ctx yang asli tidak berubah meski kita membuat turunannya
ctxAdmin := ctx.WithMetadata("role", "admin")
fmt.Printf("\nctxAdmin source: %s\n", ctxAdmin.Metadata("source"))
fmt.Printf("ctx role (tidak berubah): '%s'\n", ctx.Metadata("role"))
}
Immutable Object + atomic.Value untuk State yang Perlu Diperbarui #
Pola yang sangat powerful adalah menggabungkan Immutable Object dengan atomic.Value untuk situasi di mana state perlu diperbarui sesekali tapi dibaca sangat sering.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// FeatureFlags adalah snapshot immutable dari semua feature flag
type FeatureFlags struct {
flags map[string]bool
}
func NewFeatureFlags(flags map[string]bool) *FeatureFlags {
// Copy untuk memastikan immutability
copied := make(map[string]bool, len(flags))
for k, v := range flags {
copied[k] = v
}
return &FeatureFlags{flags: copied}
}
func (f *FeatureFlags) IsEnabled(feature string) bool {
return f.flags[feature] // aman dibaca concurrent — tidak ada writer
}
func (f *FeatureFlags) All() map[string]bool {
result := make(map[string]bool, len(f.flags))
for k, v := range f.flags {
result[k] = v
}
return result
}
// FeatureFlagStore menyimpan snapshot flags terbaru secara atomic
// Banyak goroutine bisa membaca snapshot saat ini tanpa lock
// Hanya satu goroutine yang perlu lock saat melakukan update
type FeatureFlagStore struct {
current atomic.Value // menyimpan *FeatureFlags
mu sync.Mutex // hanya untuk operasi update
}
func NewFeatureFlagStore(initial map[string]bool) *FeatureFlagStore {
s := &FeatureFlagStore{}
s.current.Store(NewFeatureFlags(initial))
return s
}
// Get mengembalikan snapshot flags saat ini — sangat cepat, tanpa lock
func (s *FeatureFlagStore) Get() *FeatureFlags {
return s.current.Load().(*FeatureFlags)
}
// Update mengganti snapshot dengan versi baru — hanya butuh lock di sini
func (s *FeatureFlagStore) Update(newFlags map[string]bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.current.Store(NewFeatureFlags(newFlags)) // atomic swap
}
func main() {
store := NewFeatureFlagStore(map[string]bool{
"dark_mode": true,
"new_checkout": false,
"beta_dashboard": true,
})
// Banyak goroutine membaca secara concurrent tanpa lock
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
flags := store.Get() // atomic load — tidak ada lock
fmt.Printf("[Worker %d] dark_mode: %v, new_checkout: %v\n",
id, flags.IsEnabled("dark_mode"), flags.IsEnabled("new_checkout"))
}(i)
}
// Satu goroutine melakukan update sesekali
go func() {
time.Sleep(100 * time.Millisecond)
store.Update(map[string]bool{
"dark_mode": true,
"new_checkout": true, // diaktifkan
"beta_dashboard": false,
})
fmt.Println("[Updater] Flags diperbarui")
}()
wg.Wait()
}
Pola ini adalah kombinasi yang sangat efisien:
flowchart LR
U([Updater]) -->|Lock, buat snapshot baru| AV[(atomic.Value)]
AV -->|atomic.Load, tanpa lock| R1([Reader 1])
AV -->|atomic.Load, tanpa lock| R2([Reader 2])
AV -->|atomic.Load, tanpa lock| R3([Reader 3])
Note[Immutable snapshot:\ntidak perlu lock untuk read]
AV --- Note
Copy-on-Write: Variasi untuk Koleksi Besar #
Ketika objek immutable sangat besar dan perubahan sering terjadi, menyalin seluruh objek setiap kali bisa mahal. Copy-on-Write (CoW) adalah solusinya: buat salinan hanya saat benar-benar ada perubahan.
package main
import (
"fmt"
"sync/atomic"
)
// ImmutableList adalah list immutable dengan operasi append
type ImmutableList struct {
items []int
}
func NewImmutableList(items ...int) *ImmutableList {
copied := make([]int, len(items))
copy(copied, items)
return &ImmutableList{items: copied}
}
func (l *ImmutableList) Get(i int) int { return l.items[i] }
func (l *ImmutableList) Len() int { return len(l.items) }
// Append menghasilkan ImmutableList BARU dengan item tambahan
// l tidak dimodifikasi
func (l *ImmutableList) Append(item int) *ImmutableList {
newItems := make([]int, len(l.items)+1)
copy(newItems, l.items)
newItems[len(l.items)] = item
return &ImmutableList{items: newItems} // ✓ objek baru, l tidak berubah
}
// CowStore menggunakan copy-on-write untuk update yang efisien
type CowStore struct {
list atomic.Pointer[ImmutableList]
}
func NewCowStore() *CowStore {
s := &CowStore{}
s.list.Store(NewImmutableList())
return s
}
func (s *CowStore) Read() *ImmutableList {
return s.list.Load() // atomic — tanpa lock
}
func (s *CowStore) Append(item int) {
for {
old := s.list.Load()
newList := old.Append(item) // buat salinan dengan item baru
if s.list.CompareAndSwap(old, newList) {
return // ✓ berhasil diperbarui
}
// gagal — goroutine lain sudah update, coba ulang
}
}
func main() {
store := NewCowStore()
// Banyak goroutine menulis secara concurrent
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(val int) {
defer wg.Done()
store.Append(val)
}(i)
}
wg.Wait()
list := store.Read()
fmt.Printf("List (%d items):", list.Len())
for i := 0; i < list.Len(); i++ {
fmt.Printf(" %d", list.Get(i))
}
fmt.Println()
}
Kapan Immutable Object Kurang Tepat #
Immutable Object sangat cocok untuk:
✓ Value object (Money, Coordinate, Color, DateRange)
✓ Konfigurasi yang jarang berubah
✓ Data yang dikirim antar goroutine via channel
✓ Snapshot state untuk audit log atau event sourcing
✓ Request context yang dibawa sepanjang request lifecycle
Pertimbangkan pendekatan lain jika:
✗ Objek sangat besar dan harus sering diperbarui
(alokasi objek baru setiap update bisa mahal)
✗ Perubahan sangat granular (satu field kecil berubah setiap milidetik)
✗ Objek mewakili entitas stateful (koneksi database, file handle)
✗ Memori terbatas dan garbage pressure harus diminimalkan
Anti-Pattern yang Harus Dihindari #
// ✗ Field public — siapapun bisa memodifikasi dari luar
type BrokenConfig struct {
Host string // ✗ langsung bisa diubah: cfg.Host = "attacker.com"
Port int
Secret string
}
// ✓ Field private dengan getter
type SafeConfig struct {
host string
port int
secret string
}
func (c *SafeConfig) Host() string { return c.host }
func (c *SafeConfig) Port() int { return c.port }
// secret tidak punya getter — tidak perlu diekspos
// ✗ Mengembalikan slice internal langsung
func (c *BrokenData) Items() []string {
return c.items // ✗ caller bisa append atau memodifikasi
}
// ✓ Mengembalikan salinan
func (c *SafeData) Items() []string {
result := make([]string, len(c.items))
copy(result, c.items)
return result // ✓ salinan — modifikasi tidak mempengaruhi internal
}
// ✗ Constructor menerima pointer ke mutable object
type BrokenWrapper struct {
data *ExternalMutable
}
func NewBrokenWrapper(d *ExternalMutable) *BrokenWrapper {
return &BrokenWrapper{data: d} // ✗ caller masih bisa memodifikasi *d
}
// ✓ Ekstrak nilai yang diperlukan saat konstruksi
type SafeWrapper struct {
name string
value int
}
func NewSafeWrapper(d *ExternalMutable) *SafeWrapper {
return &SafeWrapper{
name: d.Name, // ✓ copy nilai, bukan pointer
value: d.Value,
}
}
// ✗ Method yang memodifikasi state internal
func (c *BrokenCounter) Increment() {
c.count++ // ✗ ini bukan immutable — ini mutable dengan nama yang menyesatkan
}
// ✓ Method yang mengembalikan objek baru
func (c *ImmutableCounter) Increment() *ImmutableCounter {
return &ImmutableCounter{count: c.count + 1} // ✓ objek baru
}
Checklist Review Immutable Object #
DESAIN STRUCT:
□ Semua field private (huruf kecil)
□ Constructor (NewX) menginisialisasi semua field yang diperlukan
□ Tidak ada method setter (SetX atau mutating method)
□ Transformasi "perubahan" menghasilkan objek baru (pola WithX)
REFERENCE TYPE:
□ Slice di-copy saat masuk di constructor (bukan menyimpan reference asli)
□ Slice di-copy saat keluar di getter (tidak mengembalikan reference internal)
□ Map di-copy saat masuk dan saat keluar
□ Pointer ke mutable object tidak disimpan sebagai field
PENGGUNAAN:
□ Objek dibagikan antar goroutine tanpa mutex
□ Kombinasi dengan atomic.Value untuk update periodik
□ Pola CoW dipertimbangkan untuk koleksi besar yang sering diperbarui
DOKUMENTASI:
□ Komentar package/struct menyatakan bahwa tipe ini immutable
□ Kontrak immutability didokumentasikan di constructor
□ Warning diberikan jika ada field yang mungkin disalahartikan
PENGUJIAN:
□ Diuji dengan go test -race untuk memastikan tidak ada data race
□ Test memverifikasi bahwa objek tidak berubah setelah transformasi
□ Benchmark mengukur overhead alokasi objek baru pada operasi WithX
Ringkasan #
- Immutable Object menghilangkan race condition secara fundamental — jika tidak ada operasi write, tidak ada konflik antara reader dan writer; tidak perlu mutex, lock, atau atomic sama sekali.
- Di Go, immutability adalah convention — tidak ada keyword
final; kita menegakkannya melalui field private, tidak ada setter, dan constructor yang mengontrol inisialisasi.- Tiga aturan utama implementasi — field harus private, tidak boleh ada setter atau method yang memodifikasi field internal, dan “perubahan” dilakukan dengan membuat objek baru.
- Reference type adalah jebakan — slice, map, dan pointer di dalam struct tidak otomatis immutable; selalu copy saat menerima di constructor dan saat mengembalikan di getter.
- Pola
WithXuntuk transformasi —obj.WithName("baru")mengembalikan objek baru dengan nama berbeda;objasli tidak berubah; ini memungkinkan chaining yang ekspresif.- Kombinasikan dengan
atomic.Valueuntuk state yang perlu diperbarui sesekali tapi dibaca sangat sering — immutable snapshot + atomic swap adalah pola yang sangat efisien.- Copy-on-Write untuk koleksi besar — alih-alih menyalin seluruh koleksi setiap kali, buat salinan hanya saat ada perubahan dan gunakan
CompareAndSwapuntuk update concurrent yang aman.- Value type memperkuat immutability — mendeklarasikan tipe sebagai value (bukan pointer) membuat setiap assignment otomatis membuat salinan; cocok untuk tipe kecil seperti
MoneyatauCoordinate.- Immutable Object adalah primitive terbaik untuk message passing — data yang dikirim antar goroutine via channel idealnya adalah immutable; tidak perlu koordinasi setelah diterima.
- Overhead alokasi adalah trade-off yang perlu diukur — untuk objek kecil yang jarang berubah, immutability hampir gratis; untuk objek besar yang sering berubah, pertimbangkan RWMutex atau pola lain.
← Sebelumnya: Double-Checked Locking Berikutnya: Guarded Suspension →