Materi 1 · Modul 0
Kenalan sama Go
Bahasa yang sengaja dibuat kecil. Fiturnya sedikit, dan itu justru alasan orang memilihnya.
Tiga hal yang membuat Go berbeda
Dikompilasi jadi satu binary. Hasil go build adalah satu file yang bisa dijalankan tanpa runtime apa pun. Tidak ada PHP-FPM, tidak ada node_modules, tidak ada interpreter yang harus terpasang di server. Kirim satu file, jalankan, selesai.
Bertipe statis dan dikompilasi cepat. Kesalahan tipe ketangkap sebelum program jalan, tapi compile-nya hitungan detik — bukan menit seperti bahasa bertipe statis lainnya.
Concurrency jadi bagian bahasa. Menjalankan ribuan pekerjaan bersamaan cuma butuh kata kunci go. Ini bukan library tambahan, tapi bawaan bahasanya, dan inilah alasan utama orang memilih Go untuk pekerjaan tertentu.
Coba sendiri: kenapa concurrency penting
Bayangkan memanggil tiga API yang masing-masing butuh 1 detik. Klik dua tombol di bawah dan bandingkan.
pilih salah satu di atas…
// berurutan — 3 detik
a := ambilUser()
b := ambilPesanan()
c := ambilProduk()
// concurrent — 1 detik
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); a = ambilUser() }()
go func() { defer wg.Done(); b = ambilPesanan() }()
go func() { defer wg.Done(); c = ambilProduk() }()
wg.Wait() // tunggu ketiganya selesaiGoroutine bukan thread
Satu thread sistem operasi memakan sekitar 1 MB memori. Satu goroutine mulai dari sekitar 2 KB, dan Go sendiri yang mengatur pemetaan ribuan goroutine ke beberapa thread. Karena itu menjalankan 100.000 goroutine adalah hal yang wajar di Go, sementara 100.000 thread akan membuat servermu tumbang.
Catatan — Ini yang membedakan Go dari Node dalam hal concurrency. Node menangani banyak koneksi dengan satu thread dan event loop, jadi satu perhitungan berat membekukan semuanya. Go menjalankan goroutine di beberapa core sungguhan, jadi pekerjaan berat di satu goroutine tidak memblokir yang lain. Untuk kerja yang menunggu I/O keduanya setara; untuk kerja yang membebani CPU, Go jauh lebih baik.
Yang tidak ada di Go, dan itu disengaja
| Tidak ada | Gantinya |
|---|---|
| Class dan pewarisan | Struct + interface + komposisi |
| Exception / try-catch | Error sebagai nilai yang dikembalikan |
| Nilai default parameter | Struct opsi, atau beberapa fungsi |
| Operator overloading, macro | Tidak ada — kode selalu berarti apa yang tertulis |
| Framework web resmi | net/http di pustaka standar sudah memadai |
Daftar itu terasa seperti kekurangan di awal. Efek praktisnya: kode Go orang lain hampir selalu bisa kamu baca, karena tidak banyak cara berbeda untuk menulis hal yang sama. Tidak ada lapisan abstraksi pintar yang harus dipelajari dulu sebelum paham apa yang terjadi.
Harga yang kamu bayar
Jujur di awal supaya tidak kaget:
- Kodenya lebih panjang. Fitur yang di framework-framework populer bisa selesai dalam 20 baris sering butuh 60 baris di Go, karena penanganan error ditulis eksplisit di setiap langkah.
- Tidak ada ORM kaya fitur atau admin panel bawaan. Banyak framework web modern menyediakan alat otomatis untuk berbicara dengan database dan membuat halaman admin secara instan. Go tidak menyediakan itu — kamu menulis SQL sendiri dan membangun panel adminnya sendiri kalau perlu.
- Harus VPS. Go tidak bisa jalan di shared hosting atau cPanel biasa.
- Library lokal lebih tipis. Integrasi Midtrans, Fonnte, dan sejenisnya biasanya harus kamu panggil lewat HTTP sendiri.
Materi 31 membahas kapan harga itu layak dibayar dan kapan tidak, dengan lebih rinci.
Materi 2 · Modul 0
Setup dan perintah go
Pasang Go, bikin project pertama, dan kenali perintah yang akan kamu pakai setiap hari.
Pasang Go
# Linux / WSL
wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
# tambahkan ke ~/.bashrc atau ~/.zshrc
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:$(go env GOPATH)/bin
go version # go version go1.23.0 linux/amd64Di macOS pakai brew install go, di Windows pakai installer resmi atau kerja di WSL2. Untuk editor, VS Code dengan ekstensi Go resmi sudah lengkap: autocomplete, format otomatis, dan debugger.
Project pertama
mkdir toko-api && cd toko-api
go mod init github.com/yunusalmeida/toko-apimodule github.com/yunusalmeida/toko-api
go 1.23Nama module biasanya berupa path repositori. Bukan karena harus online, tapi karena itulah cara orang lain mengimpor package-mu nanti. File ini mencatat nama module dan versi Go yang dipakai — seperti daftar identitas project.
package main
import "fmt"
func main() {
fmt.Println("Halo dari Go")
}go run . # compile ke memori lalu jalankan
go build -o bin/api # hasilkan binary
./bin/apiCatatan — package main dan fungsi main() adalah syarat program yang bisa dijalankan. Package dengan nama lain adalah pustaka yang diimpor package lain, bukan program mandiri.
Perintah yang sering dipakai
| Perintah | Gunanya |
|---|---|
go run . | Jalankan tanpa menyimpan binary |
go build ./... | Compile semua package, ketahuan error kompilasi |
go test ./... | Jalankan seluruh test |
go get github.com/x/y | Tambah dependency |
go mod tidy | Rapikan: tambah yang dipakai, buang yang tidak |
go fmt ./... | Format kode sesuai standar |
go vet ./... | Deteksi kesalahan yang lolos compiler |
go doc net/http | Baca dokumentasi dari terminal |
gofmt: perdebatan format sudah selesai
Go punya satu format resmi, dan gofmt menerapkannya. Tidak ada opsi untuk tab vs spasi, tidak ada perdebatan soal kurung kurawal. Semua kode Go di dunia terlihat sama. Nyalakan "format on save" di editor dan lupakan topik ini selamanya.
Struktur folder yang akan kita pakai
toko-api/
├── go.mod
├── go.sum ← checksum dependency, wajib di-commit
├── cmd/
│ └── api/
│ └── main.go ← titik masuk
├── internal/ ← tidak bisa diimpor project lain
│ ├── config/
│ ├── handler/ ← lapisan HTTP
│ ├── service/ ← logika bisnis
│ ├── repository/ ← akses database
│ ├── model/ ← struct data
│ └── middleware/
├── migrations/
└── .envCatatan — Folder bernama internal punya arti khusus di Go: package di dalamnya hanya bisa diimpor oleh kode dalam module yang sama. Compiler yang menegakkannya. Ini cara Go menandai "ini urusan dalam, jangan dipakai dari luar" tanpa perlu dokumentasi.
Dependency
go get github.com/jackc/pgx/v5
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt
go mod tidyTidak ada folder vendor atau node_modules secara bawaan — dependency disimpan di cache global dan ditautkan saat build. Sejak Go 1.21, versi Go yang dipakai juga tercatat di go.mod, jadi build di komputermu dan di server memakai versi yang sama.
Materi 3 · Modul 0
Sintaks dasar
Variabel, tipe, dan alur kontrol. Bagian yang paling cepat dilewati kalau kamu sudah pernah ngoding.
Variabel
// bentuk lengkap
var nama string = "Kaos"
// tipe ditebak
var harga = 85000
// bentuk singkat — paling sering dipakai, hanya di dalam fungsi
stok := 12
aktif := true
// beberapa sekaligus
var (
host = "localhost"
port = 8080
)
// konstanta
const MaksUpload = 2 << 20 // 2 MBHati-hati — Variabel yang dideklarasikan tapi tidak dipakai adalah error kompilasi, bukan peringatan. Begitu juga import yang tidak terpakai. Awalnya menjengkelkan, tapi efeknya: tidak ada kode mati yang menumpuk di project Go.
Zero value: tidak ada null di mana-mana
Setiap tipe punya nilai awal yang pasti. Variabel tanpa nilai eksplisit tidak pernah "tidak terdefinisi".
var s string // ""
var n int // 0
var f float64 // 0.0
var b bool // false
var p *Produk // nil
var sl []int // nil, tapi len() dan append() tetap aman
var m map[string]int // nil — baca aman, TULIS panic!Catatan — Zero value bikin banyak kode lebih bersih: struct yang baru dibuat sudah siap dipakai tanpa constructor. Tapi ada konsekuensi yang perlu disadari: 0 dan "" tidak bisa dibedakan dari "belum diisi". Kalau bedanya penting — misalnya harga 0 vs harga tidak dikirim — pakai pointer (*int) supaya nil berarti tidak ada nilai.
Tipe dasar
int, int8, int16, int32, int64 // int = 64-bit di mesin modern
uint, uint8 ... uint64 // tanpa negatif
float32, float64 // pakai float64 untuk desimal
string, bool
byte = uint8 // untuk data mentah
rune = int32 // satu karakter Unicode
// konversi WAJIB eksplisit — tidak ada konversi otomatis
n := 10
f := float64(n) / 3 // ✅
// f := n / 3.0 // ❌ error kompilasiHati-hati — Jangan simpan uang sebagai float64. 0.1 + 0.2 tidak sama dengan 0.3 pada bilangan pecahan biner, dan selisih itu menumpuk jadi laporan penjualan yang tidak cocok. Simpan rupiah sebagai int64 dalam satuan penuh (atau sen kalau perlu desimal).
Alur kontrol
// if — tanpa tanda kurung, kurawal wajib
if stok > 0 {
fmt.Println("tersedia")
} else if stok == 0 {
fmt.Println("habis")
}
// if dengan deklarasi — pola khas Go, variabelnya hidup di blok if saja
if produk, err := cariProduk(7); err != nil {
return err
} else {
fmt.Println(produk.Nama)
}
// for — SATU-SATUNYA kata kunci perulangan di Go
for i := 0; i < 10; i++ { }
for stok > 0 { stok-- } // ini "while"-nya Go
for { break } // loop tak berujung
// range — iterasi slice, map, string, channel
for i, item := range items { }
for _, item := range items { } // _ = buang nilainya
for key, nilai := range m { } // map: urutannya TIDAK tetap
for i := range 10 { } // Go 1.22+: 0 sampai 9
// switch — tidak perlu break, tidak jatuh ke case berikutnya
switch status {
case "lunas", "selesai":
fmt.Println("beres")
case "pending":
fmt.Println("menunggu")
default:
fmt.Println("tidak dikenali")
}
// switch tanpa ekspresi — pengganti if berantai
switch {
case total > 1_000_000:
diskon = 0.1
case total > 500_000:
diskon = 0.05
}defer: jalankan nanti, saat fungsi selesai
func bacaFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // dijalankan saat fungsi return, apa pun jalurnya
// ... proses file, mau return di mana pun, Close() tetap jalan
return nil
}defer menaruh pembersihan tepat di sebelah pembukaan, jadi mustahil lupa. Beberapa defer dijalankan terbalik dari urutan penulisan, seperti tumpukan. Kamu akan sering melihatnya untuk menutup file, koneksi, dan membatalkan transaksi database.
Latihan — Tulis program yang punya slice berisi beberapa harga produk, lalu cetak total, rata-rata, dan harga tertinggi. Pakai for range, dan simpan uangnya sebagai int64.
Materi 4 · Modul 0
Fungsi dan error sebagai nilai
Bagian paling khas dari Go, dan yang paling membedakannya dari PHP dan JavaScript.
Fungsi bisa mengembalikan beberapa nilai
func tambah(a, b int) int {
return a + b
}
// dua nilai kembali: hasil dan error. ini pola inti Go.
func bagi(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("pembagi tidak boleh nol")
}
return a / b, nil
}
// nilai kembali bernama — berguna untuk dokumentasi
func hitungTotal(items []Item) (total int64, jumlah int) {
for _, i := range items {
total += i.Harga * int64(i.Qty)
jumlah += i.Qty
}
return // mengembalikan total dan jumlah
}
// variadic — jumlah argumen bebas
func gabung(bagian ...string) string {
return strings.Join(bagian, "/")
}Error adalah nilai biasa, bukan exception
Tidak ada try/catch di Go. Fungsi yang bisa gagal mengembalikan error sebagai nilai terakhir, dan pemanggil wajib memutuskan mau diapakan.
hasil, err := bagi(10, 0)
if err != nil {
return err // teruskan ke atas
}
fmt.Println(hasil)
// pola yang akan kamu tulis ribuan kali:
produk, err := repo.Cari(id)
if err != nil {
return err
}
user, err := repo.CariUser(produk.UserID)
if err != nil {
return err
}Catatan — Pola if err != nil yang berulang ini paling sering dikeluhkan orang tentang Go, dan paling sering disukai orang yang sudah lama memakainya. Alasannya: setiap titik yang bisa gagal terlihat di kode. Tidak ada exception yang melompat lima lapis ke atas dan muncul di tempat yang tak terduga. Saat membaca fungsi Go, kamu bisa melihat semua jalur kegagalannya tanpa mencari ke mana-mana.
Bandingkan dengan gaya exception
Banyak bahasa lain (termasuk PHP dan JavaScript) memakai try/catch: sebuah fungsi bisa diam-diam melempar exception tanpa itu terlihat di definisinya, dan kamu baru tahu ada kemungkinan gagal kalau membaca dokumentasi atau kena error saat runtime.
// gaya exception — kegagalan tidak terlihat di tanda tangan fungsi
$produk = cariProdukAtauGagal($id); // bisa melempar exception, kamu tidak dipaksa tahu
// Go — kegagalan bagian dari kontrak fungsi
produk, err := repo.Cari(id) // jelas: ini bisa gagal
if err != nil { /* harus diputuskan */ }Fungsi sebagai nilai
// fungsi bisa disimpan di variabel dan dikirim sebagai argumen
var pemeriksa func(int) bool = func(n int) bool { return n > 0 }
func filter(items []int, cocok func(int) bool) []int {
var hasil []int
for _, i := range items {
if cocok(i) {
hasil = append(hasil, i)
}
}
return hasil
}
// closure — mengingat variabel di sekelilingnya
func pencacah() func() int {
n := 0
return func() int {
n++
return n
}
}
next := pencacah()
next() // 1
next() // 2Generic, kalau memang perlu
Go punya generic sejak versi 1.18. Dipakai secukupnya — kebanyakan kode Go tidak butuh, tapi untuk fungsi utilitas ini menghindari duplikasi.
func Map[T, U any](items []T, f func(T) U) []U {
hasil := make([]U, 0, len(items))
for _, i := range items {
hasil = append(hasil, f(i))
}
return hasil
}
nama := Map(produkList, func(p Produk) string { return p.Nama })
// pointer ke nilai — sangat berguna untuk field opsional di JSON
func Ptr[T any](v T) *T { return &v }panic: bukan pengganti error
// panic menghentikan program. pakai HANYA untuk kondisi mustahil
// atau kesalahan programmer saat aplikasi menyala.
func main() {
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatal(err) // wajar: aplikasi memang tidak bisa jalan
}
}
// ❌ jangan panic untuk kesalahan input user
if harga < 0 {
panic("harga negatif") // seluruh server mati gara-gara satu form salah
}Hati-hati — Panic di dalam goroutine yang tidak ditangani akan mematikan seluruh proses, bukan cuma goroutine itu. Di materi 14 kita pasang middleware recover supaya satu request bermasalah tidak menjatuhkan server.
Materi 5 · Modul 0
Struct, method, dan pointer
Cara Go menggambarkan data dan perilaku, tanpa class.
Struct
package model
import "time"
type Produk struct {
ID int64 `json:"id"`
Nama string `json:"nama"`
Slug string `json:"slug"`
Harga int64 `json:"harga"`
Stok int `json:"stok"`
Aktif bool `json:"aktif"`
KategoriID *int64 `json:"kategori_id"` // pointer = boleh null
Gambar *string `json:"gambar,omitempty"` // disembunyikan kalau nil
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"-"` // tidak ikut ke JSON
}Teks dalam backtick itu struct tag: keterangan yang dibaca library saat runtime. Di sini json:"nama" memberi tahu encoder JSON bahwa field Nama harus jadi "nama" di output. Tanpa tag, JSON-nya akan memakai nama field Go (Nama) dengan huruf besar.
Hati-hati — Huruf besar di awal nama field bukan gaya, tapi penentu visibilitas. Nama bisa diakses package lain (exported), nama tidak. Dan encoder JSON hanya bisa melihat field yang exported — field huruf kecil akan hilang tanpa peringatan dari respons API-mu.
Membuat dan memakai struct
// sebutkan nama field — jangan berdasarkan urutan
p := model.Produk{
Nama: "Kaos Onlenkan",
Harga: 85000,
Stok: 24,
}
// field yang tidak disebut memakai zero value
fmt.Println(p.Aktif) // false
fmt.Println(p.KategoriID) // nil
// pointer ke struct
ptr := &model.Produk{Nama: "Hoodie"}
ptr.Harga = 210000 // Go otomatis mengurus dereferenceMethod: fungsi yang menempel pada tipe
// value receiver — dapat SALINAN, perubahan tidak tersimpan
func (p Produk) Tersedia() bool {
return p.Aktif && p.Stok > 0 && p.DeletedAt == nil
}
func (p Produk) HargaRupiah() string {
return "Rp " + formatRibuan(p.Harga)
}
// pointer receiver — bisa mengubah struct aslinya
func (p *Produk) KurangiStok(n int) error {
if p.Stok < n {
return fmt.Errorf("stok %s tidak cukup: tersisa %d", p.Nama, p.Stok)
}
p.Stok -= n
return nil
}
// pemakaian
p := Produk{Nama: "Kaos", Stok: 10, Aktif: true}
fmt.Println(p.Tersedia()) // true
p.KurangiStok(3) // p.Stok jadi 7Value receiver atau pointer receiver?
| Pakai | Kalau |
|---|---|
func (p *Produk) | Method mengubah isi struct, atau struct-nya besar (menyalin jadi mahal) |
func (p Produk) | Hanya membaca, dan struct-nya kecil |
Catatan — Aturan praktis yang dipakai banyak tim: konsisten per tipe. Kalau satu method butuh pointer receiver, buat semua method tipe itu pointer receiver. Mencampur keduanya membingungkan, dan bisa membuat tipe itu tidak memenuhi interface seperti yang kamu harapkan.
Pointer: secukupnya saja
harga := 85000
ptr := &harga // & = ambil alamat
fmt.Println(*ptr) // * = ambil nilainya
*ptr = 90000 // mengubah harga lewat pointer
// selalu cek nil sebelum dereference
func cetak(p *Produk) {
if p == nil {
return
}
fmt.Println(p.Nama)
}Tiga tempat kamu benar-benar butuh pointer: method yang mengubah isi, field yang boleh nil (opsional di JSON atau kolom nullable), dan fungsi yang mengembalikan "data atau tidak ada" seperti (*Produk, error).
Komposisi: struct di dalam struct
type Timestamps struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Produk struct {
ID int64 `json:"id"`
Nama string `json:"nama"`
Timestamps // disematkan, tanpa nama field
}
p := Produk{}
p.CreatedAt = time.Now() // diakses langsung, seolah field sendiriIni yang dipakai Go sebagai ganti pewarisan. Bedanya penting: komposisi hanya meneruskan field dan method, bukan membentuk hubungan "adalah". Tidak ada hierarki kelas yang bisa tumbuh jadi lima tingkat.
Latihan — Bikin struct Pesanan dengan field ID, NamaPembeli, slice Item (masing-masing punya ProdukID, Harga, Qty), dan waktu. Tambahkan method Total() int64 dan TambahItem(item Item) error yang menolak qty nol atau negatif. Perhatikan receiver mana yang perlu pointer.
Materi 6 · Modul 0
Slice, map, dan jebakannya
Dua struktur data yang akan kamu pakai terus. Keduanya punya perilaku yang perlu dipahami, bukan dihafal.
Slice
// deklarasi
var kosong []int // nil, tapi siap di-append
angka := []int{1, 2, 3}
buat := make([]int, 0, 10) // panjang 0, kapasitas 10
// operasi
angka = append(angka, 4) // hasil append HARUS ditampung
angka = append(angka, 5, 6, 7)
angka = append(angka, lain...) // gabung slice lain
len(angka) // jumlah isi
cap(angka) // kapasitas sebelum dialokasi ulang
// potong — [awal:akhir), akhir tidak ikut
angka[1:3]
angka[:2]
angka[2:]Catatan — Kalau kamu sudah tahu perkiraan jumlahnya, sebutkan kapasitasnya: make([]Produk, 0, len(rows)). Tanpa itu, append harus mengalokasikan memori baru dan menyalin isinya berkali-kali saat slice tumbuh. Di loop yang mengolah ribuan baris database, bedanya terukur.
Jebakan slice yang wajib kamu tahu
// 1. slice berbagi memori dengan asalnya
asli := []int{1, 2, 3, 4, 5}
bagian := asli[1:3] // [2 3]
bagian[0] = 99
fmt.Println(asli) // [1 99 3 4 5] — asli ikut berubah!
// salin kalau mau benar-benar terpisah
salinan := make([]int, len(bagian))
copy(salinan, bagian)
// atau sejak Go 1.21
salinan := slices.Clone(bagian)
// 2. slice adalah argumen "setengah referensi"
func ubah(s []int) {
s[0] = 100 // ✅ terlihat di pemanggil
s = append(s, 4) // ❌ TIDAK terlihat di pemanggil
}Penjelasan yang kedua: slice itu struct kecil berisi pointer ke data, panjang, dan kapasitas. Saat dikirim ke fungsi, struct kecil itu disalin — jadi mengubah isi lewat pointer terlihat, tapi mengubah panjangnya hanya mengubah salinan. Kalau fungsi perlu menambah isi, kembalikan slice barunya seperti yang dilakukan append sendiri.
Map
// bikin — WAJIB pakai make atau literal
stok := make(map[string]int)
harga := map[string]int64{
"kaos": 85000,
"hoodie": 210000,
}
// tulis & baca
stok["kaos"] = 24
n := stok["topi"] // 0 — tidak error walau tidak ada
// bedakan "nilainya 0" dan "tidak ada" — pola dua nilai
n, ada := stok["topi"]
if !ada {
fmt.Println("produk tidak terdaftar")
}
delete(stok, "kaos")
len(stok)
for nama, jumlah := range stok { // urutan TIDAK tetap
fmt.Println(nama, jumlah)
}Hati-hati — Tiga hal soal map: (1) menulis ke map nil menyebabkan panic, jadi selalu make dulu; (2) urutan iterasi disengaja diacak oleh Go, jadi jangan pernah mengandalkannya — kalau butuh urut, kumpulkan kunci ke slice lalu slices.Sort; (3) map tidak aman diakses beberapa goroutine sekaligus, dan itu menyebabkan crash, bukan sekadar data salah. Untuk itu pakai sync.Mutex atau sync.Map (materi 10).
Pola yang sering dipakai di backend
// map sebagai himpunan (set)
diizinkan := map[string]struct{}{
"nama": {}, "harga": {}, "created_at": {},
}
if _, ok := diizinkan[kolomUrut]; !ok {
return errors.New("kolom pengurutan tidak valid")
}
// kelompokkan slice jadi map — hindari query berulang
func indexByID(list []Kategori) map[int64]Kategori {
m := make(map[int64]Kategori, len(list))
for _, k := range list {
m[k.ID] = k
}
return m
}
// pustaka slices & maps (Go 1.21+)
slices.Contains(peran, "admin")
slices.SortFunc(items, func(a, b Produk) int { return int(a.Harga - b.Harga) })
slices.IndexFunc(items, func(p Produk) bool { return p.ID == 7 })
kunci := maps.Keys(stok)String dan byte
s := "Kaos Onlenkan"
len(s) // jumlah BYTE, bukan karakter
strings.ToLower(s)
strings.Contains(s, "kaos")
strings.Split(s, " ")
strings.TrimSpace(s)
strings.ReplaceAll(s, " ", "-")
// string tidak bisa diubah — bangun dengan Builder kalau di dalam loop
var b strings.Builder
for _, kata := range kataList {
b.WriteString(kata)
b.WriteString("-")
}
hasil := b.String()Catatan — len("café") menghasilkan 5, bukan 4, karena é memakai dua byte. Untuk menghitung karakter sungguhan pakai utf8.RuneCountInString(). Ini penting saat memvalidasi panjang nama atau alamat yang mungkin memuat huruf beraksen atau emoji.
Materi 7 · Modul 1
Interface dan komposisi
Cara Go menggantikan pewarisan dan dependency injection, dengan mekanisme yang jauh lebih sederhana.
Interface dipenuhi secara implisit
Ini perbedaan besar dari PHP dan TypeScript. Di Go kamu tidak menulis "class ini implements interface itu". Kalau sebuah tipe punya semua method yang diminta interface, dia otomatis memenuhinya.
type ProdukRepository interface {
Cari(ctx context.Context, id int64) (*model.Produk, error)
Simpan(ctx context.Context, p *model.Produk) error
}
// tidak ada kata "implements" di mana pun
type postgresRepo struct { db *pgxpool.Pool }
func (r *postgresRepo) Cari(ctx context.Context, id int64) (*model.Produk, error) {
// ...
}
func (r *postgresRepo) Simpan(ctx context.Context, p *model.Produk) error {
// ...
}
// *postgresRepo sekarang bisa dipakai di mana pun ProdukRepository diminta
var repo ProdukRepository = &postgresRepo{db: pool}Kenapa ini penting: testing jadi mudah
type ProdukService struct {
repo ProdukRepository // bergantung pada kontrak, bukan implementasi
}
func NewProdukService(repo ProdukRepository) *ProdukService {
return &ProdukService{repo: repo}
}
// di produksi
svc := NewProdukService(NewPostgresRepo(pool))
// di test — repo palsu, tanpa database
type repoPalsu struct { produk *model.Produk }
func (r *repoPalsu) Cari(_ context.Context, id int64) (*model.Produk, error) {
if r.produk == nil {
return nil, ErrTidakDitemukan
}
return r.produk, nil
}
func (r *repoPalsu) Simpan(_ context.Context, _ *model.Produk) error { return nil }
svc := NewProdukService(&repoPalsu{produk: &model.Produk{ID: 1, Nama: "Kaos"}})Catatan — Tidak ada container DI, tidak ada decorator, tidak ada file konfigurasi. Kamu menyusun sendiri di main.go dengan pemanggilan fungsi biasa. Terdengar primitif dibanding NestJS, tapi konsekuensinya: alurnya bisa kamu lacak dengan "go to definition", dan tidak ada error "cannot resolve dependency" saat runtime.
Interface kecil lebih baik
Prinsip yang kuat di Go: interface sebaiknya berisi satu-dua method. Pustaka standar penuh contohnya.
// dari pustaka standar — satu method saja
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Stringer interface { String() string }
// gabungkan kalau perlu
type ReadWriter interface {
Reader
Writer
}
// contoh di aplikasimu: notifikasi
type Pengirim interface {
Kirim(ctx context.Context, tujuan, pesan string) error
}
// tiga implementasi, satu kontrak
type waSender struct{ token string }
type emailSender struct{ smtp string }
type logSender struct{} // untuk developmentHati-hati — Konvensi Go: definisikan interface di tempat yang memakainya, bukan di tempat yang mengimplementasikannya. Jadi ProdukRepository ditulis di package service (yang butuh), bukan di package repository (yang menyediakan). Ini kebalikan dari kebiasaan Java dan PHP, dan bikin package-mu tidak saling bergantung.
Menggali tipe asli di balik interface
var nilai any = "kaos"
// type assertion — pakai bentuk dua nilai supaya tidak panic
s, ok := nilai.(string)
if !ok {
return errors.New("bukan string")
}
// type switch
switch v := nilai.(type) {
case string:
fmt.Println("teks:", v)
case int:
fmt.Println("angka:", v)
case nil:
fmt.Println("kosong")
default:
fmt.Printf("tipe lain: %T\n", v)
}Gunakan ini secukupnya. Kalau kamu sering butuh any dan type switch, biasanya itu tanda desain tipenya bisa diperbaiki.
Materi 8 · Modul 1
Error handling yang rapi
Karena error itu nilai biasa, kamu punya kendali penuh — dan tanggung jawab penuh.
Membungkus error dengan konteks
// ❌ error naik tanpa jejak, sulit dilacak asalnya
if err != nil {
return err
}
// ✅ tambahkan konteks di setiap lapisan. %w membungkus error aslinya.
if err != nil {
return fmt.Errorf("cari produk %d: %w", id, err)
}
// hasilnya jadi rantai yang bercerita:
// "handler produk: service ubah stok: cari produk 7: no rows in result set"Catatan — Pakai %w (bukan %v) supaya error aslinya tetap bisa diperiksa dengan errors.Is dan errors.As. Dan tulis pesannya tanpa huruf kapital di awal dan tanpa titik, karena pesan itu akan disambung dengan pesan lain: "gagal simpan: koneksi ditutup" lebih baik daripada "Gagal simpan.: Koneksi ditutup."
Error sentinel: error yang bisa dibandingkan
package apperr
import "errors"
var (
ErrTidakDitemukan = errors.New("data tidak ditemukan")
ErrSudahAda = errors.New("data sudah ada")
ErrTidakBerhak = errors.New("tidak punya akses")
ErrKredensial = errors.New("email atau password salah")
ErrStokKurang = errors.New("stok tidak cukup")
)
// repository melempar sentinel
func (r *produkRepo) Cari(ctx context.Context, id int64) (*model.Produk, error) {
var p model.Produk
err := r.db.QueryRow(ctx, qCariProduk, id).Scan(&p.ID, &p.Nama, &p.Harga)
if errors.Is(err, pgx.ErrNoRows) {
return nil, apperr.ErrTidakDitemukan // terjemahkan error database
}
if err != nil {
return nil, fmt.Errorf("query produk: %w", err)
}
return &p, nil
}
// handler memeriksanya, walau sudah dibungkus berlapis
if errors.Is(err, apperr.ErrTidakDitemukan) {
kirimError(w, 404, "Produk tidak ditemukan")
return
}Pola "terjemahkan error database jadi error domain" itu penting. Dengan begitu lapisan handler tidak perlu tahu apa pun tentang Postgres — kalau suatu hari kamu ganti database, hanya repository yang berubah.
Error bertipe: membawa data tambahan
// error validasi yang membawa daftar field bermasalah
type ValidationError struct {
Fields map[string]string
}
// satu-satunya syarat jadi error: punya method Error() string
func (e *ValidationError) Error() string {
return "validasi gagal"
}
func (e *ValidationError) Add(field, pesan string) {
if e.Fields == nil {
e.Fields = map[string]string{}
}
e.Fields[field] = pesan
}
// ambil kembali tipe aslinya dengan errors.As
var ve *ValidationError
if errors.As(err, &ve) {
kirimJSON(w, 422, map[any]any{
"message": "Ada isian yang perlu diperbaiki.",
"errors": ve.Fields,
})
return
}Bedanya — errors.Is untuk memeriksa "apakah ini error tertentu", errors.As untuk "apakah ini error bertipe tertentu, dan kalau ya berikan datanya". Yang pertama untuk sentinel, yang kedua untuk error yang membawa informasi.
Satu tempat terpusat untuk mengubah error jadi respons HTTP
func TanganiError(w http.ResponseWriter, err error) {
var ve *apperr.ValidationError
switch {
case errors.As(err, &ve):
kirimJSON(w, 422, Respons{Message: "Ada isian yang perlu diperbaiki.", Errors: ve.Fields})
case errors.Is(err, apperr.ErrTidakDitemukan):
kirimJSON(w, 404, Respons{Message: "Data tidak ditemukan."})
case errors.Is(err, apperr.ErrSudahAda):
kirimJSON(w, 409, Respons{Message: "Data sudah terdaftar."})
case errors.Is(err, apperr.ErrKredensial):
kirimJSON(w, 401, Respons{Message: "Email atau password salah."})
case errors.Is(err, apperr.ErrTidakBerhak):
kirimJSON(w, 403, Respons{Message: "Kamu tidak punya akses ke aksi ini."})
default:
// error tak terduga: catat lengkap, kirim pesan umum
slog.Error("error tak tertangani", "err", err)
kirimJSON(w, 500, Respons{Message: "Terjadi kesalahan pada server."})
}
}Hati-hati — Cabang default tidak boleh mengirim err.Error() ke klien. Pesan error database bisa memuat nama tabel, kolom, bahkan potongan query. Catat detailnya di log, kirim pesan umum ke user.
Latihan — Bikin package apperr dengan sentinel di atas, lalu tulis fungsi yang memanggil tiga lapisan bersarang dan membungkus error di setiap lapisan dengan %w. Cetak hasilnya dan pastikan errors.Is di lapisan terluar masih bisa mengenali sentinel dari lapisan terdalam.
Materi 9 · Modul 1
Goroutine dan channel
Alasan utama orang memilih Go. Sederhana di permukaan, tapi ada beberapa aturan yang harus dipegang.
Goroutine
// jalankan fungsi tanpa menunggu
go kirimNotifikasi(userID)
// dengan fungsi anonim
go func() {
prosesGambar(path)
}()Hati-hati — Program Go berhenti ketika main() selesai, tanpa menunggu goroutine yang masih jalan. Jadi go kirimNotifikasi() di baris terakhir main mungkin tidak pernah benar-benar dijalankan. Kamu harus menunggunya secara eksplisit.
WaitGroup: menunggu sekumpulan goroutine
func AmbilDashboard(ctx context.Context) (*Dashboard, error) {
var (
wg sync.WaitGroup
d Dashboard
errPesan, errProduk error
)
wg.Add(2)
go func() {
defer wg.Done() // defer, supaya tetap jalan walau panic
d.TotalPesanan, errPesan = repo.HitungPesanan(ctx)
}()
go func() {
defer wg.Done()
d.TotalProduk, errProduk = repo.HitungProduk(ctx)
}()
wg.Wait()
if err := errors.Join(errPesan, errProduk); err != nil {
return nil, err
}
return &d, nil
}Catatan — Perhatikan setiap goroutine menulis ke field berbeda dan variabel error berbeda. Kalau dua goroutine menulis ke variabel yang sama tanpa penguncian, itu data race — hasilnya tidak bisa diprediksi. Untuk kasus "jalankan beberapa hal, kumpulkan error pertama", pustaka golang.org/x/sync/errgroup lebih ringkas dan sudah menangani context.
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
d.TotalPesanan, err = repo.HitungPesanan(ctx)
return err
})
g.Go(func() error {
var err error
d.TotalProduk, err = repo.HitungProduk(ctx)
return err
})
if err := g.Wait(); err != nil {
return nil, err // error pertama; goroutine lain otomatis dibatalkan
}Channel: pipa komunikasi antar goroutine
// unbuffered: pengirim menunggu sampai ada yang menerima
ch := make(chan string)
// buffered: pengirim baru menunggu kalau buffer penuh
hasil := make(chan int, 10)
// kirim dan terima
go func() { ch <- "selesai" }()
pesan := <-ch
// tutup dan iterasi sampai habis
close(hasil)
for n := range hasil { fmt.Println(n) }
// arah channel di parameter — dokumentasi yang ditegakkan compiler
func produsen(out chan<- int) { } // hanya boleh mengirim
func konsumen(in <-chan int) { } // hanya boleh menerimaWorker pool: pola paling berguna
Ini bentuk yang akan kamu pakai untuk memproses banyak pekerjaan dengan jumlah pekerja terbatas — kirim 500 notifikasi WhatsApp dengan 10 pekerja, misalnya.
func KirimMassal(ctx context.Context, nomor []string, pekerja int) error {
tugas := make(chan string)
var wg sync.WaitGroup
// nyalakan sejumlah pekerja
for i := 0; i < pekerja; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for no := range tugas { // ambil tugas sampai channel ditutup
if err := kirimWA(ctx, no); err != nil {
slog.Error("gagal kirim", "nomor", no, "err", err)
}
}
}(i)
}
// bagikan tugas, hormati pembatalan
for _, no := range nomor {
select {
case tugas <- no:
case <-ctx.Done():
close(tugas)
wg.Wait()
return ctx.Err()
}
}
close(tugas) // beri tahu pekerja: tidak ada tugas lagi
wg.Wait() // tunggu yang sedang jalan selesai
return nil
}select: menunggu beberapa kemungkinan
select {
case hasil := <-selesai:
fmt.Println("berhasil:", hasil)
case err := <-gagal:
fmt.Println("gagal:", err)
case <-time.After(3 * time.Second):
fmt.Println("kehabisan waktu")
case <-ctx.Done():
fmt.Println("dibatalkan")
}Tiga kesalahan concurrency yang paling sering
// 1. data race — dua goroutine menulis variabel yang sama
total := 0
for i := 0; i < 100; i++ {
go func() { total++ }() // ❌ hasilnya tidak bisa diprediksi
}
var mu sync.Mutex
go func() {
mu.Lock()
total++
mu.Unlock() // ✅
}()
// 2. goroutine bocor — menunggu channel yang tidak pernah diisi
go func() {
<-ch // ❌ kalau tidak ada yang mengirim, goroutine ini hidup selamanya
}()
// 3. deadlock — mengirim ke channel unbuffered tanpa penerima
ch := make(chan int)
ch <- 1 // ❌ "all goroutines are asleep - deadlock!"Hati-hati — Jalankan go test -race ./... dan go run -race . saat mengembangkan kode concurrent. Race detector menemukan bug yang mungkin baru muncul sekali sebulan di produksi, dan itu jenis bug yang paling melelahkan untuk dicari. Ini salah satu alat terbaik yang dimiliki Go.
Kapan tidak perlu concurrency
Ini penting: untuk API CRUD biasa, kamu tidak perlu menulis goroutine sama sekali. Server HTTP Go sudah menjalankan setiap request di goroutine-nya sendiri. Tambahkan concurrency hanya ketika satu request perlu melakukan beberapa hal independen sekaligus, atau ada pekerjaan latar yang tidak boleh menahan respons.
Materi 10 · Modul 1
Context: pembatalan dan batas waktu
Konsep yang wajib dikuasai sebelum menulis API Go. Muncul di hampir setiap fungsi.
Masalah yang diselesaikannya
User membuka halaman laporan, query-nya berat, lalu user menutup tab setelah 2 detik. Tanpa context, query itu tetap berjalan sampai selesai — memakan koneksi database dan CPU untuk hasil yang tidak akan pernah dibaca. Dengan context, pembatalan dari sisi user mengalir turun sampai ke database dan menghentikan query-nya.
// context diteruskan sebagai parameter PERTAMA, selalu
func (s *ProdukService) Cari(ctx context.Context, id int64) (*model.Produk, error) {
return s.repo.Cari(ctx, id) // diteruskan terus ke bawah
}
// di handler HTTP, context sudah disediakan dan otomatis dibatalkan
// kalau klien memutus koneksi
func (h *Handler) CariProduk(w http.ResponseWriter, r *http.Request) {
produk, err := h.svc.Cari(r.Context(), id)
}Membuat context dengan batas waktu
// batasi satu operasi ke 3 detik
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // WAJIB, kalau tidak sumber dayanya bocor
produk, err := repo.Cari(ctx, id)
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("query kelamaan: %w", err)
}
// pembatalan manual
ctx, cancel := context.WithCancel(ctx)
go func() {
if adaMasalah() {
cancel() // semua yang memakai ctx ini berhenti
}
}()
// context akar, untuk main() dan pekerjaan latar
ctx := context.Background()Hati-hati — defer cancel() itu bukan opsional. Tanpa itu, timer internal context tidak dibersihkan sampai batas waktunya lewat, dan di endpoint yang sering dipanggil itu menumpuk jadi kebocoran memori. go vet akan memperingatkanmu kalau lupa.
Memeriksa pembatalan di pekerjaan panjang
func ProsesBanyak(ctx context.Context, items []Item) error {
for i, item := range items {
// periksa pembatalan setiap putaran
select {
case <-ctx.Done():
return fmt.Errorf("dibatalkan setelah %d item: %w", i, ctx.Err())
default:
}
if err := proses(ctx, item); err != nil {
return err
}
}
return nil
}Menyimpan nilai di context
Ini cara middleware menitipkan data user yang sudah login supaya bisa dibaca handler. Tapi ada aturan ketatnya.
// tipe kunci sendiri — mencegah tabrakan dengan package lain
type ctxKey string
const userKey ctxKey = "user"
func SimpanUser(ctx context.Context, u *model.User) context.Context {
return context.WithValue(ctx, userKey, u)
}
func UserDari(ctx context.Context) (*model.User, bool) {
u, ok := ctx.Value(userKey).(*model.User)
return u, ok
}Catatan — Jangan pakai context.WithValue untuk mengirim parameter fungsi biasa. Dia hanya untuk data lintas-permintaan yang mengikuti seluruh rantai pemanggilan: user yang terautentikasi, request ID untuk tracing, dan id tenant pada aplikasi multi-tenant. Semua data lain dikirim sebagai parameter eksplisit — lebih jelas dan diperiksa compiler. Dan selalu pakai tipe kunci sendiri, jangan string mentah.
Alat sinkronisasi lain
// Mutex — lindungi data bersama
type Cache struct {
mu sync.RWMutex
data map[string]*model.Produk
}
func (c *Cache) Ambil(k string) (*model.Produk, bool) {
c.mu.RLock() // banyak pembaca boleh bersamaan
defer c.mu.RUnlock()
p, ok := c.data[k]
return p, ok
}
func (c *Cache) Set(k string, p *model.Produk) {
c.mu.Lock() // penulis mengunci sendirian
defer c.mu.Unlock()
c.data[k] = p
}
// Once — pastikan sesuatu dijalankan tepat sekali
var once sync.Once
once.Do(func() { muatKonfigurasi() })defer pada Unlock itu kebiasaan yang menyelamatkan: kalau ada return di tengah fungsi, kuncinya tetap dilepas. Mutex yang tidak pernah dilepas berarti seluruh aplikasi macet.
Materi 11 · Modul 1
Package, visibilitas, dan testing dasar
Cara membagi kode jadi beberapa bagian, dan menulis test yang sudah tersedia tanpa library tambahan.
Aturan package
// satu folder = satu package. semua file di folder itu satu package.
// internal/service/produk.go
package service
// HURUF BESAR di awal = bisa diakses package lain
func NewProdukService(...) *ProdukService { }
type ProdukService struct { }
// huruf kecil = hanya di dalam package ini
func bikinSlug(teks string) string { }
type produkRepo struct { }Tidak ada public, private, atau protected. Hanya besar-kecilnya huruf pertama. Sederhana, dan berlaku untuk fungsi, tipe, field struct, maupun konstanta.
Impor
import (
"context" // pustaka standar
"fmt"
"net/http"
"github.com/jackc/pgx/v5/pgxpool" // pihak ketiga
"github.com/yunusalmeida/toko-api/internal/model" // milik sendiri
"github.com/yunusalmeida/toko-api/internal/service"
)
// alias, kalau namanya bentrok
import mw "github.com/yunusalmeida/toko-api/internal/middleware"Hati-hati — Go melarang impor melingkar: kalau package A mengimpor B, maka B tidak boleh mengimpor A. Itu error kompilasi, bukan peringatan. Awalnya menjengkelkan, tapi memaksa arsitekturmu punya arah yang jelas: handler → service → repository → model, dan model tidak mengimpor apa pun. Kalau kamu bertemu impor melingkar, itu tanda ada tanggung jawab yang salah tempat.
Testing: sudah ada di dalam kotak
package service
import (
"context"
"errors"
"testing"
)
func TestCariProduk(t *testing.T) {
svc := NewProdukService(&repoPalsu{
produk: &model.Produk{ID: 1, Nama: "Kaos", Harga: 85000},
})
p, err := svc.Cari(context.Background(), 1)
if err != nil {
t.Fatalf("tidak mengharap error, dapat: %v", err)
}
if p.Nama != "Kaos" {
t.Errorf("nama = %q, mau %q", p.Nama, "Kaos")
}
}Tidak ada describe, it, atau expect. Fungsi yang namanya diawali Test, di file berakhiran _test.go, dan pemeriksaan pakai if biasa. t.Errorf menandai gagal tapi melanjutkan; t.Fatalf menghentikan test itu.
Table-driven test: gaya khas Go
func TestHitungTotal(t *testing.T) {
kasus := []struct {
nama string
items []Item
mau int64
}{
{"keranjang kosong", nil, 0},
{"satu item", []Item{{Harga: 85000, Qty: 1}}, 85000},
{"qty lebih dari satu", []Item{{Harga: 85000, Qty: 3}}, 255000},
{"beberapa item", []Item{
{Harga: 85000, Qty: 2},
{Harga: 210000, Qty: 1},
}, 380000},
}
for _, k := range kasus {
t.Run(k.nama, func(t *testing.T) {
dapat := HitungTotal(k.items)
if dapat != k.mau {
t.Errorf("HitungTotal() = %d, mau %d", dapat, k.mau)
}
})
}
}Catatan — Pola ini sangat dianjurkan di Go. Menambah kasus uji cuma berarti menambah satu baris di tabel, dan nama subtest muncul terpisah di output sehingga jelas mana yang gagal. Untuk perhitungan uang dan aturan bisnis, ini bentuk test paling hemat usaha.
Menjalankan test
go test ./... # semua package
go test -v ./internal/service # satu package, verbose
go test -run TestHitungTotal ./... # satu test saja
go test -race ./... # deteksi data race
go test -cover ./... # persentase cakupan
go test -bench=. ./... # benchmarkBenchmark, juga bawaan
func BenchmarkBikinSlug(b *testing.B) {
for i := 0; i < b.N; i++ {
bikinSlug("Kaos Onlenkan Edisi Terbatas")
}
}
// hasilnya: BenchmarkBikinSlug-8 4821034 248.3 ns/op 64 B/op 2 allocs/op
// ns/op = nanodetik per operasi, allocs/op = jumlah alokasi memoriAngka allocs/op itu yang paling sering dipakai untuk optimasi di Go: mengurangi alokasi memori biasanya lebih berdampak daripada mengurangi jumlah operasi.
Latihan — Bikin package internal/util dengan fungsi BikinSlug(string) string dan FormatRupiah(int64) string, lalu tulis table-driven test untuk keduanya. Sertakan kasus tepi: string kosong, huruf beraksen, angka nol, dan angka besar.
Materi 12 · Modul 2
Server HTTP dengan net/http
Pustaka standar Go sudah cukup untuk membangun API produksi. Ini bukan penyederhanaan untuk belajar.
Server pertama
package main
import (
"encoding/json"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
log.Println("jalan di :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}Itu server HTTP lengkap, tanpa satu pun dependency. Jalankan dengan go run ./cmd/api.
Routing di Go 1.22 dan setelahnya
Sebelum Go 1.22, ServeMux tidak bisa mencocokkan method HTTP atau parameter path, sehingga hampir semua orang memakai router pihak ketiga. Sekarang keduanya sudah bawaan.
mux := http.NewServeMux()
// method + path, dipisah spasi
mux.HandleFunc("GET /api/produk", h.DaftarProduk)
mux.HandleFunc("POST /api/produk", h.SimpanProduk)
// parameter path pakai kurung kurawal
mux.HandleFunc("GET /api/produk/{id}", h.CariProduk)
mux.HandleFunc("PATCH /api/produk/{id}", h.UbahProduk)
mux.HandleFunc("DELETE /api/produk/{id}", h.HapusProduk)
// wildcard sisa path
mux.HandleFunc("GET /uploads/{path...}", h.SajikanFile)
// ambil nilainya
idStr := r.PathValue("id")Catatan — Pencocokan rute Go 1.22 memakai aturan "pola paling spesifik menang", bukan urutan pendaftaran. Jadi /api/produk/terlaris otomatis dipilih di atas /api/produk/{id} tanpa kamu perlu mengatur urutannya. Ini berbeda dari NestJS dan Express yang bergantung pada urutan.
Handler: dua bentuk yang perlu dikenali
// http.Handler adalah interface dengan satu method
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
// HandlerFunc mengubah fungsi biasa jadi Handler
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("halo"))
})Seluruh ekosistem HTTP Go dibangun di atas satu interface itu. Middleware, router, dan library apa pun cocok satu sama lain karena semuanya hanya berurusan dengan http.Handler. Ini contoh bagus dari "interface kecil" di materi 7.
Handler sebagai method dari struct
Handler perlu akses ke service dan konfigurasi. Caranya: jadikan handler method dari struct yang menyimpan dependency-nya.
package handler
type ProdukHandler struct {
svc *service.ProdukService
}
func NewProdukHandler(svc *service.ProdukService) *ProdukHandler {
return &ProdukHandler{svc: svc}
}
func (h *ProdukHandler) CariProduk(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
KirimError(w, 400, "ID produk tidak valid")
return
}
produk, err := h.svc.Cari(r.Context(), id)
if err != nil {
TanganiError(w, err) // fungsi terpusat dari materi 8
return
}
KirimJSON(w, 200, Respons{Data: produk})
}Hati-hati — Setelah KirimError atau KirimJSON, wajib return. Go tidak menghentikan fungsi hanya karena kamu sudah menulis respons. Lupa return berarti kode di bawahnya tetap jalan dan menulis respons kedua — yang menghasilkan peringatan "superfluous WriteHeader call" dan JSON rusak di sisi klien. Ini kesalahan nomor satu pemula Go yang datang dari framework.
Server dengan konfigurasi yang benar
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Fatal(srv.ListenAndServe())Hati-hati — http.ListenAndServe yang ringkas itu tidak punya timeout sama sekali. Artinya satu klien jahat bisa membuka koneksi dan mengirim header sangat lambat, menahan sumber daya server selamanya. Untuk apa pun yang menghadap internet, selalu pakai &http.Server{} dengan timeout seperti di atas. Ini langkah kecil yang sering dilewatkan.
Materi 13 · Modul 2
JSON, query, dan pembantu respons
Membaca request dan mengirim respons dengan bentuk yang konsisten.
Bentuk respons yang seragam
Tentukan sekali di awal, pakai di semua endpoint. Bentuk di bawah sengaja dibuat sama dengan API Laravel dan NestJS di handbook sebelumnya, supaya frontend React yang sama bisa dipakai.
package handler
type Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int64 `json:"total"`
LastPage int `json:"lastPage"`
}
type Respons struct {
Data any `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
Message string `json:"message,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
}
func KirimJSON(w http.ResponseWriter, status int, isi any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status) // harus SETELAH header, SEBELUM body
if isi == nil {
return
}
if err := json.NewEncoder(w).Encode(isi); err != nil {
slog.Error("gagal encode respons", "err", err)
}
}
func KirimError(w http.ResponseWriter, status int, pesan string) {
KirimJSON(w, status, Respons{Message: pesan})
}Catatan — Urutannya tidak bisa ditukar: set header dulu, lalu WriteHeader(status), baru tulis body. Begitu body mulai ditulis, status dan header sudah terkirim dan tidak bisa diubah lagi. Kalau kamu memanggil WriteHeader dua kali, yang kedua diabaikan dengan peringatan di log.
Membaca JSON dari request
type BuatProdukInput struct {
Nama string `json:"nama"`
Harga int64 `json:"harga"`
Stok int `json:"stok"`
KategoriID *int64 `json:"kategori_id"`
}
func BacaJSON(w http.ResponseWriter, r *http.Request, tujuan any) error {
// batasi ukuran body — tanpa ini, kiriman 2 GB bisa menghabiskan memori
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // tolak field yang tidak dikenal
if err := dec.Decode(tujuan); err != nil {
var synErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.As(err, &synErr):
return fmt.Errorf("format JSON tidak valid")
case errors.As(err, &typeErr):
return fmt.Errorf("field %q bertipe salah", typeErr.Field)
case errors.Is(err, io.EOF):
return fmt.Errorf("body request kosong")
default:
return err
}
}
return nil
}Hati-hati — DisallowUnknownFields() itu perlindungan yang layak dipasang di mana pun kamu membaca JSON dari luar. Tanpa itu, field asing memang diabaikan oleh decoder Go — jadi relatif aman — tapi dengan itu kamu mendapat pesan error jelas saat frontend mengirim nama field yang salah, bukan data yang diam-diam tidak tersimpan. Dan MaxBytesReader jangan dilewatkan: itu satu-satunya yang mencegah request raksasa.
Query string
q := r.URL.Query()
cari := q.Get("cari") // "" kalau tidak ada
page := angkaQuery(q, "page", 1)
limit := angkaQuery(q, "limit", 10)
// pembantu kecil, biar tidak berulang
func angkaQuery(q url.Values, kunci string, bawaan int) int {
s := q.Get(kunci)
if s == "" {
return bawaan
}
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
return bawaan
}
return n
}Struct terpisah untuk input, model, dan output
Kebiasaan yang sangat berharga: jangan pakai satu struct untuk segalanya.
// 1. INPUT — apa yang boleh dikirim klien
type BuatProdukInput struct {
Nama string `json:"nama"`
Harga int64 `json:"harga"`
}
// 2. MODEL — bentuk di database
type Produk struct {
ID int64
Nama string
Harga int64
DibuatOleh int64
DeletedAt *time.Time
}
// 3. OUTPUT — apa yang dikirim ke klien
type ProdukRespons struct {
ID int64 `json:"id"`
Nama string `json:"nama"`
Harga int64 `json:"harga"`
HargaText string `json:"harga_text"` // "Rp 85.000"
}
func KeProdukRespons(p *model.Produk) ProdukRespons {
return ProdukRespons{
ID: p.ID, Nama: p.Nama, Harga: p.Harga,
HargaText: util.FormatRupiah(p.Harga),
}
}Catatan — Pemisahan ini yang mencegah kebocoran data. Kalau kamu suatu hari menambah kolom catatan_internal ke model, kolom itu tidak otomatis muncul di respons API — karena respons dibentuk oleh struct terpisah.
Struct tag JSON yang perlu diingat
Nama string `json:"nama"` // ganti nama field
Gambar *string `json:"gambar,omitempty"` // hilang kalau nil/kosong
Hash string `json:"-"` // jangan pernah keluar
Harga int64 `json:"harga,string"` // kirim sebagai stringjson:"-" pada field seperti PasswordHash adalah pertahanan lapis kedua, di samping memakai struct output terpisah. Pasang keduanya.
Materi 14 · Modul 2
Middleware
Fungsi yang membungkus handler. Polanya satu, dan berlaku untuk semuanya.
Pola dasar
package middleware
// middleware = fungsi yang terima Handler, kembalikan Handler
func Contoh(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// sebelum handler
next.ServeHTTP(w, r)
// sesudah handler
})
}Itu seluruh konsepnya. Tidak ada decorator, tidak ada registrasi, tidak ada urutan tersembunyi. Kamu membungkus secara eksplisit dan urutannya terlihat di kode.
Logging
// ResponseWriter tidak menyimpan status yang sudah ditulis,
// jadi kita bungkus untuk merekamnya
type perekam struct {
http.ResponseWriter
status int
ukuran int
}
func (p *perekam) WriteHeader(kode int) {
p.status = kode
p.ResponseWriter.WriteHeader(kode)
}
func (p *perekam) Write(b []byte) (int, error) {
if p.status == 0 {
p.status = 200 // Write tanpa WriteHeader = 200
}
n, err := p.ResponseWriter.Write(b)
p.ukuran += n
return n, err
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mulai := time.Now()
p := &perekam{ResponseWriter: w}
next.ServeHTTP(p, r)
durasi := time.Since(mulai)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", p.status,
"durasi_ms", durasi.Milliseconds(),
)
if durasi > time.Second {
slog.Warn("request lambat", "path", r.URL.Path, "durasi_ms", durasi.Milliseconds())
}
})
}Teknik membungkus http.ResponseWriter dengan struct yang menyematkannya itu pola yang akan kamu lihat di banyak kode Go. Karena perekam menyematkan interface aslinya, semua method lain tetap tersedia — kamu cuma menimpa yang perlu.
Recover: jangan biarkan panic menjatuhkan server
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic pada request",
"panic", rec,
"path", r.URL.Path,
"stack", string(debug.Stack()),
)
handler.KirimError(w, 500, "Terjadi kesalahan pada server.")
}
}()
next.ServeHTTP(w, r)
})
}Hati-hati — Middleware ini hanya menyelamatkan panic di dalam goroutine yang menangani request itu. Kalau handler-mu menjalankan go func(){...}() dan panic terjadi di dalamnya, seluruh proses tetap mati. Setiap goroutine yang kamu nyalakan sendiri harus punya defer recover()-nya sendiri.
CORS
func CORS(diizinkan []string) func(http.Handler) http.Handler {
// middleware dengan konfigurasi: fungsi yang mengembalikan middleware
set := make(map[string]struct{}, len(diizinkan))
for _, o := range diizinkan {
set[o] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if _, ok := set[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin") // penting untuk cache
}
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return // preflight berhenti di sini
}
next.ServeHTTP(w, r)
})
}
}Merangkai middleware
type Middleware func(http.Handler) http.Handler
func Rangkai(mw ...Middleware) Middleware {
return func(akhir http.Handler) http.Handler {
// dibungkus dari belakang, supaya urutan pemanggilan sesuai penulisan
for i := len(mw) - 1; i >= 0; i-- {
akhir = mw[i](akhir)
}
return akhir
}
}
// di main.go
global := middleware.Rangkai(
middleware.Recover, // paling luar: menangkap semua panic
middleware.RequestID,
middleware.Logging,
middleware.CORS(cfg.CORSOrigins),
)
srv := &http.Server{
Addr: ":8080",
Handler: global(mux),
}Catatan — Urutan itu bukan sembarang. Recover harus paling luar supaya bisa menangkap panic dari middleware lain juga. Logging sebaiknya sebelum autentikasi supaya request yang ditolak 401 tetap tercatat. Dan CORS harus sebelum autentikasi, kalau tidak preflight OPTIONS akan dibalas 401 dan browser memblokir semuanya.
Middleware untuk sebagian rute saja
mux := http.NewServeMux()
// publik
mux.HandleFunc("POST /api/auth/login", authH.Login)
mux.HandleFunc("POST /api/auth/register", authH.Register)
// terlindungi — dibungkus satu per satu
perluAuth := middleware.Rangkai(middleware.Auth(jwtSecret, userRepo))
mux.Handle("GET /api/produk", perluAuth(http.HandlerFunc(produkH.Daftar)))
mux.Handle("POST /api/produk", perluAuth(http.HandlerFunc(produkH.Simpan)))
// atau kelompokkan dengan sub-mux
adminMux := http.NewServeMux()
adminMux.HandleFunc("GET /pengguna", userH.Daftar)
adminMux.HandleFunc("DELETE /produk/{id}", produkH.Hapus)
mux.Handle("/api/admin/", http.StripPrefix("/api/admin",
middleware.Rangkai(
middleware.Auth(jwtSecret, userRepo),
middleware.PerluPeran("ADMIN"),
)(adminMux),
))Ini titik di mana router pihak ketiga seperti chi mulai terasa lebih nyaman, karena punya r.Group() dan r.Use(). Tapi perhatikan: pendekatan di atas tidak menyembunyikan apa pun. Kalau kamu penasaran middleware apa yang jalan di sebuah rute, jawabannya ada di satu tempat.
Materi 15 · Modul 2
Validasi request
Go tidak punya validator bawaan seperti class-validator. Dua pilihan, dan keduanya masuk akal.
Cara 1: method Validate sendiri
type BuatProdukInput struct {
Nama string `json:"nama"`
Harga int64 `json:"harga"`
Stok int `json:"stok"`
KategoriID *int64 `json:"kategori_id"`
}
func (in *BuatProdukInput) Validate() error {
ve := &apperr.ValidationError{}
in.Nama = strings.TrimSpace(in.Nama)
switch {
case in.Nama == "":
ve.Add("nama", "Nama produk wajib diisi")
case utf8.RuneCountInString(in.Nama) > 120:
ve.Add("nama", "Nama produk maksimal 120 karakter")
}
if in.Harga < 0 {
ve.Add("harga", "Harga tidak boleh negatif")
}
if in.Stok < 0 {
ve.Add("stok", "Stok tidak boleh negatif")
}
if len(ve.Fields) > 0 {
return ve
}
return nil
}func (h *ProdukHandler) Simpan(w http.ResponseWriter, r *http.Request) {
var in BuatProdukInput
if err := BacaJSON(w, r, &in); err != nil {
KirimError(w, 400, err.Error())
return
}
if err := in.Validate(); err != nil {
TanganiError(w, err) // jadi 422 dengan errors per field
return
}
user, _ := middleware.UserDari(r.Context())
produk, err := h.svc.Simpan(r.Context(), service.BuatProduk{
Nama: in.Nama,
Harga: in.Harga,
Stok: in.Stok,
}, user.ID)
if err != nil {
TanganiError(w, err)
return
}
KirimJSON(w, 201, Respons{Data: KeProdukRespons(produk)})
}Catatan — Perhatikan bentuk keluarannya: { "message": ..., "errors": { "nama": "pesan" } }. Sama seperti API Laravel dan NestJS di handbook sebelumnya, jadi komponen form React yang sama bisa dipakai tanpa perubahan.
Cara 2: go-playground/validator
Kalau kamu lebih suka gaya deklaratif seperti class-validator, ini yang paling banyak dipakai di ekosistem Go.
go get github.com/go-playground/validator/v10type BuatProdukInput struct {
Nama string `json:"nama" validate:"required,max=120"`
Harga int64 `json:"harga" validate:"gte=0"`
Stok int `json:"stok" validate:"gte=0"`
Email string `json:"email" validate:"omitempty,email"`
}
var validate = validator.New(validator.WithRequiredStructEnabled())
// ubah error validator jadi bentuk kita
func Periksa(in any) error {
err := validate.Struct(in)
if err == nil {
return nil
}
var verrs validator.ValidationErrors
if !errors.As(err, &verrs) {
return err
}
ve := &apperr.ValidationError{}
for _, e := range verrs {
ve.Add(namaJSON(in, e.Field()), pesanID(e))
}
return ve
}
// pesan dalam bahasa Indonesia
func pesanID(e validator.FieldError) string {
switch e.Tag() {
case "required":
return "Wajib diisi"
case "email":
return "Format email tidak valid"
case "min":
return fmt.Sprintf("Minimal %s karakter", e.Param())
case "max":
return fmt.Sprintf("Maksimal %s karakter", e.Param())
case "gte":
return fmt.Sprintf("Minimal %s", e.Param())
case "oneof":
return fmt.Sprintf("Pilih salah satu: %s", e.Param())
default:
return "Nilai tidak valid"
}
}Mana yang sebaiknya dipakai
| Pilihan | Cocok kalau |
|---|---|
| Method Validate | Aturannya melibatkan logika (saling bergantung antar field, perlu query database). Pesannya penuh kendali, dan tanpa dependency. |
| validator/v10 | Banyak endpoint dengan aturan standar. Jauh lebih hemat baris, tapi pesannya perlu diterjemahkan sendiri. |
Banyak project memakai keduanya: tag validator untuk aturan bentuk (wajib, panjang, format), dan method tambahan untuk aturan bisnis (tanggal mulai harus sebelum tanggal akhir, kategori harus ada di database).
Hati-hati — Jebakan zero value muncul di validasi. Harga int64 dengan nilai 0 tidak bisa dibedakan antara "harga memang nol" dan "field tidak dikirim". Kalau bedanya penting — terutama di endpoint update sebagian — pakai pointer: Harga *int64, lalu nil berarti "jangan ubah".
type UbahProdukInput struct {
Nama *string `json:"nama"`
Harga *int64 `json:"harga"`
Stok *int `json:"stok"`
}
// di service: hanya ubah yang dikirim
if in.Nama != nil {
produk.Nama = *in.Nama
produk.Slug = util.BikinSlug(*in.Nama)
}
if in.Harga != nil {
produk.Harga = *in.Harga
}Materi 16 · Modul 2
Struktur berlapis dan menyusun dependency
Tanpa container DI, kamu menyusun sendiri di main.go. Ternyata itu kelebihan.
Tiga lapisan dan tanggung jawabnya
handler/ ← HTTP saja: baca request, panggil service, tulis respons
│ tidak ada logika bisnis, tidak ada SQL
▼
service/ ← aturan bisnis: validasi domain, perhitungan, orkestrasi
│ tidak tahu apa pun tentang HTTP maupun SQL
▼
repository/ ← database saja: query, scan, terjemahkan error DB
tidak ada aturan bisnis
model/ ← struct data, tidak mengimpor apa punUji cepat apakah pemisahannya benar: service tidak boleh mengimpor net/http, dan repository tidak boleh memuat kata "harus" atau "tidak boleh". Kalau ada, itu aturan bisnis yang salah tempat.
Service
package service
// interface didefinisikan DI SINI, di tempat yang memakainya
type ProdukRepository interface {
Cari(ctx context.Context, id int64) (*model.Produk, error)
Daftar(ctx context.Context, f Filter) ([]model.Produk, int64, error)
Simpan(ctx context.Context, p *model.Produk) error
Ubah(ctx context.Context, p *model.Produk) error
HapusLunak(ctx context.Context, id int64) error
}
type ProdukService struct {
repo ProdukRepository
}
func NewProdukService(repo ProdukRepository) *ProdukService {
return &ProdukService{repo: repo}
}
type BuatProduk struct {
Nama string
Harga int64
Stok int
}
func (s *ProdukService) Simpan(
ctx context.Context, in BuatProduk, userID int64,
) (*model.Produk, error) {
p := &model.Produk{
Nama: in.Nama,
Slug: util.BikinSlug(in.Nama),
Harga: in.Harga,
Stok: in.Stok,
Aktif: true,
DibuatOleh: userID,
}
if err := s.repo.Simpan(ctx, p); err != nil {
return nil, fmt.Errorf("simpan produk: %w", err)
}
return p, nil
}
// aturan kepemilikan — bagian dari logika bisnis, bukan HTTP
func (s *ProdukService) Ubah(
ctx context.Context, id int64, in UbahProduk, user *model.User,
) (*model.Produk, error) {
p, err := s.repo.Cari(ctx, id)
if err != nil {
return nil, err
}
if user.Peran != "ADMIN" && p.DibuatOleh != user.ID {
return nil, apperr.ErrTidakBerhak
}
if in.Nama != nil {
p.Nama = *in.Nama
p.Slug = util.BikinSlug(*in.Nama)
}
if in.Harga != nil {
p.Harga = *in.Harga
}
if err := s.repo.Ubah(ctx, p); err != nil {
return nil, fmt.Errorf("ubah produk %d: %w", id, err)
}
return p, nil
}Menyusun semuanya di main.go
func main() {
if err := jalankan(); err != nil {
slog.Error("aplikasi berhenti", "err", err)
os.Exit(1)
}
}
func jalankan() error {
cfg, err := config.Muat()
if err != nil {
return fmt.Errorf("konfigurasi: %w", err)
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("koneksi database: %w", err)
}
defer pool.Close()
// ── susun dari bawah ke atas ──
produkRepo := repository.NewProdukRepo(pool)
userRepo := repository.NewUserRepo(pool)
produkSvc := service.NewProdukService(produkRepo)
authSvc := service.NewAuthService(userRepo, cfg.JWTSecret, cfg.JWTExpire)
produkH := handler.NewProdukHandler(produkSvc)
authH := handler.NewAuthHandler(authSvc)
mux := rute(cfg, produkH, authH, userRepo)
srv := &http.Server{
Addr: cfg.Addr,
Handler: middleware.Global(cfg)(mux),
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 20 * time.Second,
}
return jalankanDenganShutdown(srv)
}Catatan — Pola main() yang memanggil jalankan() error itu kebiasaan baik di Go. Alasannya: defer tidak dijalankan oleh os.Exit(), jadi kalau kamu log.Fatal di tengah main, defer pool.Close() terlewat. Dengan memisahkan, semua defer tetap jalan sebelum proses keluar.
Kenapa menyusun manual itu bukan kemunduran
- Seluruh grafik dependency terlihat di satu file. Tidak perlu membaca decorator di 30 file untuk tahu apa bergantung pada apa.
- Kesalahan ketangkap saat compile. Tidak ada "cannot resolve dependency" saat aplikasi menyala.
- Urutan inisialisasi eksplisit. Kamu tahu pasti apa yang dibuat lebih dulu.
Ongkosnya: main.go jadi panjang seiring project tumbuh. Untuk project besar ada google/wire yang menghasilkan kode penyusunan itu saat compile — tetap tanpa refleksi runtime. Tapi mulailah manual; kebanyakan project tidak pernah benar-benar membutuhkannya.
Latihan — Susun struktur lengkap untuk entitas kategori: model, interface repository di package service, implementasi repository sementara yang menyimpan di map di memori, service, dan handler untuk daftar serta tambah. Rangkai semuanya di main.go. Nanti di materi 19 repository-nya diganti Postgres tanpa mengubah service maupun handler sama sekali — itu pembuktian bahwa lapisannya benar.
Materi 17 · Modul 2
Konfigurasi dan graceful shutdown
Dua hal kecil yang membedakan API mainan dan API yang siap dipakai.
Konfigurasi yang divalidasi saat menyala
package config
type Config struct {
Env string
Addr string
DatabaseURL string
JWTSecret []byte
JWTExpire time.Duration
RefreshSecret []byte
RefreshExpire time.Duration
CORSOrigins []string
UploadMax int64
}
func Muat() (*Config, error) {
c := &Config{
Env: env("APP_ENV", "development"),
Addr: env("ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: []byte(os.Getenv("JWT_SECRET")),
RefreshSecret: []byte(os.Getenv("JWT_REFRESH_SECRET")),
JWTExpire: durasi("JWT_EXPIRE", 15*time.Minute),
RefreshExpire: durasi("JWT_REFRESH_EXPIRE", 7*24*time.Hour),
CORSOrigins: strings.Split(env("CORS_ORIGINS", "http://localhost:5173"), ","),
UploadMax: 2 << 20,
}
// kumpulkan SEMUA masalah, jangan berhenti di yang pertama
var masalah []string
if c.DatabaseURL == "" {
masalah = append(masalah, "DATABASE_URL wajib diisi")
}
if len(c.JWTSecret) < 32 {
masalah = append(masalah, "JWT_SECRET minimal 32 karakter")
}
if len(c.RefreshSecret) < 32 {
masalah = append(masalah, "JWT_REFRESH_SECRET minimal 32 karakter")
}
if string(c.JWTSecret) == string(c.RefreshSecret) {
masalah = append(masalah, "JWT_SECRET dan JWT_REFRESH_SECRET harus berbeda")
}
if len(masalah) > 0 {
return nil, fmt.Errorf("konfigurasi tidak valid:\n - %s",
strings.Join(masalah, "\n - "))
}
return c, nil
}
func env(kunci, bawaan string) string {
if v := os.Getenv(kunci); v != "" {
return v
}
return bawaan
}
func durasi(kunci string, bawaan time.Duration) time.Duration {
if v := os.Getenv(kunci); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return bawaan
}Catatan — Mengumpulkan semua masalah konfigurasi sebelum keluar itu detail kecil yang sangat terasa saat deploy. Daripada memperbaiki satu variabel, deploy ulang, lalu ketemu masalah berikutnya — kamu melihat semuanya sekaligus.
Membaca .env
go get github.com/joho/godotenv// hanya untuk development — di produksi env diatur systemd/Docker
_ = godotenv.Load() // error diabaikan: wajar kalau .env tidak ada
cfg, err := config.Muat()APP_ENV=development
ADDR=:8080
DATABASE_URL=postgres://postgres:rahasia@localhost:5432/toko?sslmode=disable
JWT_SECRET=ganti-dengan-string-acak-minimal-32-karakter
JWT_REFRESH_SECRET=string-acak-lain-yang-berbeda-32-karakter
JWT_EXPIRE=15m
JWT_REFRESH_EXPIRE=168h
CORS_ORIGINS=http://localhost:5173Graceful shutdown
Saat deploy atau restart, server harus berhenti menerima koneksi baru lalu menyelesaikan request yang sedang berjalan. Tanpa ini, request yang sedang diproses terputus di tengah — dan kalau sedang menyimpan pesanan, datanya bisa setengah jadi.
func jalankanDenganShutdown(srv *http.Server) error {
// tangkap sinyal dari OS
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
// jalankan server di goroutine terpisah
errCh := make(chan error, 1)
go func() {
slog.Info("server jalan", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
// tunggu: error server, atau sinyal berhenti
select {
case err := <-errCh:
return fmt.Errorf("server gagal: %w", err)
case <-ctx.Done():
slog.Info("sinyal berhenti diterima, menutup dengan rapi")
}
// beri waktu request yang sedang jalan untuk selesai
matiCtx, batal := context.WithTimeout(context.Background(), 20*time.Second)
defer batal()
if err := srv.Shutdown(matiCtx); err != nil {
// lewat batas waktu: paksa tutup
_ = srv.Close()
return fmt.Errorf("shutdown paksa: %w", err)
}
slog.Info("server berhenti dengan bersih")
return nil
}Hati-hati — srv.Shutdown() hanya menunggu request HTTP selesai. Pekerjaan latar yang kamu jalankan sendiri (worker pool, cron, goroutine pengirim notifikasi) tidak ikut ditunggu dan akan mati begitu proses keluar. Kalau ada, tambahkan sync.WaitGroup tingkat aplikasi dan tunggu juga sebelum return.
Kenapa ini penting saat deploy
Saat kamu menjalankan systemctl restart atau mengganti container, sistem mengirim SIGTERM lalu menunggu beberapa detik sebelum SIGKILL. Aplikasi yang menangani SIGTERM dengan benar bisa di-deploy tanpa ada satu pun request user yang gagal. Aplikasi yang tidak, akan membuang beberapa request setiap kali kamu deploy.
Materi 18 · Modul 3
Database dengan pgx
Go tidak punya ORM kaya fitur bawaan seperti banyak framework populer. Yang ada: SQL langsung dengan tipe yang aman, dan itu ternyata cukup nyaman.
Pilihan akses database
| Pilihan | Catatan |
|---|---|
| pgx | Driver Postgres terbaik. SQL ditulis sendiri. Paling cepat dan paling jelas. Kita pakai ini. |
| sqlc | Tulis SQL, dia menghasilkan kode Go bertipe aman. Kombinasi bagus dengan pgx — dibahas di materi berikutnya. |
| sqlx | Pembungkus database/sql dengan pemetaan struct. Lebih ringkas dari pgx murni, lintas database. |
| GORM | ORM paling lengkap di Go — cara memakainya mirip ORM di banyak framework lain. Nyaman di awal, tapi menyembunyikan query dan mudah menghasilkan N+1 tanpa kamu sadari. Banyak tim Go menghindarinya. |
Catatan — Kalau kamu terbiasa dengan ORM yang mengurus query secara otomatis, godaan terbesarnya memakai GORM supaya terasa familiar. Saran yang umum di komunitas Go: jangan. Menulis SQL sendiri di Go terasa lebih banyak kerja di minggu pertama, tapi kamu selalu tahu query apa yang jalan — dan itu justru alasan orang memilih Go.
Koneksi dan pool
go get github.com/jackc/pgx/v5package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func Buka(ctx context.Context, url string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(url)
if err != nil {
return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
}
cfg.MaxConns = 25 // sesuaikan dengan batas Postgres
cfg.MinConns = 2
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Minute
cfg.HealthCheckPeriod = time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("buat pool: %w", err)
}
// pastikan benar-benar tersambung, jangan tunggu request pertama
pingCtx, batal := context.WithTimeout(ctx, 5*time.Second)
defer batal()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return pool, nil
}Hati-hati — MaxConns dikali jumlah instance aplikasi tidak boleh melewati max_connections Postgres (bawaannya 100). Kalau kamu menjalankan 4 instance dengan MaxConns 25, itu sudah 100 dan tidak ada sisa untuk psql maupun tool migration. Turunkan angkanya, atau pakai PgBouncer.
Query dasar
// satu baris — Scan memetakan kolom ke variabel, urutannya harus cocok
var p model.Produk
err := pool.QueryRow(ctx,
`SELECT id, nama, harga, stok FROM produk WHERE id = $1`, id,
).Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok)
if errors.Is(err, pgx.ErrNoRows) {
return nil, apperr.ErrTidakDitemukan
}
// banyak baris
rows, err := pool.Query(ctx, `SELECT id, nama, harga FROM produk LIMIT $1`, 10)
if err != nil {
return nil, err
}
defer rows.Close() // WAJIB, kalau tidak koneksinya bocor
var hasil []model.Produk
for rows.Next() {
var p model.Produk
if err := rows.Scan(&p.ID, &p.Nama, &p.Harga); err != nil {
return nil, err
}
hasil = append(hasil, p)
}
if err := rows.Err(); err != nil { // periksa error SETELAH loop!
return nil, err
}
// tanpa hasil
_, err = pool.Exec(ctx, `DELETE FROM produk WHERE id = $1`, id)Hati-hati — Dua hal yang sering dilupakan dan menyebabkan bug halus: defer rows.Close() (tanpa ini koneksi pool habis setelah beberapa ratus request) dan rows.Err() setelah loop (error yang terjadi di tengah pembacaan tidak muncul dari rows.Next(), jadi tanpa pemeriksaan ini kamu mengembalikan data yang tidak lengkap sebagai kalau berhasil).
pgx punya pembantu yang meringkas itu semua
import "github.com/jackc/pgx/v5"
// petakan langsung ke slice struct berdasarkan nama kolom
rows, err := pool.Query(ctx, `SELECT id, nama, harga, stok FROM produk`)
if err != nil {
return nil, err
}
produk, err := pgx.CollectRows(rows, pgx.RowToStructByName[model.Produk])
// Close dan Err sudah diurus CollectRows
// satu baris ke struct
rows, _ := pool.Query(ctx, `SELECT ... WHERE id = $1`, id)
p, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[model.Produk])Catatan — RowToStructByName mencocokkan nama kolom database dengan field struct (huruf kecil, underscore dihilangkan). Untuk nama yang tidak cocok, tambahkan tag db:"nama_kolom" pada field. Ini menghilangkan sebagian besar kode Scan yang membosankan, tanpa menyembunyikan SQL-nya.
Parameter, bukan penggabungan string
// ❌ SQL injection — jangan pernah
q := "SELECT * FROM produk WHERE nama = '" + cari + "'"
// ✅ parameter — $1, $2 di Postgres
pool.Query(ctx, `SELECT * FROM produk WHERE nama ILIKE $1`, "%"+cari+"%")Perlu diingat: nama kolom dan arah pengurutan tidak bisa jadi parameter. Untuk itu, satu-satunya cara aman adalah daftar putih tertutup — dibahas di materi 21.
Menangani NULL
// kolom nullable → pointer di struct
type Produk struct {
ID int64
Nama string
KategoriID *int64 // NULL → nil
Gambar *string
DeletedAt *time.Time
}
// atau tipe sql.Null kalau butuh lebih eksplisit
var catatan sql.NullString
row.Scan(&catatan)
if catatan.Valid {
fmt.Println(catatan.String)
}Hati-hati — Men-scan kolom NULL ke string biasa menghasilkan error can't scan NULL into *string. Ini salah satu error paling sering ditemui pemula Go di database, dan penyebabnya hampir selalu satu: kolom di database boleh NULL tapi field Go-nya bukan pointer.
Materi 19 · Modul 3
Migration dan sqlc
Mengelola struktur database, dan menghasilkan kode Go bertipe aman dari SQL.
Migration dengan golang-migrate
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
migrate create -ext sql -dir migrations -seq buat_tabel_users
migrate create -ext sql -dir migrations -seq buat_tabel_produk
# menghasilkan:
# migrations/000001_buat_tabel_users.up.sql
# migrations/000001_buat_tabel_users.down.sqlCREATE TYPE peran AS ENUM ('ADMIN', 'KASIR', 'PEMILIK');
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(160) NOT NULL UNIQUE,
nama VARCHAR(80) NOT NULL,
password_hash TEXT NOT NULL,
peran peran NOT NULL DEFAULT 'KASIR',
refresh_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_users_email ON users (email);CREATE TABLE kategori (
id BIGSERIAL PRIMARY KEY,
nama VARCHAR(80) NOT NULL UNIQUE,
slug VARCHAR(80) NOT NULL UNIQUE
);
CREATE TABLE produk (
id BIGSERIAL PRIMARY KEY,
nama VARCHAR(120) NOT NULL,
slug VARCHAR(140) NOT NULL UNIQUE,
harga BIGINT NOT NULL CHECK (harga >= 0),
stok INTEGER NOT NULL DEFAULT 0 CHECK (stok >= 0),
aktif BOOLEAN NOT NULL DEFAULT TRUE,
gambar TEXT,
deskripsi TEXT,
kategori_id BIGINT REFERENCES kategori (id) ON DELETE SET NULL,
dibuat_oleh BIGINT NOT NULL REFERENCES users (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- index untuk pola query yang benar-benar dipakai
CREATE INDEX idx_produk_aktif ON produk (aktif) WHERE deleted_at IS NULL;
CREATE INDEX idx_produk_kategori ON produk (kategori_id) WHERE deleted_at IS NULL;
-- pencarian teks yang cepat, butuh ekstensi pg_trgm
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_produk_nama_trgm ON produk USING gin (nama gin_trgm_ops);Catatan — CHECK (harga >= 0) dan CHECK (stok >= 0) itu lapis pertahanan terakhir. Validasi di Go bisa terlewat karena bug atau jalur kode baru; constraint database tidak bisa dilewati siapa pun, termasuk skrip manual dan psql. Untuk data yang berhubungan dengan uang dan stok, selalu pasang di kedua tempat.
export DB_URL="postgres://postgres:rahasia@localhost:5432/toko?sslmode=disable"
migrate -path migrations -database "$DB_URL" up
migrate -path migrations -database "$DB_URL" down 1
migrate -path migrations -database "$DB_URL" version
migrate -path migrations -database "$DB_URL" force 2 # perbaiki state kotorMenjalankan migration dari dalam aplikasi
import (
"embed"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed migrations/*.sql
var fileMigrasi embed.FS
func Migrasi(dbURL string) error {
src, err := iofs.New(fileMigrasi, "migrations")
if err != nil {
return err
}
m, err := migrate.NewWithSourceInstance("iofs", src, dbURL)
if err != nil {
return err
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("jalankan migrasi: %w", err)
}
return nil
}Catatan — Direktif //go:embed menanamkan file SQL ke dalam binary. Jadi satu file hasil go build sudah membawa migrationnya sendiri; kamu tidak perlu menyalin folder migrations/ ke server. Ini contoh bagus dari keunggulan deploy Go: satu file, tidak ada aset yang tertinggal.
sqlc: SQL yang jadi kode Go bertipe aman
Ini pilihan yang layak dipertimbangkan serius. Kamu tetap menulis SQL, tapi sqlc menghasilkan fungsi Go dengan tipe parameter dan hasil yang benar — dan kesalahan nama kolom ketangkap saat generate, bukan saat runtime.
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latestversion: "2"
sql:
- engine: "postgresql"
queries: "./internal/database/query"
schema: "./migrations"
gen:
go:
package: "db"
out: "./internal/database/db"
sql_package: "pgx/v5"
emit_pointers_for_null_types: true
emit_json_tags: true-- name: CariProduk :one
SELECT * FROM produk
WHERE id = $1 AND deleted_at IS NULL;
-- name: DaftarProduk :many
SELECT * FROM produk
WHERE deleted_at IS NULL
AND ($1::text IS NULL OR nama ILIKE '%' || $1 || '%')
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: SimpanProduk :one
INSERT INTO produk (nama, slug, harga, stok, dibuat_oleh)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: KurangiStok :execrows
UPDATE produk SET stok = stok - $2, updated_at = now()
WHERE id = $1 AND stok >= $2;sqlc generate
// kode Go yang dihasilkan, siap dipakai:
produk, err := q.CariProduk(ctx, 7) // tipe: db.Produk
list, err := q.DaftarProduk(ctx, db.DaftarProdukParams{
Nama: &cari,
Limit: 10,
Offset: 0,
})Catatan — Keuntungan sqlc yang paling terasa: salah tulis nama kolom terdeteksi saat sqlc generate, karena dia memeriksa query-mu terhadap schema. Dengan pgx murni, salah nama kolom baru muncul sebagai error saat endpoint itu dipanggil. Untuk project yang akan tumbuh, ini penghematan waktu yang nyata.
Suffix nama query di sqlc
| Suffix | Menghasilkan |
|---|---|
:one | Satu baris; error pgx.ErrNoRows kalau tidak ada |
:many | Slice hasil |
:exec | Hanya error |
:execrows | Jumlah baris yang terpengaruh — berguna untuk memastikan update benar-benar terjadi |
:batchexec | Eksekusi banyak sekaligus dalam satu perjalanan ke database |
Di sisa handbook ini kita pakai pgx langsung, supaya SQL-nya terlihat jelas. Tapi untuk project sungguhan, sqlc + pgx adalah kombinasi yang paling banyak direkomendasikan sekarang.
Materi 20 · Modul 3
Repository dan CRUD lengkap
Lapisan yang berurusan dengan database, dan menerjemahkan errornya jadi error domain.
Repository produk
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type ProdukRepo struct {
db *pgxpool.Pool
}
func NewProdukRepo(db *pgxpool.Pool) *ProdukRepo {
return &ProdukRepo{db: db}
}
const kolomProduk = `id, nama, slug, harga, stok, aktif, gambar,
kategori_id, dibuat_oleh, created_at, updated_at`
// ── CARI SATU ──
func (r *ProdukRepo) Cari(ctx context.Context, id int64) (*model.Produk, error) {
q := fmt.Sprintf(
`SELECT %s FROM produk WHERE id = $1 AND deleted_at IS NULL`,
kolomProduk,
)
rows, err := r.db.Query(ctx, q, id)
if err != nil {
return nil, fmt.Errorf("query cari produk: %w", err)
}
p, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[model.Produk])
if errors.Is(err, pgx.ErrNoRows) {
return nil, apperr.ErrTidakDitemukan // terjemahkan ke error domain
}
if err != nil {
return nil, fmt.Errorf("scan produk: %w", err)
}
return &p, nil
}
// ── SIMPAN ──
func (r *ProdukRepo) Simpan(ctx context.Context, p *model.Produk) error {
q := `INSERT INTO produk (nama, slug, harga, stok, aktif, kategori_id, dibuat_oleh)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`
err := r.db.QueryRow(ctx, q,
p.Nama, p.Slug, p.Harga, p.Stok, p.Aktif, p.KategoriID, p.DibuatOleh,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) // isi balik ke struct
if err != nil {
// terjemahkan pelanggaran unique jadi error domain
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return apperr.ErrSudahAda
}
return fmt.Errorf("insert produk: %w", err)
}
return nil
}
// ── UBAH ──
func (r *ProdukRepo) Ubah(ctx context.Context, p *model.Produk) error {
q := `UPDATE produk
SET nama = $2, slug = $3, harga = $4, stok = $5,
aktif = $6, kategori_id = $7, updated_at = now()
WHERE id = $1 AND deleted_at IS NULL
RETURNING updated_at`
err := r.db.QueryRow(ctx, q,
p.ID, p.Nama, p.Slug, p.Harga, p.Stok, p.Aktif, p.KategoriID,
).Scan(&p.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return apperr.ErrTidakDitemukan
}
if err != nil {
return fmt.Errorf("update produk %d: %w", p.ID, err)
}
return nil
}
// ── HAPUS LUNAK ──
func (r *ProdukRepo) HapusLunak(ctx context.Context, id int64) error {
q := `UPDATE produk SET deleted_at = now(), aktif = FALSE
WHERE id = $1 AND deleted_at IS NULL`
tag, err := r.db.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("hapus produk %d: %w", id, err)
}
if tag.RowsAffected() == 0 {
return apperr.ErrTidakDitemukan // tidak ada yang berubah = tidak ada
}
return nil
}Catatan — Perhatikan pola RETURNING: Postgres mengembalikan nilai yang dihasilkan server (id, timestamp) dalam satu perjalanan, jadi tidak perlu query kedua untuk membaca data yang baru disimpan. Dan pola RowsAffected() == 0 pada delete membedakan "berhasil dihapus" dari "tidak ada yang cocok" — tanpa itu, menghapus id yang tidak ada akan dibalas 204 seolah berhasil.
Handler dan rute
func rute(
cfg *config.Config,
produkH *handler.ProdukHandler,
authH *handler.AuthHandler,
userRepo *repository.UserRepo,
) http.Handler {
mux := http.NewServeMux()
// publik
mux.HandleFunc("GET /api/health", handler.Health)
mux.HandleFunc("POST /api/auth/register", authH.Register)
mux.HandleFunc("POST /api/auth/login", authH.Login)
mux.HandleFunc("POST /api/auth/refresh", authH.Refresh)
// perlu login
auth := middleware.Rangkai(middleware.Auth(cfg.JWTSecret, userRepo))
mux.Handle("GET /api/auth/me", auth(http.HandlerFunc(authH.Saya)))
mux.Handle("POST /api/auth/logout", auth(http.HandlerFunc(authH.Logout)))
mux.Handle("GET /api/produk", auth(http.HandlerFunc(produkH.Daftar)))
mux.Handle("GET /api/produk/{id}", auth(http.HandlerFunc(produkH.Cari)))
mux.Handle("POST /api/produk", auth(http.HandlerFunc(produkH.Simpan)))
mux.Handle("PATCH /api/produk/{id}", auth(http.HandlerFunc(produkH.Ubah)))
// khusus admin
admin := middleware.Rangkai(
middleware.Auth(cfg.JWTSecret, userRepo),
middleware.PerluPeran("ADMIN"),
)
mux.Handle("DELETE /api/produk/{id}", admin(http.HandlerFunc(produkH.Hapus)))
return mux
}Seluruh peta rute dan hak aksesnya ada di satu fungsi, terbaca dari atas ke bawah. Tidak perlu mencari decorator di file lain untuk tahu endpoint mana yang terbuka.
Uji dengan file .http
@host = http://localhost:8080/api
@token = tempel-token-dari-login
### login
POST {{host}}/auth/login
Content-Type: application/json
{ "email": "admin@toko.com", "password": "rahasia123" }
### tambah produk
POST {{host}}/produk
Authorization: Bearer {{token}}
Content-Type: application/json
{ "nama": "Kaos Onlenkan", "harga": 85000, "stok": 24 }
### daftar dengan pencarian
GET {{host}}/produk?cari=kaos&page=1&limit=10
Authorization: Bearer {{token}}Latihan — Ganti repository kategori berbasis map dari materi 16 dengan implementasi Postgres. Perhatikan bahwa service dan handler tidak perlu diubah sebaris pun — itu bukti interface-nya dirancang benar.
Materi 21 · Modul 3
Pagination, filter, dan transaksi
Endpoint daftar yang siap dipakai tabel frontend, dan operasi yang harus utuh atau tidak sama sekali.
Query dinamis yang aman
type Filter struct {
Cari string
KategoriID *int64
Aktif *bool
Urutkan string
Arah string
Page int
Limit int
}
// DAFTAR PUTIH — satu-satunya cara aman untuk kolom pengurutan
var kolomUrutBoleh = map[string]string{
"nama": "nama",
"harga": "harga",
"stok": "stok",
"created_at": "created_at",
}
func (r *ProdukRepo) Daftar(
ctx context.Context, f Filter,
) ([]model.Produk, int64, error) {
// susun kondisi dan argumen bersamaan
kondisi := []string{"deleted_at IS NULL"}
args := []any{}
n := 1
if f.Cari != "" {
kondisi = append(kondisi, fmt.Sprintf("nama ILIKE $%d", n))
args = append(args, "%"+f.Cari+"%")
n++
}
if f.KategoriID != nil {
kondisi = append(kondisi, fmt.Sprintf("kategori_id = $%d", n))
args = append(args, *f.KategoriID)
n++
}
if f.Aktif != nil {
kondisi = append(kondisi, fmt.Sprintf("aktif = $%d", n))
args = append(args, *f.Aktif)
n++
}
where := "WHERE " + strings.Join(kondisi, " AND ")
// hitung total dulu, pakai kondisi yang sama
var total int64
qHitung := "SELECT COUNT(*) FROM produk " + where
if err := r.db.QueryRow(ctx, qHitung, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("hitung produk: %w", err)
}
if total == 0 {
return []model.Produk{}, 0, nil // hemat satu query
}
// kolom urut HANYA dari daftar putih — tidak pernah dari input langsung
kolom, ok := kolomUrutBoleh[f.Urutkan]
if !ok {
kolom = "created_at"
}
arah := "DESC"
if strings.EqualFold(f.Arah, "asc") {
arah = "ASC"
}
offset := (f.Page - 1) * f.Limit
q := fmt.Sprintf(
`SELECT %s FROM produk %s ORDER BY %s %s, id DESC LIMIT $%d OFFSET $%d`,
kolomProduk, where, kolom, arah, n, n+1,
)
args = append(args, f.Limit, offset)
rows, err := r.db.Query(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("query daftar produk: %w", err)
}
items, err := pgx.CollectRows(rows, pgx.RowToStructByName[model.Produk])
if err != nil {
return nil, 0, fmt.Errorf("scan daftar produk: %w", err)
}
return items, total, nil
}Hati-hati — Perhatikan pembagian tugasnya: nilai selalu lewat parameter $n, sementara nama kolom dan arah pengurutan digabung ke string SQL — tapi hanya setelah lolos daftar putih. Menggabungkan input user langsung ke bagian ORDER BY adalah celah injeksi yang sering terlewat, karena orang mengira parameter sudah melindungi semuanya.
Catatan — ORDER BY kolom, id DESC — pengurutan kedua berdasarkan id itu penting. Tanpa itu, baris dengan nilai yang sama (misalnya dua produk berharga 85000) bisa muncul dalam urutan berbeda di setiap query, sehingga satu produk bisa muncul di halaman 1 dan halaman 2, sementara produk lain tidak muncul sama sekali.
Handler daftar
func (h *ProdukHandler) Daftar(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := repository.Filter{
Cari: strings.TrimSpace(q.Get("cari")),
Urutkan: q.Get("urutkan"),
Arah: q.Get("arah"),
Page: angkaQuery(q, "page", 1),
Limit: angkaQuery(q, "limit", 10),
}
if f.Limit > 100 {
f.Limit = 100 // batasi, jangan izinkan minta 10.000 baris
}
items, total, err := h.svc.Daftar(r.Context(), f)
if err != nil {
TanganiError(w, err)
return
}
respons := make([]ProdukRespons, 0, len(items))
for i := range items {
respons = append(respons, KeProdukRespons(&items[i]))
}
lastPage := int((total + int64(f.Limit) - 1) / int64(f.Limit))
KirimJSON(w, 200, Respons{
Data: respons,
Meta: &Meta{Page: f.Page, Limit: f.Limit, Total: total, LastPage: lastPage},
})
}{
"data": [{ "id": 7, "nama": "Kaos", "harga": 85000, "harga_text": "Rp 85.000" }],
"meta": { "page": 1, "limit": 10, "total": 48, "lastPage": 5 }
}Bentuknya identik dengan API NestJS dan Laravel di handbook sebelumnya, jadi halaman produk React yang sama bisa langsung dipakai.
Transaksi
func (r *PesananRepo) Buat(
ctx context.Context, p *model.Pesanan, items []model.ItemPesanan,
) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return fmt.Errorf("mulai transaksi: %w", err)
}
// aman: Rollback setelah Commit tidak berefek apa-apa
defer tx.Rollback(ctx)
// 1. simpan pesanan
err = tx.QueryRow(ctx,
`INSERT INTO pesanan (nama_pembeli, total, status)
VALUES ($1, $2, 'PENDING') RETURNING id, created_at`,
p.NamaPembeli, p.Total,
).Scan(&p.ID, &p.CreatedAt)
if err != nil {
return fmt.Errorf("insert pesanan: %w", err)
}
// 2. kurangi stok dengan aman & simpan item
for _, it := range items {
// syarat stok ada di dalam WHERE — bukan dibaca lalu dibandingkan.
// ini yang mencegah overselling saat dua pesanan datang bersamaan.
tag, err := tx.Exec(ctx,
`UPDATE produk SET stok = stok - $2, updated_at = now()
WHERE id = $1 AND stok >= $2 AND deleted_at IS NULL`,
it.ProdukID, it.Qty,
)
if err != nil {
return fmt.Errorf("kurangi stok produk %d: %w", it.ProdukID, err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("produk %d: %w", it.ProdukID, apperr.ErrStokKurang)
}
_, err = tx.Exec(ctx,
`INSERT INTO item_pesanan (pesanan_id, produk_id, harga, qty)
VALUES ($1, $2, $3, $4)`,
p.ID, it.ProdukID, it.Harga, it.Qty,
)
if err != nil {
return fmt.Errorf("insert item: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}Hati-hati — defer tx.Rollback(ctx) tepat setelah Begin adalah pola wajib. Kalau ada return di mana pun sebelum Commit — termasuk karena panic — transaksinya otomatis dibatalkan. Tanpa defer ini, satu jalur error yang terlewat meninggalkan transaksi menggantung yang memegang lock di database sampai koneksinya mati.
Catatan — Bagian terpenting di kode itu: WHERE id = $1 AND stok >= $2. Syarat stoknya diserahkan ke database, sehingga pemeriksaan dan pengurangan terjadi dalam satu operasi atomik. Kalau kamu membaca stok ke variabel Go, memeriksanya dengan if, lalu menulis nilai baru — dua pesanan bersamaan bisa sama-sama lolos pemeriksaan dan menjual barang yang sama dua kali. Untuk POS dan Open PO, ini bukan kasus teoretis.
Menghindari N+1
// ❌ 1 + N query
for i := range produk {
kategori, _ := kategoriRepo.Cari(ctx, *produk[i].KategoriID)
produk[i].Kategori = kategori
}
// ✅ JOIN, satu query
q := `SELECT p.id, p.nama, p.harga, k.id, k.nama
FROM produk p
LEFT JOIN kategori k ON k.id = p.kategori_id
WHERE p.deleted_at IS NULL`
// ✅ atau dua query: ambil semua kategori terkait sekaligus
ids := kumpulkanKategoriID(produk)
kategori, _ := kategoriRepo.CariBanyak(ctx, ids) // WHERE id = ANY($1)
peta := indexByID(kategori)Cara paling cepat mendeteksi N+1: nyalakan log query saat development, buka satu endpoint, dan hitung jumlah query. Kalau jumlahnya bertambah seiring jumlah baris data, kamu punya N+1.
Latihan — Tambahkan endpoint POST /api/pesanan yang memakai transaksi di atas. Lalu uji kondisi berebut: jalankan dua request bersamaan yang memesan produk dengan stok 1, dan pastikan tepat satu berhasil dan satu dibalas 409 dengan pesan stok tidak cukup.
Materi 22 · Modul 4
Password dan registrasi
Bagian yang tidak boleh dikira-kira. Untungnya jawabannya singkat.
bcrypt
go get golang.org/x/crypto/bcryptpackage util
import "golang.org/x/crypto/bcrypt"
func HashPassword(p string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(b), nil
}
func CekPassword(hash, p string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)) == nil
}DefaultCost sekarang bernilai 10 — cukup untuk sebagian besar kasus. Naikkan ke 12 kalau servermu kuat; setiap kenaikan satu berarti dua kali lebih lambat dihitung, yang menyusahkan penyerang tapi tidak terasa di login normal.
Hati-hati — bcrypt hanya memakai 72 byte pertama dari password. Password yang lebih panjang bukan error, tapi sisanya diabaikan. Untuk mencegah kebingungan, batasi panjang password di validasi (misalnya maksimal 72 karakter). Dan jangan pernah pakai md5 atau sha256 — keduanya dirancang cepat, yang berarti miliaran percobaan per detik dengan GPU.
Repository user
type UserRepo struct {
db *pgxpool.Pool
}
// kolom yang aman keluar ke klien
const kolomUserPublik = `id, email, nama, peran, created_at`
func (r *UserRepo) Simpan(ctx context.Context, u *model.User) error {
q := `INSERT INTO users (email, nama, password_hash, peran)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at`
err := r.db.QueryRow(ctx, q,
strings.ToLower(u.Email), u.Nama, u.PasswordHash, u.Peran,
).Scan(&u.ID, &u.CreatedAt)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return apperr.ErrSudahAda
}
if err != nil {
return fmt.Errorf("insert user: %w", err)
}
return nil
}
// khusus login — satu-satunya tempat hash diambil
func (r *UserRepo) CariUntukAuth(
ctx context.Context, email string,
) (*model.User, error) {
q := `SELECT id, email, nama, password_hash, peran, refresh_hash
FROM users WHERE email = $1`
var u model.User
err := r.db.QueryRow(ctx, q, strings.ToLower(email)).Scan(
&u.ID, &u.Email, &u.Nama, &u.PasswordHash, &u.Peran, &u.RefreshHash,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, apperr.ErrTidakDitemukan
}
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return &u, nil
}
// dipakai middleware auth setiap request
func (r *UserRepo) Cari(ctx context.Context, id int64) (*model.User, error) {
q := `SELECT ` + kolomUserPublik + ` FROM users WHERE id = $1`
var u model.User
err := r.db.QueryRow(ctx, q, id).Scan(
&u.ID, &u.Email, &u.Nama, &u.Peran, &u.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, apperr.ErrTidakDitemukan
}
return &u, err
}Catatan — Model User punya PasswordHash string dengan tag json:"-", dan handler tetap memakai struct output terpisah. Dua lapis pertahanan untuk hal yang sama, karena satu kelalaian di sini berarti hash password seluruh penggunamu terkirim ke browser.
Registrasi
type Daftar struct {
Email string
Nama string
Password string
}
func (s *AuthService) Daftar(
ctx context.Context, in Daftar,
) (*model.User, *Token, error) {
hash, err := util.HashPassword(in.Password)
if err != nil {
return nil, nil, err
}
u := &model.User{
Email: in.Email,
Nama: in.Nama,
PasswordHash: hash,
Peran: "KASIR",
}
if err := s.users.Simpan(ctx, u); err != nil {
return nil, nil, err // ErrSudahAda diteruskan → 409
}
token, err := s.terbitkanToken(ctx, u)
if err != nil {
return nil, nil, err
}
return u, token, nil
}Validasi input registrasi
type DaftarInput struct {
Email string `json:"email"`
Nama string `json:"nama"`
Password string `json:"password"`
}
func (in *DaftarInput) Validate() error {
ve := &apperr.ValidationError{}
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
in.Nama = strings.TrimSpace(in.Nama)
if _, err := mail.ParseAddress(in.Email); err != nil {
ve.Add("email", "Format email tidak valid")
}
if utf8.RuneCountInString(in.Nama) < 2 {
ve.Add("nama", "Nama minimal 2 karakter")
}
switch {
case len(in.Password) < 8:
ve.Add("password", "Password minimal 8 karakter")
case len(in.Password) > 72:
ve.Add("password", "Password maksimal 72 karakter")
case !adaHuruf(in.Password) || !adaAngka(in.Password):
ve.Add("password", "Password harus memuat huruf dan angka")
}
if len(ve.Fields) > 0 {
return ve
}
return nil
}Catatan — mail.ParseAddress dari pustaka standar sudah cukup baik untuk memeriksa format email, dan lebih akurat daripada regex buatan sendiri. Untuk memastikan email itu benar-benar ada, satu-satunya cara adalah mengirim email verifikasi.
Materi 23 · Modul 4
Login dengan JWT
Menerbitkan token yang membuktikan identitas user di setiap request berikutnya.
Pasang
go get github.com/golang-jwt/jwt/v5Claims dan pembuatan token
package service
import (
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int64 `json:"uid"`
Peran string `json:"peran"`
jwt.RegisteredClaims // menyematkan sub, exp, iat, iss
}
type Token struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int64 `json:"expiresIn"`
}
func (s *AuthService) buatAccessToken(u *model.User) (string, error) {
sekarang := time.Now()
claims := Claims{
UserID: u.ID,
Peran: u.Peran,
RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.FormatInt(u.ID, 10),
ExpiresAt: jwt.NewNumericDate(sekarang.Add(s.expire)),
IssuedAt: jwt.NewNumericDate(sekarang),
NotBefore: jwt.NewNumericDate(sekarang),
Issuer: "toko-api",
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString(s.secret)
}Hati-hati — Isi payload JWT hanya di-encode base64, bukan dienkripsi. Siapa pun yang memegang token bisa membacanya dengan satu tempel ke jwt.io. Jadi jangan pernah menaruh data sensitif di claims: cukup id user, peran, dan (kalau multi-tenant) id tenant.
Memeriksa token
func PeriksaToken(tokenStr string, secret []byte) (*Claims, error) {
claims := &Claims{}
t, err := jwt.ParseWithClaims(tokenStr, claims,
func(t *jwt.Token) (any, error) {
// WAJIB: pastikan algoritmanya yang kita harapkan
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("algoritma tidak didukung: %v", t.Header["alg"])
}
return secret, nil
},
jwt.WithIssuer("toko-api"),
jwt.WithValidMethods([]string{"HS256"}),
)
if err != nil || !t.Valid {
return nil, apperr.ErrTokenTidakValid
}
return claims, nil
}Hati-hati — Pemeriksaan algoritma itu bukan formalitas. Ada kerentanan klasik di mana penyerang mengubah header token jadi "alg": "none" atau menukar HMAC dengan RSA, dan library yang tidak memeriksa akan menerimanya. Selalu pasang jwt.WithValidMethods dan periksa SigningMethodHMAC seperti di atas.
Service login
type AuthService struct {
users UserRepository
secret []byte
refreshSecret []byte
expire time.Duration
refreshExpire time.Duration
}
func (s *AuthService) Login(
ctx context.Context, email, password string,
) (*model.User, *Token, error) {
u, err := s.users.CariUntukAuth(ctx, email)
// email tidak ada DAN password salah → pesan yang SAMA
if errors.Is(err, apperr.ErrTidakDitemukan) {
// hash palsu supaya waktu responsnya serupa dengan password salah
util.CekPassword(hashDummy, password)
return nil, nil, apperr.ErrKredensial
}
if err != nil {
return nil, nil, err
}
if !util.CekPassword(u.PasswordHash, password) {
return nil, nil, apperr.ErrKredensial
}
token, err := s.terbitkanToken(ctx, u)
if err != nil {
return nil, nil, err
}
return u, token, nil
}Catatan — Dua perlindungan di potongan itu. Pertama, pesan error yang sama untuk email tidak ada dan password salah, supaya penyerang tidak bisa memeriksa siapa saja yang punya akun (namanya user enumeration). Kedua, memanggil CekPassword dengan hash dummy saat user tidak ada — tanpa itu, respons untuk email tidak terdaftar datang jauh lebih cepat, dan selisih waktu itu sendiri sudah membocorkan informasi.
Handler login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var in LoginInput
if err := BacaJSON(w, r, &in); err != nil {
KirimError(w, 400, err.Error())
return
}
if err := in.Validate(); err != nil {
TanganiError(w, err)
return
}
u, token, err := h.svc.Login(r.Context(), in.Email, in.Password)
if err != nil {
TanganiError(w, err) // ErrKredensial → 401
return
}
KirimJSON(w, 200, map[string]any{
"user": KeUserRespons(u),
"accessToken": token.AccessToken,
"refreshToken": token.RefreshToken,
"expiresIn": token.ExpiresIn,
})
}{
"user": { "id": 1, "email": "nus@toko.com", "nama": "Nus", "peran": "ADMIN" },
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"expiresIn": 900
}Bentuknya sama dengan API NestJS di handbook sebelumnya, jadi AuthContext React yang sama bisa dipakai tanpa perubahan.
Materi 24 · Modul 4
Middleware auth dan user di context
Menjaga endpoint, dan menyediakan data user yang login ke handler.
Middleware autentikasi
package middleware
type UserPencari interface {
Cari(ctx context.Context, id int64) (*model.User, error)
}
func Auth(secret []byte, users UserPencari) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. ambil token dari header
auth := r.Header.Get("Authorization")
tokenStr, ok := strings.CutPrefix(auth, "Bearer ")
if !ok || tokenStr == "" {
handler.KirimError(w, 401, "Token tidak ditemukan")
return
}
// 2. periksa tanda tangan dan masa berlaku
claims, err := service.PeriksaToken(tokenStr, secret)
if err != nil {
handler.KirimError(w, 401, "Sesi tidak valid atau sudah berakhir")
return
}
// 3. ambil user dari database — bisa saja sudah dinonaktifkan
u, err := users.Cari(r.Context(), claims.UserID)
if err != nil {
handler.KirimError(w, 401, "Sesi tidak valid")
return
}
// 4. titipkan di context untuk handler
ctx := SimpanUser(r.Context(), u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}Catatan — Langkah 3 menambah satu query per request. Trade-off-nya: user yang baru dinonaktifkan langsung kehilangan akses, bukan menunggu tokennya kedaluwarsa. Untuk API bisnis, satu query berindeks itu murah. Kalau nanti perlu dihemat, cache user di Redis dengan TTL pendek — jangan hilangkan pemeriksaannya sama sekali.
Menyimpan dan mengambil user dari context
// tipe kunci sendiri — mencegah tabrakan dengan package lain
type ctxKey int
const (
userKey ctxKey = iota
reqIDKey
)
func SimpanUser(ctx context.Context, u *model.User) context.Context {
return context.WithValue(ctx, userKey, u)
}
func UserDari(ctx context.Context) (*model.User, bool) {
u, ok := ctx.Value(userKey).(*model.User)
return u, ok
}
// versi yang panic kalau tidak ada — pakai di handler yang PASTI di balik Auth
func MustUser(ctx context.Context) *model.User {
u, ok := UserDari(ctx)
if !ok {
panic("MustUser dipanggil tanpa middleware Auth")
}
return u
}MustUser yang panic itu sengaja: kalau kondisi itu terjadi, artinya kamu salah merangkai rute — bug programmer, bukan kesalahan user. Middleware recover akan mengubahnya jadi 500 dan mencatat stack trace-nya, dan kamu memperbaikinya sekali.
Pemakaian di handler
func (h *ProdukHandler) Simpan(w http.ResponseWriter, r *http.Request) {
user := middleware.MustUser(r.Context())
var in BuatProdukInput
if err := BacaJSON(w, r, &in); err != nil {
KirimError(w, 400, err.Error())
return
}
if err := in.Validate(); err != nil {
TanganiError(w, err)
return
}
p, err := h.svc.Simpan(r.Context(), service.BuatProduk{
Nama: in.Nama, Harga: in.Harga, Stok: in.Stok,
}, user.ID)
if err != nil {
TanganiError(w, err)
return
}
KirimJSON(w, 201, Respons{Data: KeProdukRespons(p)})
}Endpoint /me
func (h *AuthHandler) Saya(w http.ResponseWriter, r *http.Request) {
user := middleware.MustUser(r.Context())
KirimJSON(w, 200, KeUserRespons(user))
}Cek kepemilikan data
// ❌ diperiksa SETELAH data diambil — masih bisa bocor lewat jalur lain
p, _ := repo.Cari(ctx, id)
if p.TenantID != user.TenantID {
return apperr.ErrTidakBerhak
}
// ✅ tenant jadi bagian dari query — mustahil dilewati
func (r *ProdukRepo) CariMilik(
ctx context.Context, id, tenantID int64,
) (*model.Produk, error) {
q := `SELECT ... FROM produk
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`
// data tenant lain menghasilkan ErrTidakDitemukan → 404, bukan 403.
// jangan sampai user tahu bahwa data itu ada.
}Hati-hati — Ini kerentanan yang paling sering ditemukan di API sungguhan: endpoint memeriksa apakah user login, tapi tidak memeriksa data siapa yang diminta. Dengan sekadar mengganti angka di URL, satu sekolah bisa melihat data sekolah lain. Untuk aplikasi multi-tenant, jadikan tenant_id bagian wajib dari setiap query — bukan pemeriksaan setelahnya.
Materi 25 · Modul 4
Refresh token, role, dan rate limit
Melengkapi autentikasi jadi sesi yang aman sekaligus nyaman.
Kenapa dua token
Access token berlaku singkat (15 menit) supaya kalau bocor, kerugiannya terbatas. Refresh token berlaku lama (7 hari), disimpan hash-nya di database, dan hanya dipakai untuk menukar diri dengan access token baru. Karena tersimpan, dia bisa dibatalkan — sesuatu yang tidak bisa dilakukan pada JWT biasa.
func (s *AuthService) terbitkanToken(
ctx context.Context, u *model.User,
) (*Token, error) {
access, err := s.buatAccessToken(u)
if err != nil {
return nil, err
}
refresh, err := s.buatRefreshToken(u)
if err != nil {
return nil, err
}
// simpan HASH-nya, bukan token aslinya
hash, err := util.HashPassword(refresh)
if err != nil {
return nil, err
}
if err := s.users.SimpanRefreshHash(ctx, u.ID, hash); err != nil {
return nil, err
}
return &Token{
AccessToken: access,
RefreshToken: refresh,
ExpiresIn: int64(s.expire.Seconds()),
}, nil
}
func (s *AuthService) Refresh(
ctx context.Context, refreshStr string,
) (*model.User, *Token, error) {
// periksa dengan secret refresh yang BERBEDA
claims, err := PeriksaToken(refreshStr, s.refreshSecret)
if err != nil {
return nil, nil, apperr.ErrTokenTidakValid
}
u, err := s.users.CariDenganRefresh(ctx, claims.UserID)
if err != nil || u.RefreshHash == nil {
return nil, nil, apperr.ErrTokenTidakValid
}
if !util.CekPassword(*u.RefreshHash, refreshStr) {
// token tidak cocok: kemungkinan dicuri lalu dipakai ulang.
// batalkan SEMUA sesi user ini sebagai tindakan pengamanan.
_ = s.users.HapusRefreshHash(ctx, u.ID)
return nil, nil, apperr.ErrTokenTidakValid
}
// rotasi: setiap refresh menghasilkan pasangan baru
token, err := s.terbitkanToken(ctx, u)
if err != nil {
return nil, nil, err
}
return u, token, nil
}
func (s *AuthService) Logout(ctx context.Context, userID int64) error {
return s.users.HapusRefreshHash(ctx, userID)
}Catatan — Dua hal penting. Pertama, secret untuk refresh harus berbeda dari secret access; kalau sama, access token bisa dipakai sebagai refresh token. Kedua, refresh token disimpan dalam bentuk hash — kalau suatu hari database bocor, penyerang tetap tidak bisa memakainya untuk login.
Middleware peran
func PerluPeran(diizinkan ...string) func(http.Handler) http.Handler {
set := make(map[string]struct{}, len(diizinkan))
for _, p := range diizinkan {
set[p] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := UserDari(r.Context())
if !ok {
handler.KirimError(w, 401, "Perlu login")
return
}
if _, boleh := set[u.Peran]; !boleh {
handler.KirimError(w, 403,
fmt.Sprintf("Aksi ini hanya untuk: %s", strings.Join(diizinkan, ", ")))
return
}
next.ServeHTTP(w, r)
})
}
}Hati-hati — Urutan rangkaian menentukan segalanya. PerluPeran membaca request.Context() yang diisi oleh Auth, jadi Auth harus lebih dulu. Kalau terbalik, UserDari selalu mengembalikan false dan setiap request dibalas 401 walau tokennya benar.
Rate limit
go get golang.org/x/time/ratetype pembatas struct {
mu sync.Mutex
klien map[string]*entri
rps rate.Limit
burst int
}
type entri struct {
lim *rate.Limiter
terakhir time.Time
}
func RateLimit(rps float64, burst int) func(http.Handler) http.Handler {
p := &pembatas{
klien: map[string]*entri{},
rps: rate.Limit(rps),
burst: burst,
}
// bersihkan entri lama supaya map tidak tumbuh selamanya
go p.bersihkan()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := ipKlien(r)
if !p.izinkan(ip) {
w.Header().Set("Retry-After", "60")
handler.KirimError(w, 429, "Terlalu banyak permintaan. Coba lagi nanti.")
return
}
next.ServeHTTP(w, r)
})
}
}
func (p *pembatas) izinkan(kunci string) bool {
p.mu.Lock()
defer p.mu.Unlock()
e, ada := p.klien[kunci]
if !ada {
e = &entri{lim: rate.NewLimiter(p.rps, p.burst)}
p.klien[kunci] = e
}
e.terakhir = time.Now()
return e.lim.Allow()
}
// ambil IP asli, hormati proxy
func ipKlien(r *http.Request) string {
if f := r.Header.Get("X-Forwarded-For"); f != "" {
if i := strings.Index(f, ","); i > 0 {
return strings.TrimSpace(f[:i])
}
return strings.TrimSpace(f)
}
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return ip
}// login dibatasi jauh lebih ketat daripada endpoint biasa
ketat := middleware.RateLimit(0.1, 5) // ~6 per menit, burst 5
mux.Handle("POST /api/auth/login", ketat(http.HandlerFunc(authH.Login)))Hati-hati — Dua catatan. Pertama, X-Forwarded-For bisa dipalsukan klien, jadi hanya percayai kalau aplikasimu memang di belakang proxy yang kamu kendalikan. Kedua, pembatas di memori ini per-proses: kalau kamu menjalankan beberapa instance, batasnya jadi berkali-kali lebih longgar. Untuk itu pindahkan hitungannya ke Redis.
Alternatif yang lebih aman: cookie HttpOnly
http.SetCookie(w, &http.Cookie{
Name: "refreshToken",
Value: token.RefreshToken,
Path: "/api/auth/refresh",
HttpOnly: true, // JavaScript tidak bisa membacanya
Secure: true, // hanya lewat HTTPS
SameSite: http.SameSiteStrictMode, // tidak dikirim dari situs lain
MaxAge: 7 * 24 * 60 * 60,
})Catatan — Pola yang direkomendasikan sekarang: refresh token di cookie HttpOnly, access token di memori aplikasi frontend (variabel JavaScript, bukan localStorage). Kalau situsmu kena XSS, penyerang paling banter mendapat access token yang hidup 15 menit, bukan sesi 7 hari yang bisa diperbarui terus.
Latihan — Lengkapi POST /api/auth/refresh dan POST /api/auth/logout, lalu uji alurnya: login, tunggu access token kedaluwarsa (atur JWT_EXPIRE=10s untuk pengujian), refresh, dan pastikan refresh token yang lama tidak bisa dipakai lagi setelah dirotasi.
Materi 26 · Modul 5
Testing API
Go punya httptest di pustaka standar, jadi menguji handler HTTP tidak butuh library apa pun.
Unit test service dengan repository palsu
package service
// repository palsu — cukup memenuhi interface, tanpa database
type repoPalsu struct {
produk *model.Produk
errCari error
tersimpan *model.Produk // rekam apa yang disimpan
}
func (r *repoPalsu) Cari(_ context.Context, _ int64) (*model.Produk, error) {
if r.errCari != nil {
return nil, r.errCari
}
return r.produk, nil
}
func (r *repoPalsu) Simpan(_ context.Context, p *model.Produk) error {
p.ID = 99
r.tersimpan = p
return nil
}
func (r *repoPalsu) Ubah(_ context.Context, _ *model.Produk) error { return nil }
func (r *repoPalsu) HapusLunak(_ context.Context, _ int64) error { return nil }
func (r *repoPalsu) Daftar(_ context.Context, _ Filter) ([]model.Produk, int64, error) {
return nil, 0, nil
}
func TestSimpanMembuatSlug(t *testing.T) {
repo := &repoPalsu{}
svc := NewProdukService(repo)
p, err := svc.Simpan(context.Background(), BuatProduk{
Nama: "Kaos Onlenkan Edisi Terbatas", Harga: 85000,
}, 1)
if err != nil {
t.Fatalf("tidak mengharap error: %v", err)
}
if p.Slug != "kaos-onlenkan-edisi-terbatas" {
t.Errorf("slug = %q, mau %q", p.Slug, "kaos-onlenkan-edisi-terbatas")
}
if repo.tersimpan.DibuatOleh != 1 {
t.Errorf("dibuat_oleh = %d, mau 1", repo.tersimpan.DibuatOleh)
}
}
func TestUbahMenolakBukanPemilik(t *testing.T) {
repo := &repoPalsu{
produk: &model.Produk{ID: 1, Nama: "Kaos", DibuatOleh: 5},
}
svc := NewProdukService(repo)
nama := "Diubah"
_, err := svc.Ubah(context.Background(), 1,
UbahProduk{Nama: &nama},
&model.User{ID: 9, Peran: "KASIR"}, // user lain
)
if !errors.Is(err, apperr.ErrTidakBerhak) {
t.Errorf("err = %v, mau ErrTidakBerhak", err)
}
}Catatan — Test kedua itu contoh yang paling berharga: dia menjaga aturan hak akses. Aturan seperti itu mudah hilang saat seseorang merapikan kode enam bulan kemudian, dan akibatnya user bisa mengedit data orang lain. Kalau kamu hanya menulis beberapa test, tulis yang seperti ini.
Test handler dengan httptest
func TestSimpanProduk(t *testing.T) {
h := NewProdukHandler(svcUji())
body := `{"nama":"Kaos","harga":85000,"stok":10}`
req := httptest.NewRequest(http.MethodPost, "/api/produk",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// sisipkan user seperti yang dilakukan middleware Auth
ctx := middleware.SimpanUser(req.Context(),
&model.User{ID: 1, Peran: "ADMIN"})
req = req.WithContext(ctx)
rec := httptest.NewRecorder() // ResponseWriter tiruan
h.Simpan(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, mau 201. body: %s", rec.Code, rec.Body.String())
}
var res struct {
Data ProdukRespons `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
t.Fatalf("respons bukan JSON valid: %v", err)
}
if res.Data.Nama != "Kaos" {
t.Errorf("nama = %q, mau %q", res.Data.Nama, "Kaos")
}
}Table-driven test untuk validasi
func TestValidasiProduk(t *testing.T) {
kasus := []struct {
nama string
body string
mauStatus int
mauField string // field yang harus muncul di errors
}{
{"valid", `{"nama":"Kaos","harga":85000}`, 201, ""},
{"nama kosong", `{"nama":"","harga":85000}`, 422, "nama"},
{"harga negatif", `{"nama":"Kaos","harga":-1}`, 422, "harga"},
{"JSON rusak", `{"nama":`, 400, ""},
{"field asing", `{"nama":"Kaos","harga":1,"peran":"ADMIN"}`, 400, ""},
}
for _, k := range kasus {
t.Run(k.nama, func(t *testing.T) {
rec := panggilSimpan(t, k.body)
if rec.Code != k.mauStatus {
t.Fatalf("status = %d, mau %d. body: %s",
rec.Code, k.mauStatus, rec.Body.String())
}
if k.mauField != "" {
var res struct {
Errors map[string]string `json:"errors"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &res)
if _, ada := res.Errors[k.mauField]; !ada {
t.Errorf("errors tidak memuat field %q, dapat: %v",
k.mauField, res.Errors)
}
}
})
}
}Kasus terakhir — "field asing" — menguji bahwa DisallowUnknownFields berfungsi. Itu pertahanan terhadap user yang mencoba menyisipkan "peran":"ADMIN" ke endpoint yang tidak seharusnya menerimanya.
Test integrasi dengan database sungguhan
// TestMain berjalan sekali untuk seluruh package
var testPool *pgxpool.Pool
func TestMain(m *testing.M) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
fmt.Println("TEST_DATABASE_URL tidak diset, lewati test repository")
os.Exit(0)
}
pool, err := pgxpool.New(context.Background(), url)
if err != nil {
log.Fatal(err)
}
testPool = pool
kode := m.Run()
pool.Close()
os.Exit(kode)
}
func TestSimpanDanCari(t *testing.T) {
repo := NewProdukRepo(testPool)
ctx := context.Background()
// bersihkan otomatis setelah test, apa pun hasilnya
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM produk WHERE nama LIKE 'UJI-%'`)
})
p := &model.Produk{
Nama: "UJI-Kaos", Slug: "uji-kaos",
Harga: 85000, Stok: 5, Aktif: true, DibuatOleh: 1,
}
if err := repo.Simpan(ctx, p); err != nil {
t.Fatalf("simpan: %v", err)
}
if p.ID == 0 {
t.Fatal("ID tidak terisi dari RETURNING")
}
hasil, err := repo.Cari(ctx, p.ID)
if err != nil {
t.Fatalf("cari: %v", err)
}
if hasil.Harga != 85000 {
t.Errorf("harga = %d, mau 85000", hasil.Harga)
}
}Hati-hati — Test yang menyentuh database wajib memakai database terpisah lewat TEST_DATABASE_URL. Test yang menghapus tabel akan menghancurkan data kerjamu kalau tertuju ke database development. Pola os.Exit(0) saat variabel itu kosong membuat test aman dijalankan siapa pun tanpa setup.
Yang layak diuji lebih dulu
- Perhitungan uang — total, diskon, pembulatan. Salah di sini berarti kerugian nyata.
- Hak akses — bukan pemilik ditolak, tenant lain dapat 404, peran salah dapat 403.
- Transaksi — pesanan yang gagal di tengah tidak meninggalkan stok yang sudah terpotong.
- Kode concurrent — selalu dengan
-race. - Bug yang pernah terjadi — tulis test yang gagal sebelum kamu memperbaikinya. Cara termurah menjaga bug tidak kembali.
Materi 27 · Modul 5
Logging terstruktur dengan slog
Sejak Go 1.21 ada logger terstruktur bawaan, jadi tidak perlu library lagi.
Setup
package logger
import (
"log/slog"
"os"
)
func Siapkan(env string) *slog.Logger {
var h slog.Handler
opts := &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: env != "production", // file:baris saat development
}
if env == "production" {
// JSON: bisa dicari dan difilter oleh Loki, CloudWatch, dll
h = slog.NewJSONHandler(os.Stdout, opts)
} else {
opts.Level = slog.LevelDebug
h = slog.NewTextHandler(os.Stdout, opts)
}
l := slog.New(h)
slog.SetDefault(l) // supaya slog.Info() global ikut terpakai
return l
}Cara memakainya
// pasangan kunci-nilai, bukan string yang digabung
slog.Info("produk dibuat", "produk_id", p.ID, "user_id", userID)
slog.Warn("stok menipis", "produk_id", p.ID, "stok", p.Stok)
slog.Error("gagal simpan pesanan", "err", err, "pesanan_id", id)
// hasilnya di produksi (JSON):
// {"time":"...","level":"INFO","msg":"produk dibuat","produk_id":7,"user_id":1}
// logger dengan konteks tetap — hemat pengulangan
log := slog.With("modul", "pesanan", "tenant_id", tenantID)
log.Info("mulai proses")
log.Error("gagal", "err", err) // modul & tenant_id otomatis ikutCatatan — Kenapa terstruktur, bukan fmt.Sprintf? Karena log JSON bisa ditanyai: "tampilkan semua error pada endpoint pesanan untuk tenant 12 dalam 1 jam terakhir". Log berupa teks bebas hanya bisa di-grep, dan itu tidak cukup begitu volumenya bertambah.
Request ID untuk menelusuri satu permintaan
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
w.Header().Set("X-Request-ID", id)
// logger yang sudah membawa request_id, disimpan di context
log := slog.With("request_id", id)
ctx := SimpanLogger(r.Context(), log)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// di service atau repository mana pun
func (s *PesananService) Buat(ctx context.Context, ...) error {
log := middleware.LoggerDari(ctx)
log.Info("memproses pesanan", "item", len(items))
// setiap log dari request ini punya request_id yang sama
}Saat user melaporkan "pesanan saya gagal tadi jam 2", kamu minta request ID dari respons error yang dia terima, lalu mencari satu ID itu — dan seluruh jejak request-nya muncul berurutan.
Sensor data sensitif
// implementasi LogValuer menentukan bagaimana tipe ini muncul di log
func (u User) LogValue() slog.Value {
return slog.GroupValue(
slog.Int64("id", u.ID),
slog.String("peran", u.Peran),
// email, hash, refresh token sengaja TIDAK disertakan
)
}
// sekarang aman:
slog.Info("login berhasil", "user", u)
// {"msg":"login berhasil","user":{"id":1,"peran":"ADMIN"}}Hati-hati — Log yang memuat password, token, atau nomor kartu adalah kebocoran yang sungguhan: file log biasanya tidak dienkripsi, ikut ter-backup, dan kadang dikirim ke layanan pihak ketiga. Pasang LogValue() pada tipe yang membawa data sensitif sejak awal, jangan menunggu audit.
Health check dan metrik
func Health(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, batal := context.WithTimeout(r.Context(), 2*time.Second)
defer batal()
if err := pool.Ping(ctx); err != nil {
KirimJSON(w, 503, map[string]string{
"status": "gagal", "database": "tidak tersambung",
})
return
}
stat := pool.Stat()
KirimJSON(w, 200, map[string]any{
"status": "ok",
"goroutine": runtime.NumGoroutine(),
"db_terpakai": stat.AcquiredConns(),
"db_menganggur": stat.IdleConns(),
})
}
}Catatan — Angka goroutine di health check itu alat diagnosis yang murah. Kalau jumlahnya terus naik dan tidak pernah turun, kamu punya kebocoran goroutine — biasanya goroutine yang menunggu channel yang tidak pernah diisi, atau yang tidak menghormati pembatalan context.
pprof: profiling bawaan
// hanya di development atau di port internal yang tidak publik!
if cfg.Env != "production" {
import _ "net/http/pprof"
go func() {
slog.Info("pprof di :6060")
http.ListenAndServe("localhost:6060", nil)
}()
}go tool pprof http://localhost:6060/debug/pprof/heap # memori
go tool pprof http://localhost:6060/debug/pprof/profile # CPU 30 detik
curl http://localhost:6060/debug/pprof/goroutine?debug=2 # semua goroutineHati-hati — Jangan pernah membuka pprof ke internet. Endpoint itu membocorkan struktur internal aplikasi dan bisa dipakai untuk membuat servermu kehabisan sumber daya. Ikat ke localhost seperti di atas, dan akses lewat SSH tunnel kalau perlu memeriksa produksi.
Materi 28 · Modul 5
Pekerjaan latar dan penjadwalan
Di sini keunggulan Go paling terasa: worker dan scheduler tidak butuh Redis atau proses terpisah.
Jangan langsung tembak goroutine di handler
// ❌ tiga masalah sekaligus
func (h *PesananHandler) Buat(w http.ResponseWriter, r *http.Request) {
// ... simpan pesanan ...
go h.wa.Kirim(r.Context(), nomor, pesan)
// 1. context request dibatalkan begitu respons terkirim → kirim gagal
// 2. panic di dalamnya mematikan SELURUH proses
// 3. mati saat server restart, tanpa jejak dan tanpa percobaan ulang
}Worker pool sebagai bagian aplikasi
package worker
type Tugas struct {
Jenis string
Data map[string]any
Coba int
}
type Pool struct {
tugas chan Tugas
wg sync.WaitGroup
wa Pengirim
pekerja int
}
func NewPool(pekerja, buffer int, wa Pengirim) *Pool {
return &Pool{
tugas: make(chan Tugas, buffer),
wa: wa,
pekerja: pekerja,
}
}
func (p *Pool) Mulai(ctx context.Context) {
for i := 0; i < p.pekerja; i++ {
p.wg.Add(1)
go p.jalan(ctx, i)
}
slog.Info("worker pool mulai", "pekerja", p.pekerja)
}
func (p *Pool) jalan(ctx context.Context, id int) {
defer p.wg.Done()
for t := range p.tugas {
p.kerjakanAman(ctx, t, id)
}
}
// setiap pekerja punya recover sendiri — satu tugas gagal tidak menjatuhkan pool
func (p *Pool) kerjakanAman(ctx context.Context, t Tugas, id int) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic di worker",
"worker", id, "jenis", t.Jenis,
"panic", rec, "stack", string(debug.Stack()))
}
}()
// context terpisah dari request — punya batas waktunya sendiri
tCtx, batal := context.WithTimeout(ctx, 30*time.Second)
defer batal()
err := p.kerjakan(tCtx, t)
if err == nil {
return
}
// coba ulang dengan jeda yang membesar
if t.Coba < 3 {
t.Coba++
jeda := time.Duration(1<<t.Coba) * time.Second // 2s, 4s, 8s
go func() {
select {
case <-time.After(jeda):
p.Kirim(t)
case <-ctx.Done():
}
}()
return
}
slog.Error("tugas gagal permanen", "jenis", t.Jenis, "err", err)
}
func (p *Pool) Kirim(t Tugas) {
select {
case p.tugas <- t:
default:
// buffer penuh — jangan blokir handler, catat saja
slog.Warn("antrean penuh, tugas dibuang", "jenis", t.Jenis)
}
}
func (p *Pool) Tutup() {
close(p.tugas)
p.wg.Wait() // tunggu tugas yang sedang jalan selesai
slog.Info("worker pool berhenti")
}Catatan — Perhatikan select dengan default di Kirim. Tanpa itu, handler HTTP akan ikut menunggu ketika buffer antrean penuh — artinya masalah di worker menular jadi API yang lambat. Dengan default, handler selalu selesai cepat.
Pakai di handler dan main
// handler — cepat, tidak menunggu
func (h *PesananHandler) Buat(w http.ResponseWriter, r *http.Request) {
p, err := h.svc.Buat(r.Context(), in)
if err != nil {
TanganiError(w, err)
return
}
h.pool.Kirim(worker.Tugas{
Jenis: "wa-pesanan-baru",
Data: map[string]any{"pesanan_id": p.ID, "nomor": in.NomorWA},
})
KirimJSON(w, 201, Respons{Data: p})
}
// main.go
pool := worker.NewPool(5, 1000, waClient)
pool.Mulai(ctx)
defer pool.Tutup() // tunggu worker saat shutdownPenjadwalan (cron) tanpa dependency
func JalankanBerkala(
ctx context.Context, nama string, tiap time.Duration,
f func(context.Context) error,
) {
t := time.NewTicker(tiap)
defer t.Stop()
for {
select {
case <-ctx.Done():
slog.Info("tugas berkala berhenti", "nama", nama)
return
case <-t.C:
mulai := time.Now()
if err := f(ctx); err != nil {
slog.Error("tugas berkala gagal", "nama", nama, "err", err)
continue
}
slog.Info("tugas berkala selesai",
"nama", nama, "durasi_ms", time.Since(mulai).Milliseconds())
}
}
}
// main.go
go worker.JalankanBerkala(ctx, "bersihkan-token", time.Hour, authSvc.BersihkanToken)
go worker.JalankanBerkala(ctx, "cek-stok", 6*time.Hour, produkSvc.PeringatanStok)Untuk jadwal berbasis jam, pakai robfig/cron
go get github.com/robfig/cron/v3
zona, _ := time.LoadLocation("Asia/Jakarta")
c := cron.New(cron.WithLocation(zona))
// setiap hari jam 8 pagi WIB
c.AddFunc("0 8 * * *", func() {
if err := tagihanSvc.KirimPengingat(ctx); err != nil {
slog.Error("pengingat tagihan gagal", "err", err)
}
})
c.Start()
defer c.Stop()Hati-hati — Dua jebakan penjadwalan. Pertama, zona waktu: server biasanya UTC, jadi "0 8 * * *" tanpa WithLocation berjalan jam 3 siang WIB. Kedua, kalau kamu menjalankan beberapa instance aplikasi, setiap instance menjalankan cron-nya sendiri sehingga pengingat terkirim berkali-kali. Solusinya: jalankan scheduler di satu proses khusus, atau pakai kunci di database (SELECT ... FOR UPDATE SKIP LOCKED) supaya hanya satu instance yang mengambil pekerjaan.
Kalau butuh antrean yang tahan restart
Worker pool di memori hilang saat proses mati. Untuk pekerjaan yang tidak boleh hilang — pengiriman uang, notifikasi pembayaran — simpan tugasnya di tabel database dan ambil dengan FOR UPDATE SKIP LOCKED, atau pakai hibiken/asynq yang berbasis Redis dan mirip BullMQ di Node.
-- ambil satu tugas, kunci barisnya, lewati yang sedang dipegang worker lain
UPDATE tugas SET status = 'JALAN', diambil_pada = now()
WHERE id = (
SELECT id FROM tugas
WHERE status = 'MENUNGGU' AND jalankan_pada <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;Materi 29 · Modul 5
Upload file
Menerima gambar produk, dengan pemeriksaan yang benar-benar mengamankan.
Handler upload
func (h *ProdukHandler) UnggahGambar(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
KirimError(w, 400, "ID produk tidak valid")
return
}
// batasi ukuran SEBELUM membaca apa pun
r.Body = http.MaxBytesReader(w, r.Body, h.maksUpload)
if err := r.ParseMultipartForm(h.maksUpload); err != nil {
KirimError(w, 400, "File terlalu besar atau format form tidak valid")
return
}
file, hdr, err := r.FormFile("gambar")
if err != nil {
KirimError(w, 400, "File gambar wajib dikirim")
return
}
defer file.Close()
// baca 512 byte pertama untuk memeriksa ISI file, bukan sekadar namanya
kepala := make([]byte, 512)
n, _ := file.Read(kepala)
tipe := http.DetectContentType(kepala[:n])
if !tipeGambarBoleh(tipe) {
KirimError(w, 422, "File harus berupa gambar JPG, PNG, atau WebP")
return
}
// kembali ke awal file sebelum menyalin
if _, err := file.Seek(0, io.SeekStart); err != nil {
KirimError(w, 500, "Gagal memproses file")
return
}
url, err := h.upload.SimpanGambar(r.Context(), file, hdr, tipe)
if err != nil {
TanganiError(w, err)
return
}
p, err := h.svc.SetGambar(r.Context(), id, url)
if err != nil {
TanganiError(w, err)
return
}
KirimJSON(w, 200, Respons{Data: KeProdukRespons(p)})
}
func tipeGambarBoleh(t string) bool {
switch t {
case "image/jpeg", "image/png", "image/webp":
return true
}
return false
}Hati-hati — Tiga aturan upload yang tidak boleh dilewat. Satu: periksa isi file dengan DetectContentType, jangan percaya hdr.Header.Get("Content-Type") yang dikirim klien — itu bisa dipalsukan. Dua: jangan pernah memakai nama file asli dari user; nama seperti ../../etc/passwd atau skrip.php adalah serangan nyata. Tiga: simpan upload di direktori yang tidak bisa mengeksekusi apa pun, dan sajikan lewat endpoint atau CDN.
Menyimpan dengan nama acak
type Lokal struct {
dir string
}
var ekstensi = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
func (l *Lokal) SimpanGambar(
ctx context.Context, src io.Reader, hdr *multipart.FileHeader, tipe string,
) (string, error) {
// nama dari server, ekstensi dari tipe yang terdeteksi
nama := uuid.NewString() + ekstensi[tipe]
// bagi ke subfolder tanggal — satu folder dengan 100.000 file itu lambat
sub := time.Now().Format("2006/01")
folder := filepath.Join(l.dir, "produk", sub)
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("buat folder upload: %w", err)
}
tujuan := filepath.Join(folder, nama)
f, err := os.OpenFile(tujuan, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return "", fmt.Errorf("buat file: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, src); err != nil {
os.Remove(tujuan) // jangan tinggalkan file setengah
return "", fmt.Errorf("salin file: %w", err)
}
return "/uploads/produk/" + sub + "/" + nama, nil
}Catatan — io.Copy menyalin secara bertahap lewat buffer kecil, jadi file 50 MB tidak pernah seluruhnya masuk memori. Ini contoh kekuatan antarmuka io.Reader dan io.Writer: fungsi yang sama bekerja untuk file, jaringan, dan buffer memori.
Mengubah ukuran gambar
go get github.com/disintegration/imaging
func (l *Lokal) ProsesGambar(src io.Reader, dasar string) (string, string, error) {
img, err := imaging.Decode(src, imaging.AutoOrientation(true))
if err != nil {
return "", "", fmt.Errorf("decode gambar: %w", err)
}
besar := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
kecil := imaging.Fill(img, 300, 300, imaging.Center, imaging.Lanczos)
if err := imaging.Save(besar, dasar+".jpg",
imaging.JPEGQuality(82)); err != nil {
return "", "", err
}
if err := imaging.Save(kecil, dasar+"-thumb.jpg",
imaging.JPEGQuality(75)); err != nil {
return "", "", err
}
return dasar + ".jpg", dasar + "-thumb.jpg", nil
}Hati-hati — Memproses gambar itu pekerjaan CPU berat. Di Go ini tidak memblokir request lain seperti di Node, tapi tetap memakan core. Kalau ada banyak upload sekaligus, lempar pemrosesannya ke worker pool (materi 28) dengan jumlah pekerja terbatas — kalau tidak, 20 upload bersamaan bisa menghabiskan seluruh CPU dan membuat semua endpoint lain melambat.
Menyajikan file dengan aman
// http.Dir sudah mencegah path traversal (../)
fs := http.FileServer(http.Dir("./uploads"))
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/",
cacheLama(fs)))
func cacheLama(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// nama file acak & tidak berubah → aman di-cache lama
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}Untuk produksi, lebih baik file statis disajikan Nginx atau CDN — bukan aplikasi Go. Selain lebih cepat, itu membebaskan servermu dari pekerjaan yang tidak perlu.
Object storage
// Cloudflare R2 / S3 — API-nya sama
go get github.com/aws/aws-sdk-go-v2/service/s3
func (s *R2) Unggah(
ctx context.Context, kunci string, isi io.Reader, tipe string,
) (string, error) {
_, err := s.klien.PutObject(ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Key: &kunci,
Body: isi,
ContentType: &tipe,
CacheControl: aws.String("public, max-age=31536000, immutable"),
})
if err != nil {
return "", fmt.Errorf("unggah ke R2: %w", err)
}
return s.publicURL + "/" + kunci, nil
}Catatan — Menyimpan upload di disk server punya masalah saat deploy: file hilang kalau kamu mengganti container, dan tidak terbagi kalau ada beberapa instance. Untuk produksi sungguhan, object storage hampir selalu pilihan yang benar — dan karena antarmuka Penyimpanan kamu berupa interface, menggantinya cuma mengubah satu baris di main.go.
Materi 30 · Modul 5
Build dan deploy
Bagian yang paling menyenangkan di Go. Satu file binary, tidak ada runtime.
Build
# build biasa
go build -o bin/api ./cmd/api
# untuk produksi: buang info debug, ukuran jauh lebih kecil
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/api ./cmd/api
# cross-compile dari laptop ke server Linux
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -ldflags="-s -w" -o bin/api-linux ./cmd/api
# sisipkan versi ke dalam binary
go build -ldflags="-s -w -X main.versi=$(git rev-parse --short HEAD)" \
-o bin/api ./cmd/apiCatatan — CGO_ENABLED=0 menghasilkan binary yang benar-benar mandiri, tanpa ketergantungan pada pustaka sistem. Efeknya: binary yang kamu build di Ubuntu bisa jalan di Alpine, dan image Docker-nya bisa memakai scratch yang isinya nol byte selain binary-mu. Ini yang membuat deploy Go terasa ringan dibanding PHP atau Node.
Makefile
.PHONY: dev build test lint migrate-up
dev:
go run ./cmd/api
build:
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/api ./cmd/api
test:
go test -race -cover ./...
lint:
go vet ./...
golangci-lint run
migrate-up:
migrate -path migrations -database "$(DB_URL)" up
deploy: test build
scp bin/api server:/opt/toko-api/api.baru
ssh server 'systemctl stop toko-api \
&& mv /opt/toko-api/api.baru /opt/toko-api/api \
&& systemctl start toko-api'Perhatikan target deploy: seluruh proses deploy adalah menyalin satu file dan restart service. Tidak ada composer install, tidak ada npm ci, tidak ada build di server.
systemd di VPS
[Unit]
Description=Toko API
After=network.target postgresql.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/toko-api
ExecStart=/opt/toko-api/api
EnvironmentFile=/opt/toko-api/.env
Restart=always
RestartSec=5
# beri waktu graceful shutdown menyelesaikan request
KillSignal=SIGTERM
TimeoutStopSec=30
# pengamanan dasar
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/toko-api/uploads
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now toko-api
sudo systemctl status toko-api
sudo journalctl -u toko-api -f # lihat logCatatan — KillSignal=SIGTERM dan TimeoutStopSec=30 bekerja bersama graceful shutdown dari materi 17. Hasilnya: systemctl restart tidak memutus satu pun request yang sedang berjalan. Tanpa itu, setiap deploy membuang beberapa request user.
Docker
# ── tahap build ──
FROM golang:1.23-alpine AS builder
WORKDIR /src
# cache dependency terpisah dari kode
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /api ./cmd/api
# ── tahap jalan ──
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
adduser -D -u 10001 app
COPY --from=builder /api /api
USER app
EXPOSE 8080
ENTRYPOINT ["/api"]Image akhirnya sekitar 15–20 MB. Bandingkan dengan image PHP atau Node yang biasanya 200 MB ke atas. Kalau kamu tidak butuh ca-certificates dan zona waktu, bahkan bisa pakai FROM scratch dan ukurannya jadi sebesar binary saja.
Hati-hati — Kalau memakai scratch atau alpine tanpa tzdata, time.LoadLocation("Asia/Jakarta") akan gagal dan cron-mu berjalan di UTC. Dan tanpa ca-certificates, setiap panggilan HTTPS ke API luar gagal dengan error sertifikat. Dua paket kecil itu hampir selalu perlu.
services:
api:
build: .
ports: ["8080:8080"]
env_file: .env
depends_on:
db: { condition: service_healthy }
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: rahasia
POSTGRES_DB: toko
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
volumes:
pgdata:Nginx di depan
server {
listen 443 ssl http2;
server_name api.domainmu.com;
ssl_certificate /etc/letsencrypt/live/api.domainmu.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.domainmu.com/privkey.pem;
client_max_body_size 5M;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# file upload disajikan nginx, bukan aplikasi Go
location /uploads/ {
alias /opt/toko-api/uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
}
}CI/CD dengan GitHub Actions
name: Deploy API
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- run: go vet ./...
- run: go test -race ./...
- name: Build
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o api ./cmd/api
- name: Kirim ke server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
source: api
target: /opt/toko-api/
- name: Restart
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /opt/toko-api
./api migrate-up || true
sudo systemctl restart toko-apiChecklist sebelum go-live
- Semua timeout di
http.Serverterpasang, bukanListenAndServeyang ringkas. - Graceful shutdown berfungsi — uji dengan
systemctl restartsaat ada request berjalan. - Konfigurasi divalidasi saat menyala; aplikasi menolak jalan kalau secret kurang.
- Secret produksi berbeda dari development, dan tidak pernah masuk Git.
- Migration sudah dijalankan, dan ada backup database otomatis yang restore-nya pernah dicoba.
- CORS hanya mengizinkan domain frontend yang benar.
- Rate limit aktif di endpoint login, dan IP klien terbaca benar di belakang proxy.
- pprof tidak terbuka ke internet.
- Upload tidak disimpan di folder yang tergantikan saat deploy.
- Log terkumpul dan bisa dicari; error 500 terpantau.
- Health check dipantau dengan notifikasi kalau mati.
go test -race ./...lulus di CI.
Materi 31 · Modul 6
Kapan Go, kapan framework lain
Materi terpenting di handbook ini. Bukan soal bahasa mana yang lebih baik, tapi kapan masing-masing menang.
Perbandingan yang jujur
Dua nama yang muncul di tabel berikut — Laravel (framework PHP) dan NestJS (framework Node.js dengan TypeScript) — adalah dua contoh framework backend yang membawa banyak alat siap pakai: ORM, sistem autentikasi, admin panel, dan sejenisnya. Go sengaja tidak membawa semua itu. Tabel ini membandingkan ketiganya dari segi karakteristik, bukan mengasumsikan kamu sudah memakai salah satunya.
| Framework kaya fitur (Laravel) | Framework terstruktur (NestJS) | Go | |
|---|---|---|---|
| Kecepatan bikin CRUD | Tercepat | Cepat | Paling lambat |
| Admin panel instan | Ada (mis. Filament) | Tidak ada | Tidak ada |
| ORM | Sangat kaya (mis. Eloquent) | Baik (mis. Prisma) | SQL manual |
| Banyak koneksi menganggur | Lemah | Baik | Terbaik |
| Kerja berat di CPU | Lemah | Lemah | Terbaik |
| Pemakaian memori | Tinggi | Sedang | Sangat rendah |
| Kerumitan deploy | Sedang | Sedang | Paling sederhana |
| Bisa shared hosting | Ya | Tidak | Tidak |
| Jumlah baris kode | Paling sedikit | Sedang | Paling banyak |
Pilih Go kalau
- Banyak koneksi hidup sekaligus. WebSocket untuk dashboard realtime, ratusan device IoT yang mengirim data, long polling. Ribuan goroutine itu wajar; di server tradisional (seperti PHP klasik) setiap koneksi berarti satu proses terpisah.
- Pekerjaan yang membebani CPU. Pengolahan gambar massal, generate PDF ribuan lembar, perhitungan laporan besar. Go memakai semua core dengan baik.
- Service kecil yang harus hemat. Gateway WhatsApp, penerima webhook, proxy. Satu binary 15 MB dengan memori 20 MB — beberapa service bisa hidup nyaman di VPS 1 GB.
- Deploy harus sesederhana mungkin. Satu file, tidak ada runtime, tidak ada dependency di server. Cocok untuk perangkat yang ditempatkan di lokasi klien.
- Kode yang harus stabil bertahun-tahun. Jaminan kompatibilitas Go sangat kuat: kode dari 2015 masih compile hari ini tanpa diubah.
Jangan pilih Go kalau
- Yang dibutuhkan admin panel CRUD. Framework dengan admin panel bawaan bisa menyelesaikan dalam sehari apa yang di Go butuh dua minggu — dan hasilnya lebih lengkap.
- Fitur masih sering berubah. Kode Go lebih panjang, jadi perubahan besar lebih mahal. Untuk produk yang masih mencari bentuk, framework yang serba tersedia jauh lebih lincah.
- Hostingnya terbatas (shared hosting). Ini penghalang praktis, bukan teknis. Go butuh VPS atau server yang bisa menjalankan proses sendiri.
- Kamu baru mulai dan waktunya terbatas. Menguasai satu bahasa dengan dalam biasanya lebih menghasilkan daripada menguasai beberapa secara dangkal sekaligus.
Perlu dicatat soal performa — Untuk API CRUD yang kerjanya menunggu database, selisih Go dan Node tidak sebesar yang diiklankan benchmark. Bottleneck-nya query, bukan bahasanya. Go menang jelas di dua hal spesifik: jumlah koneksi bersamaan, dan kerja yang membebani CPU. Kalau aplikasimu tidak menyentuh keduanya, "Go lebih cepat" bukan alasan yang cukup untuk berpindah.
Contoh skenario nyata
Kombinasi yang sering dipakai di lapangan, dan alasannya:
| Bagian aplikasi | Yang sering dipakai |
|---|---|
| SaaS multi-tenant, billing, CRUD | Framework kaya fitur seperti Laravel — ORM dan admin panelnya sangat menghemat waktu |
| Frontend pelanggan | React / Next.js |
| API untuk aplikasi React | Laravel atau NestJS, tergantung tim dan kebutuhan |
| Gateway WhatsApp dengan antrean | Go — banyak koneksi, harus hemat, jarang berubah |
| Penerima data IoT / ESP32 | Go — ratusan koneksi bersamaan |
| Realtime presensi (WebSocket) | Go — ini yang paling sulit dilakukan server tradisional |
| Worker pengolah gambar / PDF | Go — kerja CPU |
Pola yang muncul: Go untuk service pendukung yang spesifik, stabil, dan berat di koneksi atau CPU. Framework kaya fitur tetap unggul untuk produk yang fiturnya terus tumbuh.
Ongkos yang jarang dihitung
Menambah bahasa kedua bukan cuma soal belajar sintaks. Yang ikut bertambah:
- Dua pipeline deploy dan dua cara memantau.
- Dua tempat mencari log saat ada masalah jam 11 malam.
- Dua set dependency yang harus diperbarui keamanannya.
- Satu lagi hal yang harus diajarkan kalau nanti ada orang lain ikut mengerjakan.
Untuk tim kecil, itu bukan biaya sepele. Karena itu saran yang masuk akal: ambil Go saat ada satu kebutuhan konkret yang terasa dipaksakan kalau dikerjakan dengan framework lain — bukan karena penasaran. Saat momen itu datang, dasar Go bisa dikuasai dalam seminggu, dan alasannya akan jelas dengan sendirinya.
Menyambungkan Go dengan React
Karena bentuk respons di handbook ini sengaja disamakan, aplikasi React dari volume pertama bisa langsung menunjuk ke API Go ini:
VITE_API_URL=http://localhost:8080/api| React (volume 1) | Go (volume ini) |
|---|---|
| Halaman login | POST /api/auth/login, materi 23 |
AuthContext cek /me | GET /api/auth/me, materi 24 |
| Daftar + cari + paginasi | GET /api/produk, materi 21 |
| Form tambah/ubah, error 422 | POST/PATCH, materi 15 & 20 |
| Upload gambar | POST /api/produk/{id}/gambar, materi 29 |
| Interceptor refresh otomatis | POST /api/auth/refresh, materi 25 |
Jadi tiga handbook ini sebenarnya satu aplikasi yang sama, dengan tiga backend yang bisa ditukar. Kalau kamu membangun ketiganya, kamu akan punya perbandingan langsung dari pengalaman sendiri — dan itu dasar keputusan yang jauh lebih baik daripada tabel mana pun, termasuk tabel di atas.
Materi 32 · Modul 6
Roadmap dan checklist
Arah lanjutan setelah 31 materi, dan cara mengukur diri.
Langkah berikutnya
- Kuasai pustaka standar lebih dalam. Ini yang paling berdampak di Go.
io,context,encoding/json,net/http,sync,time. Banyak masalah yang di bahasa lain butuh library, di Go sudah ada jawabannya di pustaka standar. - sqlc. Kalau kamu serius memakai Go untuk API berdatabase, ini peningkatan terbesar dari pgx murni: SQL tetap terlihat, tapi salah nama kolom ketangkap saat generate.
- Pola concurrency.
errgroup, fan-in/fan-out, pipeline, semaphore berbatas. Baca "Go Concurrency Patterns" dan tulis ulang contohnya sendiri. - WebSocket. Pakai
coder/websocket. Inilah kekuatan Go yang paling jelas, dan langsung relevan untuk dashboard realtime. - Optimasi berdasar ukuran, bukan dugaan. Benchmark, pprof, dan mengurangi alokasi memori. Di Go,
allocs/opbiasanya lebih menentukan daripada jumlah operasi. - gRPC kalau nanti ada beberapa service internal yang saling memanggil. Untuk API publik, REST tetap pilihan yang tepat.
Sumber belajar yang layak
- Tour of Go dan Effective Go — dokumentasi resmi, dan memang bagus.
- Go by Example — cari pola cepat saat lupa sintaks.
- Kode pustaka standar — ini saran yang serius. Baca sumber
net/http. Kode Go paling baik yang bisa kamu temukan ada di situ, dan mudah dibaca. - Uber Go Style Guide — kumpulan keputusan praktis yang sudah teruji di skala besar.
Checklist penguasaan
Tandai yang sudah bisa kamu kerjakan tanpa melihat contoh.
Dasar — Jelaskan zero value dan kapan harus pakai pointer · bedakan value dan pointer receiver · jelaskan kenapa slice bisa berbagi memori dengan asalnya · tulis fungsi yang mengembalikan (hasil, error) dan membungkus error dengan %w · bikin interface kecil dan implementasi palsunya untuk test.
Concurrency — Tulis WaitGroup dengan benar (Add sebelum go, Done di defer) · bikin worker pool dengan channel · pakai select dengan ctx.Done() · jelaskan apa itu data race dan temukan satu dengan -race · jelaskan kenapa panic di goroutine bisa mematikan seluruh proses.
API — Bikin server dengan timeout lengkap dan graceful shutdown · routing dengan method dan path param · tulis middleware logging dan recover dari nol · susun handler/service/repository dengan interface di tempat yang memakainya · terjemahkan error domain jadi status HTTP di satu tempat terpusat.
Data & auth — Tulis query dengan parameter dan defer rows.Close() · bangun query dinamis dengan daftar putih untuk kolom pengurutan · jelaskan kenapa WHERE stok >= $2 lebih aman daripada baca-lalu-tulis · implementasi JWT dengan pemeriksaan algoritma · rotasi refresh token dan batalkan sesi saat token dipakai ulang.
Produksi — Validasi konfigurasi saat menyala · logging terstruktur dengan request ID · worker pool dengan recover dan retry · upload file yang memeriksa isi, bukan nama · build binary statis dan deploy lewat systemd tanpa memutus request · jalankan go test -race di CI.
Kalau kamu juga mengajar ini
Urutan modulnya bisa langsung dipakai sebagai silabus, tapi Go punya beberapa titik yang perlu waktu ekstra dibanding bahasa lain:
- Materi 4–5 (error sebagai nilai, pointer dan receiver). Murid yang datang dari PHP atau JavaScript butuh waktu untuk berhenti mencari
try/catchdan mulai nyaman denganif err != nil. - Materi 6 (jebakan slice). Bagian "slice berbagi memori" hampir selalu perlu didemonstrasikan langsung, bukan dijelaskan.
- Materi 9–10 (goroutine dan context). Paling menarik, dan paling mudah salah. Sediakan satu sesi khusus, dan pakai
-racedi depan kelas untuk menunjukkan data race benar-benar terjadi. - Materi 7 (interface implisit). Ide "tidak perlu menulis implements" biasanya baru benar-benar masuk setelah mereka membuat implementasi palsu untuk test sendiri.
Satu saran: mulai dari materi 31, bukan terakhir. Murid yang paham dulu kapan Go dipakai akan belajar dengan pertanyaan yang lebih tajam, dan tidak menganggap Go pengganti Laravel untuk semua hal.
Satu saran terakhir
Go lebih mudah dipelajari daripada dikuasai, dan mudahnya bukan karena dangkal — tapi karena bahasanya kecil. Dua minggu cukup untuk membaca kode Go orang lain dengan nyaman. Yang butuh waktu lebih lama adalah kebiasaannya: interface kecil, error yang dibungkus dengan konteks, tidak berlebihan memakai goroutine, dan menahan diri dari abstraksi yang belum perlu.
Dan cara paling cepat sampai ke sana bukan menambah materi, tapi mengambil satu masalah yang benar-benar kamu punya — gateway WhatsApp, penerima data alat presensi, worker pengolah gambar — lalu menyelesaikannya di Go sampai dipakai sungguhan. Di situ kamu akan bertemu hal yang tidak pernah muncul di tutorial, dan di situlah pemahamannya menempel.
Selesai. Kembali ke materi mana pun kapan saja lewat sidebar.