Pola Repository Go: Model Null, Satu List, Satu Escaper
Model database dan DTO JSON tidak pernah berbagi struct: kolom nullable di-scan ke sql.NullX, respons memakai pointer, satu List melayani admin dan publik.
Ringkasan
Struct untuk database dan JSON dibuat terpisah. Satu fungsi konversi memetakan keduanya. Pola ini menghindari bug null dan query yang divergen.
Satu Struct untuk Dua Dunia yang Beda
Modul organisasi di KotaPortal butuh menyimpan kolom yang boleh kosong: nama panjang, deskripsi, kontak, alamat, masa bakti, tipe. Tebakan paling cepat adalah satu struct untuk semuanya, kolom database sekaligus bentuk JSON, dengan omitempty di mana-mana supaya respons terlihat rapi. Tebakan itu runtuh di dua titik: driver database butuh tipe null eksplisit saat Scan, dan satu bentuk JSON tidak bisa melayani kebutuhan internal sekaligus publik.
Struct model memegang baris database apa adanya, dengan tag kolom dan tipe null dari pustaka standar:
type Organization struct {
ID int64 `db:"id"`
CategoryID int64 `db:"category_id"`
Nama string `db:"nama"`
NamaPanjang sql.NullString `db:"nama_panjang"`
Deskripsi sql.NullString `db:"deskripsi"`
Kontak sql.NullString `db:"kontak"`
Alamat sql.NullString `db:"alamat"`
MasaBakti sql.NullString `db:"masa_bakti"`
SkHabis bool `db:"sk_habis"`
TipeCabor sql.NullString `db:"tipe_cabor"`
NoUrut sql.NullInt64 `db:"no_urut"`
Status string `db:"status"`
}
Kolom wajib pakai tipe Go biasa, kolom boleh-kosong pakai sql.NullString dan sql.NullInt64. Alasannya mekanis: tipe null string mengimplementasikan Scanner sehingga bisa menjadi tujuan scan [1], dan setiap kolom database yang nullable wajib di-scan ke tipe yang mendukung null [2]. Struct respons JSON hidup terpisah dengan pointer dan omitempty, yang menghapus nilai kosong saat encoding [4]:
type OrganizationResponse struct {
ID int64 `json:"id"`
Nama string `json:"nama"`
NamaPanjang *string `json:"nama_panjang,omitempty"`
Deskripsi *string `json:"deskripsi,omitempty"`
Kontak *string `json:"kontak,omitempty"`
Alamat *string `json:"alamat,omitempty"`
MasaBakti *string `json:"masa_bakti,omitempty"`
Status string `json:"status"`
Members []MemberResponse `json:"members,omitempty"`
}
Satu fungsi konversi memetakan keduanya dan mengisi pointer hanya ketika kolomnya valid. Bentuk database dan bentuk JSON tidak pernah berbagi struct, jadi perubahan skema tidak bocor ke kontrak API. Pola ini juga membuat respons konsisten: kolom yang null di database hilang dari JSON alih-alih muncul sebagai string kosong yang menyesatkan.
Satu List untuk Admin dan Publik
Kebutuhan kedua adalah daftar yang sama dengan aturan visibilitas beda: admin melihat semua status, publik hanya yang aktif. Alih-alih dua query, satu metode List membangun klausa WHERE dari sebuah flag:
switch {
case q.AllStatuses:
// Admin listing: no status filter.
case q.Status != "":
where = append(where, "o.status = ?")
args = append(args, q.Status)
default:
where = append(where, "o.status = 'AKTIF'")
}
Metode yang sama menjalankan dua query berpasangan: hitung total dengan klausa identik untuk jumlah halaman, lalu ambil baris dengan pengurutan plus batas dan offset. Kembalian kosong bukan error, melainkan slice kosong dengan total nol, sehingga halaman kosong tetap respons valid. Klausa yang dipakai dua-duanya dirakit sekali dari potongan yang sama, jadi filter hitung dan filter data tidak mungkin divergen. Pola ini menempel di atas sqlx, yang handle-nya membungkus padanan database/sql sehingga pemakaiannya terasa sama [5]. Satu cabang, tiga perilaku, tanpa duplikasi query.
Semua LIKE Pengguna Lewat Satu Pintu
Pencarian nama memakai LIKE pada dua kolom sekaligus, dan di MySQL karakter garis bawah cocok dengan tepat satu karakter sementara persen cocok dengan karakter dalam jumlah berapa pun termasuk nol [3]. Masukan pengguna yang mengandung dua karakter itu akan berperilaku sebagai wildcard dan mengembalikan baris yang tidak dimaksud. Perbaikannya adalah satu helper pusat yang meng-escape ketiganya lalu membungkus pola untuk pencocokan contains:
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
func LikePattern(q string) string {
return "%" + likeEscaper.Replace(q) + "%"
}
Setiap LIKE yang dibangun dari masukan pengguna wajib lewat helper ini. Aturannya sederhana dan bisa diaudit dengan satu pencarian teks: tidak ada pola LIKE yang dirakit di tempat lain.
Keputusan yang bertahan dari commit ini ada tiga: model dan DTO tidak pernah berbagi struct, satu List melayani dua audiens lewat flag, dan satu helper memiliki semua escaping LIKE. Masing-masing menghilangkan satu kelas bug yang diam-diam: null yang gagal saat scan, query admin dan publik yang divergen, dan wildcard yang menyelinap dari kolom pencarian.
Sources: [1] database/sql NullString [2] Go SQL wiki: dealing with NULL [3] MySQL 8.4 pattern matching (snapshot arsip 2026-09-03) [4] encoding/json omitempty [5] sqlx illustrated guide