Web Artisan
Beranda

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.

Roadmap 1 · Fondasi Go

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.

Bahasa: Go 1.26~65 menit bacaProyek: Online Shop Skincare
01

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.

🌉Jembatan: kamu sudah pakai pointer di modul lalu

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

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.

02

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.

📦Analogi gudang skincare

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.

Pointer menyimpan alamat, bukan nilai utama product Product value · alamat 0xC0001 SKU: SERUM-001 Name: Brightening Serum Stock: 12 p tipe: *Product isi: 0xC0001 p.Stock = 10 → ubah asli Saat field diubah lewat pointer, value asli ikut berubah karena alamat menunjuk ke lokasi yang sama. Yang disalin saat pointer dikirim ke fungsi hanyalah alamat itu, bukan seluruh struct.
Gambar 1. Variabel 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.go
package 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. }
🧭Catatan istilah

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.

03

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.

JS: object terasa reference
  • Object dikirim sebagai reference value, sehingga mutasi property terlihat di pemanggil.
  • Kamu jarang memikirkan alamat object secara eksplisit.
Go: struct disalin kecuali pakai pointer
  • struct disalin penuh bila parameternya bukan pointer.
  • Pakai *Product bila fungsi harus mengubah Product asli.

Contoh berikut sengaja kecil. Fokusnya bukan logika stok, tetapi bedanya mengubah salinan dan mengubah nilai asli.

cmd/playground/pass_by_value.go
package 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.

⚠️Jebakan dari JS

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.

04

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.go
package 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 }
💡Idiom Go

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.go
package 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 }
05

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.go
package 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.

🌉Jembatan: berbeda dari state React

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.

Value receiver `(o Order)`
  • Cocok untuk method baca pada struct kecil dan terasa immutable.
  • Receiver disalin saat method dipanggil.
  • Perubahan tidak terlihat oleh pemanggil.
Pointer receiver `(o *Order)`
  • 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.
📌Guideline praktis

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.

06

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.go
package 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.

JS / TypeScript
  • description?: string bisa tidak ada.
  • description: null biasanya berarti kosong eksplisit.
  • undefined dan null adalah dua hal berbeda.
Go
  • Description *string bisa nil untuk “tidak ada”.
  • Description string selalu punya value, minimal "".
  • Tidak ada undefined, hanya zero value dan nil.
⚠️Jangan overuse pointer

*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.

07

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.go
package 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
  end

Gambar 4. Pointer membuat hasil repository bisa mewakili entity yang ditemukan, sementara error menjelaskan kondisi gagal atau tidak ditemukan. Service memetakan ErrProductNotFound menjadi HTTP 404.

💡Konvensi yang sehat

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.

08

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.go
package 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.go
package 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.

🌉Jembatan: mirip guard di React

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.

Cek pointer sebelum dipakai

Kalau parameter bertipe *Product, tanyakan apakah nil valid. Bila ya, guard di awal fungsi sebelum field diakses.

Jangan sembunyikan not found

Repository sebaiknya mengembalikan error yang jelas agar service tidak perlu menebak arti nil.

Biarkan panic untuk bug, bukan flow normal

Produk tidak ditemukan adalah 404, bukan panic. Panic disisakan untuk kondisi yang memang menandakan bug programmer.

09

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.go
package 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 }
🧭Jangan over-optimasi

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.

🌉Jembatan: dari garbage collector JS

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.

10

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.

Struktur latihan pointer
  • 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
Buat modul kecil

Gunakan go mod init, lalu set versi Go sesuai proyek dengan go mod edit -go=1.26.

Tulis entity Product

Description dibuat *string untuk nilai opsional, Reserve memakai pointer receiver untuk mutasi stok.

Tulis repository

FindBySKU mengembalikan *Product agar “tidak ditemukan” diwakili nil plus error.

Jalankan dan uji alur

main.go mencari produk, mengurangi stok, lalu mencetak hasilnya. Test memastikan mutasi menempel.

Terminal
mkdir 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.go
package 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.go
package 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.go
package 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.go
package 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) } }
🧪Eksperimen yang menjernihkan

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.

11

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.go
package 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 }
🚫Hati-hati pada modul lama

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.

🌉Jembatan: tidak seperti object PHP

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.

12

Ringkasan & Poin Penting

Pointer adalah konsep kecil yang efeknya besar pada desain backend Go.

Yang Wajib Menempel

  • Go selalu mengirim argumen sebagai value. Kirim Product dan fungsi menerima salinan; kirim *Product dan yang disalin hanyalah alamatnya.
  • &x mengambil alamat x, sedangkan *p membaca atau mengubah nilai yang ditunjuk oleh pointer p. Tidak ada aritmetika pointer di Go.
  • Pointer receiver dipakai saat method perlu mengubah receiver asli, misalnya Order.MarkPaid dan Product.Reserve.
  • nil berarti pointer tidak menunjuk ke value apa pun. Selalu guard sebelum mengakses field bila nil adalah kemungkinan yang sah, agar tidak panic.
  • *string, *int64, dan *time.Time berguna untuk nilai opsional, tetapi jangan menjadikan semua field pointer tanpa alasan domain.
  • Repository sering mengembalikan *Product agar hasil pencarian satu data bisa “ditemukan” atau “tidak ditemukan” lewat nil plus error.
  • Escape analysis membuat return &product aman 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.

Prinsip praktis

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”.

🌉Langkah berikutnya

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.