Pola repositori CRUD generik di Go untuk lapisan data yang bersih
Pelajari pola repositori CRUD generik di Go yang praktis untuk menggunakan kembali logika list/get/create/update/delete dengan constraint yang mudah dibaca, tanpa refleksi, dan kode yang jelas.

Mengapa repositori CRUD gampang berantakan di Go
Repositori CRUD biasanya dimulai sederhana. Anda menulis GetUser, kemudian ListUsers, lalu hal yang sama untuk Orders, lalu Invoices. Beberapa entitas kemudian, lapisan data berubah menjadi tumpukan salinan-hampir-di mana perbedaan kecil mudah terlewat.
Yang paling sering berulang bukanlah SQL itu sendiri. Yang berulang adalah alur sekitarnya: menjalankan query, melakukan scan baris, menangani “tidak ditemukan”, memetakan error database, menerapkan default pagination, dan mengkonversi input ke tipe yang tepat.
Hotspot yang biasa ditemui sudah familier: kode Scan yang terduplikasi, pola context.Context dan transaksi yang berulang, boilerplate LIMIT/OFFSET (kadang dengan total counts), pemeriksaan “0 rows berarti tidak ditemukan”, dan variasi INSERT ... RETURNING id yang disalin-tempel.
Saat pengulangan mulai menyakitkan, banyak tim menggunakan refleksi. Refleksi menjanjikan CRUD “tulis sekali”: ambil struct apa pun dan isi dari kolom saat runtime. Biayanya terlihat kemudian. Kode yang berat refleksi lebih sulit dibaca, dukungan IDE menurun, dan kegagalan bergeser dari waktu kompilasi ke waktu-runtun. Perubahan kecil, seperti mengganti nama field atau menambah kolom nullable, menjadi kejutan yang hanya muncul di tes atau produksi.
Reuse yang type-safe berarti berbagi alur CRUD tanpa melepaskan kenyamanan sehari-hari Go: tanda tangan yang jelas, tipe yang diperiksa compiler, dan autocomplete yang benar-benar membantu. Dengan generik, Anda bisa menggunakan kembali operasi seperti Get[T] dan List[T] sambil tetap meminta tiap entitas menyediakan bagian yang tidak bisa ditebak, misalnya bagaimana melakukan scan baris menjadi T.
Pola ini sengaja fokus pada lapisan akses data. Ia menjaga SQL dan pemetaan konsisten dan membosankan. Ia tidak mencoba memodelkan domain Anda, menegakkan aturan bisnis, atau menggantikan logika tingkat service.
Tujuan desain (dan apa yang tidak akan diselesaikan)
Pola repositori yang baik membuat akses database sehari-hari dapat diprediksi. Anda harus bisa membaca sebuah repositori dan cepat melihat apa yang dilakukannya, SQL apa yang dijalankan, dan error apa yang mungkin dikembalikan.
Tujuannya sederhana:
- Keamanan tipe dari ujung ke ujung (ID, entitas, dan hasil bukan
any) - Constraint yang menjelaskan niat tanpa trik tipe yang rumit
- Lebih sedikit boilerplate tanpa menyembunyikan perilaku penting
- Perilaku konsisten di seluruh List/Get/Create/Update/Delete
Non-goal sama pentingnya. Ini bukan ORM. Ia tidak boleh menebak pemetaan field, auto-join tabel, atau mengubah query secara diam-diam. “Magic mapping” akan mendorong Anda kembali ke refleksi, tag, dan edge case.
Anggaplah workflow SQL yang normal: SQL eksplisit (atau query builder tipis), batas transaksi yang jelas, dan error yang bisa Anda pahami. Saat sesuatu gagal, error harus berkata “not found”, “conflict/constraint violation”, atau “DB unavailable”, bukan “repository error” yang samar.
Keputusan kunci adalah apa yang menjadi generik versus apa yang tetap per entitas.
- Generik: alurnya (jalankan query, scan, kembalikan nilai bertipe, terjemahkan error umum).
- Per entitas: maknanya (nama tabel, kolom yang dipilih, join, dan string SQL).
Berusaha memaksa semua entitas ke satu sistem filter universal biasanya membuat kode lebih sulit dibaca daripada menulis dua query yang jelas.
Memilih constraint entitas dan ID
Sebagian besar kode CRUD berulang karena setiap tabel melakukan langkah dasar yang sama, tetapi setiap entitas punya fieldnya sendiri. Dengan generik, triknya adalah berbagi bentuk kecil dan membiarkan sisanya bebas.
Mulailah dengan menentukan apa yang benar-benar harus diketahui repositori tentang sebuah entitas. Bagi banyak tim, bagian universal tunggal adalah ID. Timestamp bisa berguna, tapi tidak universal, dan memaksanya ke setiap tipe sering membuat model terasa tidak alami.
Pilih tipe ID yang bisa Anda gunakan
Tipe ID Anda harus cocok dengan cara Anda mengidentifikasi baris di database. Beberapa proyek menggunakan int64, yang lain menggunakan UUID string. Jika Anda ingin satu pendekatan yang bekerja lintas layanan, jadikan ID generik. Jika seluruh codebase Anda memakai satu tipe ID, mempertahankannya tetap bisa mempersingkat tanda tangan.
Constraint default yang baik untuk ID adalah comparable, karena Anda akan membandingkan ID, menggunakannya sebagai kunci map, dan meneruskannya.
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
Pertahankan constraint entitas seminimal mungkin
Hindari meminta field lewat embedding struct atau trik tipe-set seperti ~struct{...}. Mereka terlihat kuat, tapi mengikat tipe domain Anda ke pola repositori.
Sebaliknya, minta hanya apa yang alur CRUD bersama butuhkan:
- Mendapatkan dan menyetel ID (supaya Create bisa mengembalikannya, dan Update/Delete bisa menargetkannya)
Jika nanti Anda menambah fitur seperti soft deletes atau optimistic locking, tambahkan interface kecil opt-in (mis. GetVersion/SetVersion) dan gunakan hanya di tempat yang diperlukan. Interface kecil cenderung tahan lama.
Antarmuka repositori generik yang tetap terbaca
Antarmuka repositori harus menggambarkan apa yang dibutuhkan aplikasi Anda, bukan apa yang kebetulan dilakukan database. Jika antarmuka terasa seperti SQL, detail bocor ke mana-mana.
Jaga metode agar kecil dan dapat diprediksi. Letakkan context.Context pertama, lalu input utama (ID atau data), lalu tombol opsional yang digabung menjadi struct.
type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
Get(ctx context.Context, id ID) (T, error)
List(ctx context.Context, q ListQ) ([]T, error)
Create(ctx context.Context, in CreateIn) (T, error)
Update(ctx context.Context, id ID, in UpdateIn) (T, error)
Delete(ctx context.Context, id ID) error
}
Untuk List, hindari memaksakan tipe filter universal. Filter adalah tempat entitas sangat berbeda. Pendekatan praktis adalah tipe query per-entitas plus bentuk pagination kecil yang dapat disisipkan.
type Page struct {
Limit int
Offset int
}
Penanganan error adalah tempat repositori sering berisik. Tentukan dari awal error mana yang boleh di-branch oleh pemanggil. Sekumpulan sederhana biasanya cukup:
ErrNotFoundsaat ID tidak adaErrConflictuntuk pelanggaran unik atau konflik versiErrValidationsaat input tidak valid (hanya jika repo melakukan validasi)
Segala sesuatu lainnya bisa menjadi error bawaan yang dibungkus (DB/network). Dengan kontrak itu, kode service bisa menangani “not found” atau “conflict” tanpa peduli apakah penyimpanan adalah PostgreSQL hari ini atau sesuatu yang lain nanti.
Cara menghindari refleksi sambil tetap memakai alur ulang
Refleksi biasanya masuk ketika Anda ingin satu potong kode “mengisi struct apa pun”. Itu menyembunyikan error sampai runtime dan membuat aturannya tidak jelas.
Pendekatan yang lebih bersih adalah hanya menggunakan kembali bagian yang membosankan: mengeksekusi query, melintasi rows, memeriksa jumlah baris terpengaruh, dan membungkus error secara konsisten. Pertahankan pemetaan ke/dari struct eksplisit.
Pisahkan tanggung jawab: SQL, pemetaan, alur bersama
Pembagian praktis seperti ini:
- Per entitas: simpan string SQL dan urutan parameter di satu tempat
- Per entitas: tulis fungsi pemetaan kecil yang melakukan scan baris ke struct konkret
- Generik: sediakan alur bersama yang mengeksekusi query dan memanggil mapper
Dengan begitu, generik mengurangi pengulangan tanpa menyembunyikan apa yang dilakukan database.
Berikut abstraksi kecil yang memungkinkan Anda mengoper *sql.DB atau *sql.Tx tanpa bagian lain peduli:
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
Apa yang harus (dan tidak harus) dilakukan oleh generik
Lapisan generik tidak boleh mencoba “memahami” struct Anda. Sebaliknya, ia harus menerima fungsi eksplisit yang Anda sediakan, seperti:
- binder yang mengubah input menjadi argumen query
- scanner yang membaca kolom ke entitas
Misalnya, repositori Customer bisa menyimpan SQL sebagai konstanta (selectByID, insert, update) dan mengimplementasikan scanCustomer(rows) sekali. List generik bisa menangani loop, context, dan pembungkusan error, sementara scanCustomer menjaga pemetaan tetap type-safe dan jelas.
Jika Anda menambah kolom, Anda mengubah SQL dan scanner. Compiler akan membantu menemukan apa yang rusak.
Langkah demi langkah: mengimplementasikan pola
Tujuannya adalah satu alur yang dapat digunakan kembali untuk List/Get/Create/Update/Delete sambil menjaga tiap repositori jujur tentang SQL dan pemetaan barisnya.
1) Definisikan tipe inti
Mulailah dengan constraint paling sedikit yang Anda butuhkan. Pilih tipe ID yang cocok untuk codebase Anda dan antarmuka repositori yang dapat diprediksi.
type ID interface{ ~int64 | ~string }
type Repo[E any, K ID] interface {
Get(ctx context.Context, id K) (E, error)
List(ctx context.Context, limit, offset int) ([]E, error)
Create(ctx context.Context, e *E) error
Update(ctx context.Context, e *E) error
Delete(ctx context.Context, id K) error
}
2) Tambahkan executor untuk DB dan transaksi
Jangan mengikat kode generik langsung ke *sql.DB atau *sql.Tx. Bergantunglah pada interface executor kecil yang cocok dengan apa yang Anda panggil (QueryContext, ExecContext, QueryRowContext). Lalu service dapat meneruskan DB atau transaksi tanpa mengubah kode repositori.
3) Bangun base generik dengan alur bersama
Buat baseRepo[E,K] yang menyimpan executor dan beberapa field fungsi. Base menangani bagian membosankan: memanggil query, memetakan “not found”, memeriksa affected rows, dan mengembalikan error konsisten.
4) Implementasikan bagian khusus entitas
Setiap repositori entitas menyediakan yang tidak bisa generik:
- SQL untuk list/get/create/update/delete
- fungsi
scan(row)yang mengubah baris menjadiE - fungsi
bind(...)yang mengembalikan argumen query
5) Sambungkan repositori konkret dan gunakan dari service
Bangun NewCustomerRepo(exec Executor) *CustomerRepo yang menyematkan atau membungkus baseRepo. Lapisan service bergantung pada antarmuka Repo[E,K] dan memutuskan kapan memulai transaksi; repositori hanya menggunakan executor yang diberikan.
Menangani List/Get/Create/Update/Delete tanpa kejutan
Repositori generik hanya membantu jika setiap metode berperilaku sama di mana-mana. Sebagian besar sakit berasal dari inkonsistensi kecil: satu repo mengurutkan dengan created_at, yang lain dengan id; satu mengembalikan nil, nil untuk baris yang hilang, yang lain mengembalikan error.
List: pagination dan pengurutan yang tidak berubah-ubah
Pilih satu gaya pagination dan terapkan secara konsisten. Offset pagination (limit/offset) sederhana dan cocok untuk layar admin. Cursor pagination lebih baik untuk endless scrolling, tapi membutuhkan kunci sort yang stabil.
Apa pun yang Anda pilih, buat ordering eksplisit dan stabil. Mengurutkan berdasarkan kolom unik (sering primary key) mencegah item berpindah antar halaman saat baris baru muncul.
Get: sinyal “not found” yang jelas
Get(ctx, id) harus mengembalikan entitas bertipe dan sinyal rekaman hilang yang jelas, biasanya error sentinel bersama seperti ErrNotFound. Hindari mengembalikan entitas bernilai-nol dengan error nil. Pemanggil tidak bisa membedakan “hilang” dari “field kosong”.
Biasakan ini sejak awal: tipe untuk data, error untuk status.
Sebelum mengimplementasikan metode, buat beberapa keputusan dan tetap konsisten:
Create: apakah menerima tipe input (tanpa ID, tanpa timestamp) atau entitas penuh? Banyak tim memilihCreate(ctx, in CreateX)untuk mencegah pemanggil mengatur field yang dimiliki server.Update: apakah mengganti penuh atau patch? Jika patch, jangan gunakan struct biasa di mana nilai nol ambigu. Gunakan pointer, tipe nullable, atau field mask eksplisit.Delete: hard delete atau soft delete? Jika soft delete, putuskan apakahGetmenyembunyikan baris yang dihapus secara default.
Juga putuskan apa yang dikembalikan metode tulis. Opsi low-surprise adalah mengembalikan entitas yang diperbarui (setelah default DB) atau hanya mengembalikan ID plus ErrNotFound saat tidak ada yang berubah.
Strategi pengujian untuk bagian generik dan khusus entitas
Pendekatan ini hanya berguna jika mudah dipercaya. Pisahkan tes sesuai pembagian kode: uji helper bersama sekali, lalu uji SQL dan scanning tiap entitas secara terpisah.
Anggap bagian bersama sebagai fungsi murni kecil kapan pun memungkinkan, seperti validasi pagination, pemetaan sort key ke kolom yang diperbolehkan, atau membangun fragmen WHERE. Ini bisa ditutup dengan unit test cepat.
Untuk query list, tes terstruktur (table-driven) bekerja baik karena edge case adalah inti permasalahan. Tutupi hal seperti filter kosong, sort key tidak dikenal, limit 0, limit melebihi max, offset negatif, dan batas “halaman berikutnya” di mana Anda mengambil satu baris ekstra.
Tetap fokus tes per-entitas pada hal yang memang spesifik entitas: SQL yang diharapkan dijalankan dan bagaimana baris di-scan ke tipe entitas. Gunakan mock SQL atau database tes ringan dan pastikan logic scan menangani null, kolom opsional, dan konversi tipe.
Jika pola Anda mendukung transaksi, uji perilaku commit/rollback dengan executor palsu kecil yang mencatat panggilan dan mensimulasikan error:
- Begin mengembalikan executor scoped tx
- saat error, rollback dipanggil tepat sekali
- saat sukses, commit dipanggil tepat sekali
- jika commit gagal, error dikembalikan apa adanya
Anda juga bisa menambahkan tes “kontrak” kecil yang harus dilalui setiap repositori: create lalu get mengembalikan data yang sama, update mengubah field yang dimaksud, delete membuat get mengembalikan not found, dan list mengembalikan ordering stabil dengan input yang sama.
Kesalahan umum dan jebakan
Generik membuat godaan untuk membangun satu repositori yang menguasai semuanya. Akses data penuh dengan perbedaan kecil, dan perbedaan itu penting.
Beberapa jebakan yang sering muncul:
- Terlalu generalisasi sampai setiap metode menerima sekantung opsi besar (joins, search, permissions, soft deletes, caching). Pada titik itu, Anda telah membangun ORM kedua.
- Constraint yang terlalu cerdas. Jika pembaca perlu mendekode tipe-set untuk memahami apa yang harus diimplementasikan entitas, abstraksi itu lebih mahal daripada manfaatnya.
- Menganggap tipe input sebagai model DB. Ketika Create dan Update memakai struct yang sama dengan yang Anda scan dari baris, detail DB bocor ke handler dan tes, dan perubahan skema menyebar ke seluruh aplikasi.
- Perilaku silent di
List: pengurutan tidak stabil, default yang tidak konsisten, atau aturan paging yang berbeda antar entitas. - Penanganan not-found yang memaksa pemanggil mengurai string error alih-alih memakai
errors.Is.
Contoh konkret: ListCustomers mengembalikan pelanggan dalam urutan berbeda setiap kali karena repositori tidak mengatur ORDER BY. Pagination kemudian menduplikasi atau melewatkan record antar permintaan. Buat ordering eksplisit (bahkan jika hanya berdasarkan primary key) dan jaga default konsisten.
Daftar periksa cepat sebelum mengadopsi ini
Sebelum Anda menggulirkan repositori generik ke setiap paket, pastikan itu menghilangkan pengulangan tanpa menyembunyikan perilaku database penting.
Mulailah dengan konsistensi. Jika satu repo menerima context.Context dan yang lain tidak, atau satu mengembalikan (T, error) sementara yang lain (*T, error), rasa sakit akan muncul di mana-mana: service, tes, dan mock.
Pastikan setiap entitas masih punya satu tempat yang jelas untuk SQL-nya. Generik harus menggunakan kembali alur (scan, validasi, map error), bukan menyebarkan query di seluruh fragmen string.
Daftar cek cepat yang mencegah kebanyakan kejutan:
- Satu konvensi tanda tangan untuk List/Get/Create/Update/Delete
- Satu aturan not-found yang dipakai semua repo
- Ordering list yang stabil, didokumentasikan dan dites
- Cara bersih menjalankan kode yang sama pada
*sql.DBdan*sql.Tx(via interface executor) - Batas yang jelas antara kode generik dan aturan entitas (validasi dan pengecekan bisnis tetap di luar lapisan generik)
Jika Anda membangun alat internal dengan cepat di AppMaster dan nanti mengekspor atau memperluas kode Go yang dihasilkan, cek-cek ini membantu menjaga lapisan data dapat diprediksi dan mudah diuji.
Contoh realistis: membangun repositori Customer
Berikut bentuk repositori Customer kecil yang tetap type-safe tanpa menjadi rumit.
Mulai dengan model yang disimpan. Pertahankan ID bertipe kuat agar Anda tidak mencampuradukkannya dengan ID lain secara tidak sengaja:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // "active", "blocked", "trial"...
}
Sekarang pisahkan “apa yang diterima API” dari “apa yang Anda simpan”. Inilah alasan Create dan Update sebaiknya berbeda.
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
Base generik Anda bisa menangani alur bersama (eksekusi SQL, scan, map error), sementara repo Customer memiliki SQL dan pemetaan khusus Customer. Dari sudut pandang lapisan service, antarmukanya tetap bersih:
type CustomerRepo interface {
Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
Get(ctx context.Context, id CustomerID) (Customer, error)
Delete(ctx context.Context, id CustomerID) error
List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}
Untuk List, perlakukan filter dan pagination sebagai objek request kelas satu. Itu menjaga call site tetap terbaca dan membuatnya lebih sulit lupa batas.
type CustomerListQuery struct {
Status *string // filter
Search *string // name contains
Limit int
Offset int
}
Dari situ, pola ini skala dengan baik: salin struktur untuk entitas berikutnya, pisahkan input dari model yang disimpan, dan jaga scanning eksplisit sehingga perubahan tetap jelas dan mudah dibantu compiler.
FAQ
Gunakan generik untuk menggunakan kembali aliran (query, loop scan, penanganan not-found, default pagination, pemetaan error), tetapi biarkan SQL dan pemetaan baris tetap eksplisit per entitas. Itu memberi Anda pengulangan yang lebih sedikit tanpa mengubah lapisan data menjadi “sihir” waktu-runtun yang rusak secara diam-diam.
Refleksi menyembunyikan aturan pemetaan dan memindahkan kegagalan ke waktu-runtun. Anda kehilangan pengecekan kompilator, dukungan IDE melemah, dan perubahan skema kecil menjadi kejutan. Dengan generik ditambah fungsi scanner eksplisit, Anda mempertahankan keselamatan tipe sambil tetap berbagi bagian yang berulang.
Default yang baik adalah comparable, karena ID biasa dibandingkan, dipakai sebagai kunci map, dan dilewatkan ke mana-mana. Jika sistem Anda memakai beberapa gaya ID (mis. int64 dan UUID string), membuat tipe ID menjadi generik menghindari memaksakan satu pilihan ke semua repo.
Buat seminimal mungkin: biasanya hanya apa yang aliran CRUD bersama butuhkan, seperti GetID() dan SetID(). Hindari memaksa field umum lewat embedding atau tipe-set yang cerdas, karena itu mengikat tipe domain Anda ke pola repositori dan membuat refactor menyakitkan.
Gunakan interface executor kecil (sering dinamai DBTX) yang hanya mencakup metode yang Anda panggil, seperti QueryContext, QueryRowContext, dan ExecContext. Dengan begitu kode repositori dapat dijalankan terhadap *sql.DB atau *sql.Tx tanpa pembelahan atau duplikasi metode.
Mengembalikan nilai nol plus error nil untuk “tidak ditemukan” memaksa pemanggil menebak apakah entitas hilang atau hanya memiliki field kosong. Sentinel bersama seperti ErrNotFound menaruh status di channel error, sehingga kode service bisa andal melakukan branching dengan errors.Is.
Pisahkan input dari model yang disimpan. Lebih baik Create(ctx, CreateInput) dan Update(ctx, id, UpdateInput) sehingga pemanggil tidak bisa mengatur field yang dimiliki server seperti ID atau timestamp. Untuk patch update, gunakan pointer (atau tipe nullable) agar Anda bisa membedakan “tidak diset” dari “diset ke nol.”
Tetapkan ORDER BY yang stabil dan eksplisit setiap kali, idealnya pada kolom unik seperti primary key. Tanpa itu, pagination bisa melewatkan atau menduplikasi item antar permintaan ketika baris baru muncul atau planner mengubah urutan scan.
Tampilkan sekumpulan kecil error yang dapat di-branch oleh pemanggil, seperti ErrNotFound dan ErrConflict, dan bungkus semua lainnya dengan konteks dari error DB bawahannya. Jangan paksa pemanggil mengurai string; tujuannya errors.Is plus pesan yang berguna untuk log.
Uji helper bersama sekali (normalisasi pagination, pemetaan not-found, pengecekan affected-row), lalu uji SQL dan scanning setiap entitas secara terpisah. Tambahkan tes “kontrak” kecil per repositori: create-then-get cocok, update mengubah field yang diharapkan, delete membuat get mengembalikan ErrNotFound, dan ordering list stabil.


