Progress belajar
Modul 7 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Pointer dan Dasar Memori
untuk Backend Go
Pointer di Go bukan untuk mengelola memori manual seperti C, melainkan alat untuk mutasi yang jelas, nilai yang bisa tidak ada, dan desain repository yang rapi pada backend online shop skincare.
Kenapa Pointer Perlu Dipahami
Pointer bukan topik akademik, ia sudah muncul diam-diam di struct, method, repository, JSON, dan database kita.
Kalau kamu datang dari JavaScript, pointer terasa asing karena object di JS sudah terasa seperti reference secara otomatis, dan kamu jarang memikirkan alamat secara eksplisit.
Di Go, nilai tidak bergerak secara ajaib. Saat kamu mengirim struct ke fungsi, Go mengirim salinan nilai itu. Kalau fungsi perlu mengubah data asli, kamu mengirim alamatnya lewat pointer. Ini penting di backend karena banyak operasi memang bersifat mutasi: mengurangi stok, memindahkan stok ke Reserved, mengubah status order dari pending ke paid, atau mengisi PaymentID setelah callback payment gateway tiba.
Di modul struct, Order.MarkPaid dan Inventory.Reserve memakai pointer receiver (o *Order) dan (i *Inventory) agar mutasi menempel. Modul ini membongkar kenapa tanda * dan & itu wajib ada di sana.
Pointer juga membantu membedakan dua kondisi yang berbeda: field kosong karena memang kosong, atau field tidak ada sama sekali. Di API skincare, Description bisa berisi string kosong, tetapi nil bisa berarti deskripsi belum diisi oleh admin katalog. Perbedaan kecil ini berguna saat membaca data dari PostgreSQL dan saat membentuk response JSON dengan omitempty, persis seperti PaymentID *int64 yang sudah kamu lihat di DTO order.
Pointer adalah nilai yang menyimpan alamat nilai lain. Dalam Go, &x mengambil alamat x, sedangkan *p membaca atau mengubah nilai yang ditunjuk oleh pointer p. Tidak ada aritmetika pointer seperti di C.
Mutasi
Method seperti order.MarkPaid() butuh mengubah field pada order asli, bukan salinannya.
Opsional
*string atau *int64 bisa bernilai nil, cocok untuk field yang boleh tidak ada.
Repository
FindBySKU sering mengembalikan *Product agar hasil yang tidak ditemukan bisa direpresentasikan dengan jelas.
Acuan resmi yang relevan: Effective Go tentang pointer dan allocation, Go Specification tentang Pointer types dan Address operators, serta Tour of Go: Pointers.
Model Mental: Nilai dan Alamat
Anggap nilai sebagai barang di rak gudang, pointer sebagai label yang mencatat nomor rak.
Pointer tidak menyimpan produk, ia menyimpan alamat tempat produk berada.
Dalam kode backend, kamu lebih sering membaca pointer lewat tipe seperti *Product, bukan melihat alamat memori mentah. Kamu tidak perlu mengatur malloc, free, atau memikirkan alamat fisik. Go punya garbage collector. Yang perlu kamu pahami hanyalah kapan kamu sedang bekerja dengan salinan nilai dan kapan kamu sedang bekerja lewat alamat nilai asli.
Value adalah kardus produk di rak. Pointer adalah secarik kertas bertuliskan nomor rak. Kamu bisa menggandakan kertas itu sebanyak mungkin, tetapi semuanya menunjuk ke kardus yang sama. Ubah isi kardus lewat nomor rak, dan siapa pun yang memegang kertas dengan nomor itu akan melihat perubahannya.
product menyimpan nilai struct, sedangkan p menyimpan alamat ke nilai itu. Saat p.Stock diubah, nilai asli ikut berubah karena p berisi alamat yang sama.Tiga istilah ini perlu menempel sebelum lanjut.
Value
Data sebenarnya, misalnya Product{Name: "Serum"} di dalam variabel product.
Address
Lokasi value, diambil dengan &product. Inilah yang disimpan oleh pointer.
Dereference
Mengakses value dari pointer dengan *p. Untuk field struct, Go membuatnya lebih ringkas.
cmd/playground/main.gopackage main import "fmt" type Product struct { SKU string Name string Stock int } func main() { product := Product{SKU: "SERUM-001", Name: "Brightening Serum", Stock: 12} p := &product fmt.Println(product.Name) fmt.Println(p.Name) // Go otomatis membaca (*p).Name untuk field struct. fmt.Println((*p).Name) // Bentuk eksplisit, hasilnya sama. p.Stock = 10 fmt.Println(product.Stock) // 10, karena p menunjuk ke product yang sama. }
Go membuat akses pointer ke field struct terasa natural. p.Name boleh dipakai meski p adalah *Product, karena compiler tahu maksudnya (*p).Name. Gula sintaks ini hanya berlaku untuk field dan method, bukan untuk dereference nilai biasa.
Go Selalu Pass by Value
Setiap argumen fungsi dikirim sebagai salinan. Pointer juga value, tetapi value itu berisi sebuah alamat.
Kesalahan mental paling umum adalah menganggap Go punya pass by reference seperti istilah sehari-hari di JavaScript.
Secara praktis, kamu boleh berkata “fungsi menerima pointer agar bisa mengubah nilai asli”. Tetapi secara teknis Go tetap selalu pass by value. Saat kamu mengirim *Product, yang disalin adalah alamatnya yang berukuran kecil, bukan seluruh struct Product. Karena alamat itu menunjuk ke data asli, perubahan lewat pointer terlihat oleh pemanggil. Tidak ada pengecualian: slice, map, dan channel pun “terasa” seperti reference hanya karena nilainya berupa header kecil yang di dalamnya menyimpan pointer.
- Object dikirim sebagai reference value, sehingga mutasi property terlihat di pemanggil.
- Kamu jarang memikirkan alamat object secara eksplisit.
structdisalin penuh bila parameternya bukan pointer.- Pakai
*Productbila fungsi harus mengubahProductasli.
Contoh berikut sengaja kecil. Fokusnya bukan logika stok, tetapi bedanya mengubah salinan dan mengubah nilai asli.
cmd/playground/pass_by_value.gopackage main import "fmt" type Product struct { SKU string Stock int } // DiscountCopy menerima salinan, perubahan tidak menempel. func DiscountCopy(product Product) { product.Stock-- } // ReserveOne menerima alamat, perubahan menempel ke product asli. func ReserveOne(product *Product) { product.Stock-- } func main() { product := Product{SKU: "TONER-001", Stock: 5} DiscountCopy(product) fmt.Println(product.Stock) // 5, karena yang diubah hanya salinan. ReserveOne(&product) fmt.Println(product.Stock) // 4, karena yang diubah adalah product asli. }
flowchart TD A["product asli, Stock 5"] -->|"DiscountCopy(product), struct disalin"| B["salinan, Stock 4"] B -.->|"fungsi selesai, salinan dibuang"| C["product asli TETAP 5"] A -->|"ReserveOne(&product), kirim alamat"| D["param menunjuk product asli"] D -->|"product.Stock--"| E["product asli JADI 4"]
Gambar 2. Inti pass by value. Kirim struct dan kamu mengubah salinan yang dibuang. Kirim alamat dengan & dan kamu mengubah nilai asli. Ini pola yang sama persis dengan value vs pointer receiver di modul struct.
Kalau kamu menulis fungsi Go yang menerima Product, jangan berharap perubahan field di dalam fungsi terlihat di luar. Compiler tidak protes, kode jalan, tetapi mutasinya diam-diam hilang. Pakai *Product bila mutasi memang bagian dari kontrak fungsi.
Operator & dan *
Dua operator ini cukup untuk membaca hampir semua kode pointer di backend.
& menjawab pertanyaan di mana nilai ini berada, sedangkan * menjawab nilai apa yang ada di alamat itu.
Ada dua makna * yang perlu dibedakan. Di deklarasi tipe, *Product berarti “pointer ke Product”. Di ekspresi, *p berarti “ambil nilai yang ditunjuk oleh pointer p”. Sekali kamu membiasakan dua peran ini, sebagian besar kode pointer langsung terbaca.
&product
Menghasilkan alamat dari variabel product. Tipe hasilnya *Product.
*productPtr
Mengambil nilai Product dari pointer. Inilah yang disebut dereference.
cmd/playground/address.gopackage main import "fmt" type Product struct { Name string } func main() { product := Product{Name: "Sunscreen SPF 50"} productPtr := &product fmt.Printf("type product: %T\n", product) // main.Product fmt.Printf("type productPtr: %T\n", productPtr) // *main.Product value := *productPtr // dereference: ambil salinan Product dari pointer fmt.Println(value.Name) (*productPtr).Name = "Mineral Sunscreen SPF 50" fmt.Println(product.Name) // Mineral Sunscreen SPF 50 }
Untuk field struct, tulis productPtr.Name, bukan (*productPtr).Name, kecuali kamu sedang mengajar atau ingin menekankan proses dereference. Bentuk ringkas itulah yang akan kamu lihat di hampir semua kode produksi.
Ada juga fungsi bawaan new yang mengalokasikan dan mengembalikan pointer. new(Product) memberi *Product ke struct ber-zero value. Sejak Go 1.26, new boleh menerima ekspresi nilai awal, misalnya new(Product{Name: "Serum"}), sehingga lebih ringkas. Walau begitu, di kode domain kita lebih sering memakai composite literal &Product{...} karena lebih jelas membaca field yang diisi.
cmd/playground/new_vs_literal.gopackage main import "fmt" type Product struct { Name string Stock int } func main() { a := new(Product) // *Product ber-zero value: &Product{Name:"", Stock:0} b := &Product{Name: "Serum"} // composite literal, lebih idiomatik untuk domain a.Stock = 3 fmt.Println(a.Stock, b.Name) // 3 Serum }
Pointer Receiver untuk Mutasi
Method dengan value receiver bekerja pada salinan. Method dengan pointer receiver bekerja pada nilai asli.
Pada entity backend, method yang mengubah state hampir selalu memakai pointer receiver.
Di modul struct kamu sudah memakai ini, kini kita lihat alasannya dari dekat. func (o Order) MarkPaid() menerima salinan order. Kalau method itu mengubah o.Status, perubahan hanya terjadi di salinan dan hilang begitu method selesai. Untuk mengubah order asli, receiver harus *Order. Perhatikan bahwa di entity ini PaidAt dan PaymentID sengaja bertipe pointer agar bisa kosong sebelum order dibayar, tema yang kita lanjutkan di bagian berikutnya.
internal/order/order.gopackage order import "time" type Status string const ( StatusPending Status = "pending" StatusPaid Status = "paid" ) type Order struct { ID int64 UserID int64 Status Status PaymentID *int64 // nil sebelum dibayar PaidAt *time.Time // nil sebelum dibayar } // MarkPaid mengubah state, jadi receiver wajib *Order. func (o *Order) MarkPaid(paymentID int64, paidAt time.Time) { o.Status = StatusPaid o.PaymentID = &paymentID o.PaidAt = &paidAt } // IsPaid hanya membaca, value receiver sudah cukup. func (o Order) IsPaid() bool { return o.Status == StatusPaid }
flowchart TD O["order asli, Status pending"] -->|"value receiver: (o Order)"| V["salinan o, Status jadi paid"] V -.->|"method selesai, salinan dibuang"| X["order asli TETAP pending"] O -->|"pointer receiver: (o *Order)"| P["o menunjuk order asli"] P -->|"o.Status = paid"| Y["order asli JADI paid"]
Gambar 3. Value receiver menyalin order lalu membuang salinannya, sehingga mutasi MarkPaid lenyap. Pointer receiver memegang alamat order asli, sehingga transisi status benar-benar menempel.
Di contoh ini, MarkPaid memakai pointer receiver karena mengubah Status, PaymentID, dan PaidAt. IsPaid memakai value receiver karena hanya membaca. Dalam praktik, banyak tim memilih konsisten memakai pointer receiver untuk seluruh method pada entity yang punya satu saja method mutasi, agar method set-nya seragam. Konsistensi ini penting saat tipe bertemu interface di modul berikutnya, karena method dengan pointer receiver hanya masuk method set *Order, bukan Order.
Di React kamu tidak boleh mutasi state langsung karena render bergantung pada immutable update. Di Go domain model, mutasi lewat method justru idiomatik selama transisi state jelas dan diuji. Dua dunia ini punya aturan berbeda, jadi jangan bawa refleks “jangan mutasi” dari React ke entity Go.
- Cocok untuk method baca pada struct kecil dan terasa immutable.
- Receiver disalin saat method dipanggil.
- Perubahan tidak terlihat oleh pemanggil.
- Wajib bila method harus mengubah receiver asli.
- Lebih efisien untuk struct besar dan membuat niat mutasi jelas.
- Masuk method set
*Order, penting untuk interface nanti.
Jangan pakai pointer hanya demi menghemat beberapa byte. Pakai pointer karena ada mutasi, struct besar, atau nilai yang bisa tidak ada. Mulai dari value, naik ke pointer saat alasannya muncul.
Nilai Opsional dengan nil
`nil` adalah ketiadaan nilai untuk pointer, slice, map, channel, func, dan interface.
Di Go tidak ada undefined. Untuk nilai opsional pada scalar atau waktu, pointer sering menjadi cara yang eksplisit.
Kalau field bertipe string, zero value-nya adalah string kosong. Tetapi kadang string kosong bukan berarti “tidak ada”. Dalam API skincare, Description kosong bisa berarti produk memang tidak punya deskripsi pendek, sedangkan nil bisa berarti datanya belum diisi oleh admin katalog. Pola ini sama persis dengan PaidAt *time.Time dan PaymentID *int64 pada Order: keduanya nil sebelum order dibayar, lalu terisi setelah MarkPaid.
internal/product/model.gopackage product type Product struct { ID int64 `json:"id"` SKU string `json:"sku"` Name string `json:"name"` PriceRupiah int64 `json:"price_rupiah"` Description *string `json:"description,omitempty"` // nil = belum diisi ImageURL *string `json:"image_url,omitempty"` // nil = tanpa gambar } // StringPtr membantu membuat *string dari literal, karena &"teks" tidak valid. func StringPtr(value string) *string { return &value }
Dengan omitempty, field pointer yang nil hilang dari JSON. Ini berbeda dari string kosong yang tetap punya nilai. Pilihan ini harus sengaja, jangan menjadikan semua field pointer hanya karena takut zero value. PriceRupiah tetap int64 biasa karena harga selalu punya nilai, dan harga 0 pun informasi yang sah.
description?: stringbisa tidak ada.description: nullbiasanya berarti kosong eksplisit.undefineddannulladalah dua hal berbeda.
Description *stringbisaniluntuk “tidak ada”.Description stringselalu punya value, minimal"".- Tidak ada
undefined, hanya zero value dannil.
*bool, *int, atau *string berguna untuk nilai opsional, tetapi kalau semua field dibuat pointer, kode penuh cek nil dan domain model jauh lebih sulit dibaca. Jadikan field pointer hanya saat “tidak ada” benar-benar berbeda artinya dari zero value.
Repository Mengembalikan Pointer
Data yang dicari bisa ada, bisa tidak ada. Pointer membantu merepresentasikan kemungkinan itu.
Saat repository mencari satu row, hasilnya tidak selalu ada. *Product memberi ruang untuk nilai yang tidak ditemukan.
Nanti saat masuk PostgreSQL dan pgx, pola ini sering muncul. Repository menerima context.Context, mencari data berdasarkan key, lalu mengembalikan pointer ke entity atau error. Untuk operasi FindBySKU, Product value saja kurang ekspresif karena zero value Product{} bisa terlihat seperti produk valid padahal sebenarnya tidak ditemukan.
internal/product/repository.gopackage product import ( "context" "errors" ) var ErrProductNotFound = errors.New("product not found") type Repository interface { FindBySKU(ctx context.Context, sku string) (*Product, error) } type InMemoryRepository struct { products map[string]*Product } func NewInMemoryRepository(products map[string]*Product) *InMemoryRepository { return &InMemoryRepository{products: products} } func (r *InMemoryRepository) FindBySKU(ctx context.Context, sku string) (*Product, error) { p, ok := r.products[sku] if !ok { return nil, ErrProductNotFound } return p, nil }
Untuk data yang akan dimutasi, simpan *Product di map, bukan Product. Dengan map[string]*Product, pemanggil dan map menunjuk ke struct yang sama, sehingga Reserve benar-benar mengubah stok yang tersimpan. Kalau map berisi value (map[string]Product), setiap pembacaan mengembalikan salinan, dan mutasi pada hasil pencarian tidak akan menempel di map.
sequenceDiagram
participant Handler as Product Handler
participant Service as Product Service
participant Repo as Product Repository
participant Store as Data Store
Handler->>Service: GetProductDetail(ctx, sku)
Service->>Repo: FindBySKU(ctx, sku)
Repo->>Store: cari berdasarkan sku
alt ditemukan
Store-->>Repo: row produk
Repo-->>Service: "*Product, nil"
Service-->>Handler: response detail (200)
else tidak ditemukan
Store-->>Repo: tidak ada row
Repo-->>Service: "nil, ErrProductNotFound"
Service-->>Handler: 404 Not Found
endGambar 4. Pointer membuat hasil repository bisa mewakili entity yang ditemukan, sementara error menjelaskan kondisi gagal atau tidak ditemukan. Service memetakan ErrProductNotFound menjadi HTTP 404.
Untuk repository, hindari return nil, nil saat data tidak ditemukan, karena pemanggil harus menebak artinya. Lebih jelas pakai ErrProductNotFound, lalu service memetakannya ke 404 dengan errors.Is yang kamu pelajari di modul fungsi dan error.
Nil Dereference dan Cara Menghindarinya
`nil` berguna, tetapi dereference pointer nil akan panic dan bisa menjatuhkan request.
nil mirip null dari rasa, tetapi Go memaksa kamu berhadapan dengan tipe pointer secara eksplisit.
Pointer yang bernilai nil tidak menunjuk ke value apa pun. Kalau kamu mengakses field atau dereference pointer itu, program panic dengan pesan invalid memory address or nil pointer dereference. Di backend, panic yang tidak ditangani bisa memutus request dan menghasilkan 500. Maka pola paling aman adalah guard clause sebelum memakai pointer.
cmd/playground/nil_panic.gopackage main import "fmt" type Product struct { Name string } func main() { var product *Product // nil, belum menunjuk ke apa pun fmt.Println(product.Name) // panic: invalid memory address or nil pointer dereference }
Versi aman menambahkan guard clause di awal, persis pola yang kamu lihat di control flow.
internal/product/service.gopackage product import "errors" var ErrMissingProduct = errors.New("missing product") func DisplayName(p *Product) (string, error) { if p == nil { return "", ErrMissingProduct } return p.Name, nil }
flowchart TD
A["DisplayName(p *Product)"] --> B{"p == nil?"}
B -->|"ya"| C["return ErrMissingProduct"]
B -->|"tidak"| D["akses p.Name dengan aman"]
D --> E["return nama, nil"]
C --> F["pemanggil tangani error, bukan panic"]Gambar 5. Guard clause memotong jalur menuju nil dereference. Alih-alih panic dan 500, pemanggil menerima error yang bisa dipetakan ke 404 atau 400.
Di React kamu sering menulis if (!user) return null sebelum membaca user.name. Di Go, pola yang sama muncul sebagai if p == nil { return ..., err } sebelum mengakses field. Refleksnya identik, hanya bentuknya yang berbeda.
Kalau parameter bertipe *Product, tanyakan apakah nil valid. Bila ya, guard di awal fungsi sebelum field diakses.
Repository sebaiknya mengembalikan error yang jelas agar service tidak perlu menebak arti nil.
Produk tidak ditemukan adalah 404, bukan panic. Panic disisakan untuk kondisi yang memang menandakan bug programmer.
Sekilas Stack, Heap, dan Escape Analysis
Cukup tahu garis besarnya. Go yang memutuskan, bukan kamu.
Kamu tidak perlu mengelola alokasi memori manual di Go, tetapi memahami garis besarnya membantu membaca keputusan desain pointer.
Variabel lokal yang hidup singkat biasanya ditaruh di stack, yang cepat dan otomatis dibersihkan saat fungsi selesai. Nilai yang masih dipakai setelah fungsi selesai, misalnya karena alamatnya dikembalikan, ditaruh di heap dan dikelola garbage collector. Proses compiler menentukan ini disebut escape analysis. Yang penting untuk dipahami: mengembalikan &product dari sebuah fungsi tetap aman di Go, tidak seperti C, karena compiler otomatis memindahkan nilai itu ke heap.
internal/product/factory.gopackage product // Aman di Go: compiler mendeteksi product "escape" lewat return, // lalu mengalokasikannya di heap. Tidak ada dangling pointer. func NewActiveProduct(sku, name string, price int64) *Product { p := Product{SKU: sku, Name: name, PriceRupiah: price} return &p }
Pemula sering memaksa pointer demi “menghindari salinan”. Untuk struct kecil yang hanya dibaca, value justru lebih cepat dan lebih jelas. Pilih pointer karena alasan desain (mutasi, opsional, kontrak repository), bukan karena tebakan performa. Bila perlu, buktikan dengan benchmark dan go build -gcflags=-m untuk melihat keputusan escape analysis.
Sama seperti V8 di JavaScript, Go punya garbage collector, jadi kamu tidak memanggil free. Bedanya, di Go kamu masih memilih secara eksplisit antara value dan pointer, sehingga kamu lebih sadar kapan data disalin dan kapan dibagikan.
Hands-on: Product Repository In-Memory
Kita memakai pointer untuk field opsional, hasil repository, dan mutasi stok dalam satu alur kecil.
Latihan ini sengaja belum memakai database agar fokus tetap pada pointer dan mutasi.
- cmd/
- pointer-demo/
- main.go jalankan contoh end-to-end
- internal/
- product/
- model.go entity Product dan method mutasi
- repository.go kontrak dan repository in-memory
- repository_test.go uji Reserve dan FindBySKU
- go.mod
Gunakan go mod init, lalu set versi Go sesuai proyek dengan go mod edit -go=1.26.
Description dibuat *string untuk nilai opsional, Reserve memakai pointer receiver untuk mutasi stok.
FindBySKU mengembalikan *Product agar “tidak ditemukan” diwakili nil plus error.
main.go mencari produk, mengurangi stok, lalu mencetak hasilnya. Test memastikan mutasi menempel.
Terminalmkdir skincare-backend cd skincare-backend go mod init github.com/kamu/skincare-backend go mod edit -go=1.26 mkdir -p cmd/pointer-demo internal/product go run ./cmd/pointer-demo go test ./...
internal/product/model.gopackage product import "errors" var ( ErrMissingProduct = errors.New("missing product") ErrInsufficientStock = errors.New("insufficient stock") ) type Product struct { SKU string Name string Description *string // nil = belum diisi Stock int } // NewDescription membungkus literal menjadi *string. func NewDescription(value string) *string { return &value } // Reserve memakai pointer receiver karena mengubah Stock asli. func (p *Product) Reserve(quantity int) error { if p == nil { return ErrMissingProduct } if quantity <= 0 { return errors.New("quantity must be positive") } if p.Stock < quantity { return ErrInsufficientStock } p.Stock -= quantity return nil }
internal/product/repository.gopackage product import ( "context" "errors" ) var ErrProductNotFound = errors.New("product not found") type Repository interface { FindBySKU(ctx context.Context, sku string) (*Product, error) } type InMemoryRepository struct { products map[string]*Product } func NewInMemoryRepository(products map[string]*Product) *InMemoryRepository { return &InMemoryRepository{products: products} } func (r *InMemoryRepository) FindBySKU(ctx context.Context, sku string) (*Product, error) { p, ok := r.products[sku] if !ok { return nil, ErrProductNotFound } return p, nil }
cmd/pointer-demo/main.gopackage main import ( "context" "fmt" "log" "github.com/kamu/skincare-backend/internal/product" ) func main() { ctx := context.Background() repo := product.NewInMemoryRepository(map[string]*product.Product{ "SERUM-001": { SKU: "SERUM-001", Name: "Brightening Serum", Description: product.NewDescription("Serum ringan untuk pagi dan malam."), Stock: 12, }, }) item, err := repo.FindBySKU(ctx, "SERUM-001") if err != nil { log.Fatal(err) } if err := item.Reserve(2); err != nil { log.Fatal(err) } fmt.Printf("%s sisa stok: %d\n", item.Name, item.Stock) // 10 if item.Description != nil { fmt.Println(*item.Description) } }
Tulis test kecil untuk membuktikan dua hal: mutasi lewat pointer benar-benar menempel di map, dan “tidak ditemukan” menghasilkan error yang jelas.
internal/product/repository_test.gopackage product import ( "context" "errors" "testing" ) func TestReserveMutatesStoredProduct(t *testing.T) { repo := NewInMemoryRepository(map[string]*Product{ "SERUM-001": {SKU: "SERUM-001", Name: "Brightening Serum", Stock: 12}, }) item, err := repo.FindBySKU(context.Background(), "SERUM-001") if err != nil { t.Fatalf("find failed: %v", err) } if err := item.Reserve(2); err != nil { t.Fatalf("reserve failed: %v", err) } again, _ := repo.FindBySKU(context.Background(), "SERUM-001") if again.Stock != 10 { t.Fatalf("stock not persisted: got %d want 10", again.Stock) } } func TestFindBySKUNotFound(t *testing.T) { repo := NewInMemoryRepository(map[string]*Product{}) _, err := repo.FindBySKU(context.Background(), "GHOST-999") if !errors.Is(err, ErrProductNotFound) { t.Fatalf("expected ErrProductNotFound, got %v", err) } }
Ganti map[string]*Product menjadi map[string]Product, lalu jalankan ulang test. TestReserveMutatesStoredProduct akan gagal karena FindBySKU kini mengembalikan salinan, sehingga Reserve mengubah salinan itu, bukan produk di map. Itulah cara tercepat merasakan beda value dan pointer dalam penyimpanan.
Jebakan Umum dari JS dan PHP
Sebagian bug pointer pemula lahir dari membawa kebiasaan reference JS dan object PHP terlalu jauh.
Pointer terasa sederhana, tetapi beberapa detail kecil sering mengejutkan developer JavaScript dan PHP.
Mengira Go pass by reference
Go selalu pass by value. Yang membuat mutasi terlihat di pemanggil bukan “reference”, melainkan pointer yang isinya alamat. Tanpa * di receiver atau parameter, mutasi hilang diam-diam.
Lupa pointer receiver untuk mutasi
MarkPaid, Reserve, dan teman-temannya wajib pointer receiver. Value receiver mengubah salinan yang langsung dibuang, tanpa error apa pun dari compiler.
Dereference pointer nil
Membaca field dari *Product yang nil memicu panic dan 500. Selalu guard if p == nil saat nil adalah kemungkinan yang sah.
Semua field dijadikan pointer
*string, *bool, dan *int untuk semua field membuat kode penuh cek nil. Pakai pointer hanya saat “tidak ada” berbeda makna dari zero value.
Repository balik nil, nil
Saat tidak ada data, kembalikan nil, ErrProductNotFound, bukan nil, nil. Pemanggil tidak boleh menebak arti nil.
Memakai &item lama di range loop
Dulu &item di dalam for _, item := range items berbahaya karena semua pointer menunjuk variabel yang sama. Sejak Go 1.22 variabel loop dibuat per iterasi, jadi pola ini aman selama go.mod menyatakan go1.22 atau lebih.
Jebakan terakhir layak dilihat dari dekat karena sangat khas. Sebelum Go 1.22, variabel item dibuat sekali lalu dipakai ulang tiap iterasi, sehingga menyimpan &item menghasilkan banyak pointer ke satu variabel yang nilainya terus berubah.
internal/order/collect.gopackage order import "github.com/kamu/skincare-backend/internal/product" // Sejak Go 1.22, item adalah variabel baru di tiap iterasi, // jadi &item menunjuk elemen yang benar, bukan elemen terakhir saja. func PointersTo(products []product.Product) []*product.Product { out := make([]*product.Product, 0, len(products)) for _, item := range products { out = append(out, &item) // aman di go.mod yang menyatakan go1.22+ } return out }
Perilaku per iterasi hanya berlaku bila go.mod menyatakan go 1.22 atau lebih. Pada modul lama dengan versi bahasa di bawah itu, &item masih mengulang variabel yang sama dan menghasilkan slice pointer yang semuanya menunjuk elemen terakhir. Ini sumber bug yang sangat sulit dilacak.
Di PHP object dilewatkan lewat handle, sehingga method selalu memutasi object asli. Di Go, struct adalah value type yang disalin secara default, dan kamu yang memutuskan kapan memakai pointer. Kuasa itu sekaligus tanggung jawab: salah pilih, mutasi senyap hilang.
Ringkasan & Poin Penting
Pointer adalah konsep kecil yang efeknya besar pada desain backend Go.
Yang Wajib Menempel
- Go selalu mengirim argumen sebagai value. Kirim
Productdan fungsi menerima salinan; kirim*Productdan yang disalin hanyalah alamatnya. &xmengambil alamatx, sedangkan*pmembaca atau mengubah nilai yang ditunjuk oleh pointerp. Tidak ada aritmetika pointer di Go.- Pointer receiver dipakai saat method perlu mengubah receiver asli, misalnya
Order.MarkPaiddanProduct.Reserve. nilberarti pointer tidak menunjuk ke value apa pun. Selalu guard sebelum mengakses field bilaniladalah kemungkinan yang sah, agar tidak panic.*string,*int64, dan*time.Timeberguna untuk nilai opsional, tetapi jangan menjadikan semua field pointer tanpa alasan domain.- Repository sering mengembalikan
*Productagar hasil pencarian satu data bisa “ditemukan” atau “tidak ditemukan” lewatnilplus error. - Escape analysis membuat
return &productaman tanpa manajemen memori manual. Pilih pointer karena desain, bukan tebakan performa.
Di proyek online shop skincare, pointer muncul di entity domain, DTO response, repository PostgreSQL, transaksi checkout, dan update status payment. Kamu sudah merangkainya dari MarkPaid, Reserve, FindBySKU, hingga field opsional seperti Description dan PaidAt.
Mulai dari value biasa. Naik ke pointer hanya saat ada alasan jelas: mutasi, nilai opsional, struct besar, atau kontrak repository yang perlu mewakili “tidak ditemukan”.
Modul berikutnya masuk ke slice dan map, koleksi data yang paling sering kamu pakai untuk menyimpan daftar produk, item cart, dan index berbasis SKU. Di sana pointer kembali muncul, terutama saat memilih antara []Product dan []*Product, serta saat memahami kenapa slice dan map “terasa” seperti reference padahal tetap tunduk pada aturan pass by value yang baru saja kamu kuasai.
Progress disimpan lokal di browser ini.