Lapisan data cenderung merembes ke seluruh kodebase: string SQL muncul di handler HTTP, rows.Scan mengisi struct di tengah logika bisnis, dan mengganti database berarti menyentuh ratusan berkas. Repository pattern memotong rembesan tersebut dengan menyembunyikan seluruh akses data di balik satu interface kecil. Logika bisnis hanya melihat method Create atau Get beserta struct domain — bukan SQL, bukan driver, bukan connection. Pada chapter ini kita bangun ItemRepository dengan dua implementasi: fake in-memory yang bisa diuji nyata di chapter ini, dan versi PostgreSQL sebagai fragmen yang mengonsumsi database/sql.
Kontrak Repository #
Repository dimulai dari tiga potong kecil: struct domain murni, sentinel error domain, dan interface. Ketiganya tidak mengimport database/sql sama sekali:
package main
import (
"errors"
"fmt"
"maps"
"slices"
"sync"
)
// ErrNotFound adalah error domain: item dengan ID tersebut tidak ada.
// Handler HTTP menerjemahkannya menjadi status 404 tanpa mengenali database.
var ErrNotFound = errors.New("item tidak ditemukan")
// Item adalah struct domain murni: tidak mengimport database apa pun.
type Item struct {
ID int
Nama string
Harga int
}
// ItemRepository adalah kontrak akses data item.
// Interface dibuat kecil: lima method cukup untuk kebutuhan saat ini.
type ItemRepository interface {
List() ([]Item, error)
Get(id int) (Item, error)
Create(nama string, harga int) (Item, error)
Update(item Item) error
Delete(id int) error
}
// MemoryRepository menyimpan item di map. Implementasi ini dipakai
// untuk demo dan test cepat tanpa database aktif.
type MemoryRepository struct {
mu sync.Mutex
berikut int
penyimpanan map[int]Item
}
func NewMemoryRepository() *MemoryRepository {
return &MemoryRepository{berikut: 1, penyimpanan: map[int]Item{}}
}
func (r *MemoryRepository) List() ([]Item, error) {
r.mu.Lock()
defer r.mu.Unlock()
hasil := make([]Item, 0, len(r.penyimpanan))
for _, id := range slices.Sorted(maps.Keys(r.penyimpanan)) {
hasil = append(hasil, r.penyimpanan[id])
}
return hasil, nil
}
func (r *MemoryRepository) Get(id int) (Item, error) {
r.mu.Lock()
defer r.mu.Unlock()
item, ada := r.penyimpanan[id]
if !ada {
return Item{}, ErrNotFound
}
return item, nil
}
func (r *MemoryRepository) Create(nama string, harga int) (Item, error) {
r.mu.Lock()
defer r.mu.Unlock()
item := Item{ID: r.berikut, Nama: nama, Harga: harga}
r.penyimpanan[r.berikut] = item
r.berikut++
return item, nil
}
func (r *MemoryRepository) Update(item Item) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ada := r.penyimpanan[item.ID]; !ada {
return ErrNotFound
}
r.penyimpanan[item.ID] = item
return nil
}
func (r *MemoryRepository) Delete(id int) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ada := r.penyimpanan[id]; !ada {
return ErrNotFound
}
delete(r.penyimpanan, id)
return nil
}
func main() {
var repo ItemRepository = NewMemoryRepository()
repo.Create("Kopi Gayo", 45000)
repo.Create("Teh Melati", 25000)
item, _ := repo.Get(1)
fmt.Printf("get(1) : %+v\n", item)
item.Harga = 50000
if err := repo.Update(item); err != nil {
fmt.Println("update :", err)
return
}
daftar, _ := repo.List()
for _, it := range daftar {
fmt.Printf("list : %s Rp%d\n", it.Nama, it.Harga)
}
if err := repo.Delete(2); err != nil {
fmt.Println("delete(2):", err)
return
}
if _, err := repo.Get(2); err != nil {
fmt.Println("get(2) :", err)
}
}Berkas di atas berisi produksi sekaligus demo main yang menekankan satu titik penting: variabel repo bertipe ItemRepository, bukan *MemoryRepository. Handler kita akan bergantung pada interface, sehingga implementasi bisa ditukar tanpa menyentuh pemanggil. Jalankan demonya:
go run repository-demo.goOutput yang Dihasilkan:
get(1) : {ID:1 Nama:Kopi Gayo Harga:45000}
list : Kopi Gayo Rp50000
list : Teh Melati Rp25000
get(2) : item tidak ditemukanMari kita telaah bagian-bagian penting berkas tersebut:
- Baris 11-13 (
ErrNotFound): sentinel error domain. Pemanggil memeriksaerrors.Is(err, ErrNotFound)tanpa tahu database apa pun. - Baris 16-20 (
type Item): struct domain murni — hanya tipe dasar, tanpa tag database. - Baris 24-30 (
ItemRepository): interface kecil berisi lima method CRUD. Method yang tidak dibutuhkan jangan ditambahkan; interface yang besar memaksa fake mengimplementasikan banyak method kosong. - Baris 34-38 (
MemoryRepository): fake berbasis map dengansync.Mutex— aman dipakai dari beberapa goroutine test. - Baris 44-52 (
List): iterasi map diurutkan lewatslices.Sorted(maps.Keys(...))karena urutan map Go diacak; tanpa pengurutan, outputListtidak deterministik. - Baris 93-94 (
var repo ItemRepository): titik kunci pola repository — pemanggil bergantung pada interface, implementasi konkret di-inject sebagai nilai.
Menguji Repository dengan Fake #
Interface kecil membuat fake murah ditulis, dan fake membuat unit test berjalan tanpa database. Berkas repository_test.go menguji MemoryRepository secara table-driven dengan subtest — pola yang dibahas tuntas pada chapter Table-Driven Test
:
package main
import (
"errors"
"testing"
)
func TestMemoryRepository(t *testing.T) {
tests := []struct {
nama string
jalankan func(t *testing.T)
}{
{
nama: "Create menghasilkan ID berurutan",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
pertama, err := repo.Create("Kopi Gayo", 45000)
if err != nil {
t.Fatalf("Create pertama gagal: %v", err)
}
kedua, err := repo.Create("Teh Melati", 25000)
if err != nil {
t.Fatalf("Create kedua gagal: %v", err)
}
if pertama.ID != 1 || kedua.ID != 2 {
t.Errorf("ID = %d, %d; ingin 1, 2", pertama.ID, kedua.ID)
}
},
},
{
nama: "Get mengembalikan item yang dibuat",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
dibuat, _ := repo.Create("Gula Aren", 15000)
diambil, err := repo.Get(dibuat.ID)
if err != nil {
t.Fatalf("Get gagal: %v", err)
}
if diambil != dibuat {
t.Errorf("Get = %+v; ingin %+v", diambil, dibuat)
}
},
},
{
nama: "Get item yang tidak ada mengembalikan ErrNotFound",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
_, err := repo.Get(99)
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v; ingin ErrNotFound", err)
}
},
},
{
nama: "List terurut naik berdasarkan ID",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
repo.Create("Kopi Gayo", 45000)
repo.Create("Teh Melati", 25000)
repo.Create("Gula Aren", 15000)
daftar, err := repo.List()
if err != nil {
t.Fatalf("List gagal: %v", err)
}
for i, item := range daftar {
if item.ID != i+1 {
t.Errorf("daftar[%d].ID = %d; ingin %d", i, item.ID, i+1)
}
}
},
},
{
nama: "Update item yang tidak ada mengembalikan ErrNotFound",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
err := repo.Update(Item{ID: 99, Nama: "Tidak Ada", Harga: 0})
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v; ingin ErrNotFound", err)
}
},
},
{
nama: "Delete menghapus item dari List",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
dibuat, _ := repo.Create("Kopi Gayo", 45000)
if err := repo.Delete(dibuat.ID); err != nil {
t.Fatalf("Delete gagal: %v", err)
}
daftar, _ := repo.List()
if len(daftar) != 0 {
t.Errorf("len(List) = %d setelah Delete; ingin 0", len(daftar))
}
if _, err := repo.Get(dibuat.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("Get setelah Delete = %v; ingin ErrNotFound", err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.nama, func(t *testing.T) {
tt.jalankan(t)
})
}
}Jalankan seluruh test di folder chapter:
go test -v ./...Output yang Dihasilkan:
=== RUN TestMemoryRepository
=== RUN TestMemoryRepository/Create_menghasilkan_ID_berurutan
=== RUN TestMemoryRepository/Get_mengembalikan_item_yang_dibuat
=== RUN TestMemoryRepository/Get_item_yang_tidak_ada_mengembalikan_ErrNotFound
=== RUN TestMemoryRepository/List_terurut_naik_berdasarkan_ID
=== RUN TestMemoryRepository/Update_item_yang_tidak_ada_mengembalikan_ErrNotFound
=== RUN TestMemoryRepository/Delete_menghapus_item_dari_List
--- PASS: TestMemoryRepository (0.00s)
--- PASS: TestMemoryRepository/Create_menghasilkan_ID_berurutan (0.00s)
--- PASS: TestMemoryRepository/Get_mengembalikan_item_yang_dibuat (0.00s)
--- PASS: TestMemoryRepository/Get_item_yang_tidak_ada_mengembalikan_ErrNotFound (0.00s)
--- PASS: TestMemoryRepository/List_terurut_naik_berdasarkan_ID (0.00s)
--- PASS: TestMemoryRepository/Update_item_yang_tidak_ada_mengembalikan_ErrNotFound (0.00s)
--- PASS: TestMemoryRepository/Delete_menghapus_item_dari_List (0.00s)
PASSSetiap subtest membuat NewMemoryRepository() segar, sehingga kasus tidak berbagi state — prinsip isolasi yang sama berlaku saat menguji repository database nyata pada chapter Testing Database
.
Implementasi PostgreSQL #
Implementasi nyata menyimpan *sql.DB dan menerjemahkan baris tabel menjadi struct domain. Dua tanggung jawab tambahan muncul di sini: memetakan kolom NULL dan menerjemahkan error driver menjadi error domain. Fragmen berikut membutuhkan PostgreSQL aktif untuk dijalankan — lihat proyek REST API PostgreSQL
:
type PostgresRepository struct {
db *sql.DB
}
// ItemNyata menambahkan kolom nullable: deskripsi boleh kosong.
type ItemNyata struct {
ID int
Nama string
Harga int
Deskripsi *string // pointer: nil berarti kolom NULL
}
func (r *PostgresRepository) Get(id int) (ItemNyata, error) {
var item ItemNyata
var deskripsi sql.NullString
err := r.db.QueryRow(
"SELECT id, nama, harga, deskripsi FROM items WHERE id = $1", id,
).Scan(&item.ID, &item.Nama, &item.Harga, &deskripsi)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ItemNyata{}, ErrNotFound // terjemahkan error driver → domain
}
return ItemNyata{}, fmt.Errorf("query item %d: %w", id, err)
}
if deskripsi.Valid {
item.Deskripsi = &deskripsi.String
}
return item, nil
}Kolom NULL diisi ke sql.NullString karena Scan ke *string menolak nilai NULL. Setelah Scan, field Valid memisahkan NULL (false) dari string kosong (true), lalu nilainya dipindahkan ke pointer Deskripsi. Alternatif lain: scan langsung ke *string untuk driver yang mendukung, atau memakai COALESCE di query.
Repository dalam Arsitektur #
Repository adalah salah satu dari sekian pola struktural di Go. Pembahasan yang lebih luas — lapisan service, dependency injection, dan batas internal/ — tersedia pada chapter Repository Pattern
serta section Arsitektur Aplikasi
. Prinsipnya sama untuk semua: ketergantungan mengarah ke interface milik pemanggil, bukan ke implementasi konkret.
Dengan akses data terisolasi, pertanyaan berikutnya berbeda: bagaimana membatalkan query yang terlalu lama berjalan? Chapter selanjutnya membawa context.Context ke dalam panggilan database.
Referensi #
- Package database/sql: Rows
—
Next,Err, dan pemetaan kolom keScan. - Package database/sql
—
QueryRow,sql.NullString, dansql.ErrNoRows. - Package testing
—
t.Rununtuk subtest pada fake memory.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
repository_test.go
Unduh berkaspackage main
import (
"errors"
"testing"
)
func TestMemoryRepository(t *testing.T) {
tests := []struct {
nama string
jalankan func(t *testing.T)
}{
{
nama: "Create menghasilkan ID berurutan",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
pertama, err := repo.Create("Kopi Gayo", 45000)
if err != nil {
t.Fatalf("Create pertama gagal: %v", err)
}
kedua, err := repo.Create("Teh Melati", 25000)
if err != nil {
t.Fatalf("Create kedua gagal: %v", err)
}
if pertama.ID != 1 || kedua.ID != 2 {
t.Errorf("ID = %d, %d; ingin 1, 2", pertama.ID, kedua.ID)
}
},
},
{
nama: "Get mengembalikan item yang dibuat",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
dibuat, _ := repo.Create("Gula Aren", 15000)
diambil, err := repo.Get(dibuat.ID)
if err != nil {
t.Fatalf("Get gagal: %v", err)
}
if diambil != dibuat {
t.Errorf("Get = %+v; ingin %+v", diambil, dibuat)
}
},
},
{
nama: "Get item yang tidak ada mengembalikan ErrNotFound",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
_, err := repo.Get(99)
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v; ingin ErrNotFound", err)
}
},
},
{
nama: "List terurut naik berdasarkan ID",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
repo.Create("Kopi Gayo", 45000)
repo.Create("Teh Melati", 25000)
repo.Create("Gula Aren", 15000)
daftar, err := repo.List()
if err != nil {
t.Fatalf("List gagal: %v", err)
}
for i, item := range daftar {
if item.ID != i+1 {
t.Errorf("daftar[%d].ID = %d; ingin %d", i, item.ID, i+1)
}
}
},
},
{
nama: "Update item yang tidak ada mengembalikan ErrNotFound",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
err := repo.Update(Item{ID: 99, Nama: "Tidak Ada", Harga: 0})
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v; ingin ErrNotFound", err)
}
},
},
{
nama: "Delete menghapus item dari List",
jalankan: func(t *testing.T) {
repo := NewMemoryRepository()
dibuat, _ := repo.Create("Kopi Gayo", 45000)
if err := repo.Delete(dibuat.ID); err != nil {
t.Fatalf("Delete gagal: %v", err)
}
daftar, _ := repo.List()
if len(daftar) != 0 {
t.Errorf("len(List) = %d setelah Delete; ingin 0", len(daftar))
}
if _, err := repo.Get(dibuat.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("Get setelah Delete = %v; ingin ErrNotFound", err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.nama, func(t *testing.T) {
tt.jalankan(t)
})
}
}
repository-demo.go
Unduh berkaspackage main
import (
"errors"
"fmt"
"maps"
"slices"
"sync"
)
// ErrNotFound adalah error domain: item dengan ID tersebut tidak ada.
// Handler HTTP menerjemahkannya menjadi status 404 tanpa mengenali database.
var ErrNotFound = errors.New("item tidak ditemukan")
// Item adalah struct domain murni: tidak mengimport database apa pun.
type Item struct {
ID int
Nama string
Harga int
}
// ItemRepository adalah kontrak akses data item.
// Interface dibuat kecil: lima method cukup untuk kebutuhan saat ini.
type ItemRepository interface {
List() ([]Item, error)
Get(id int) (Item, error)
Create(nama string, harga int) (Item, error)
Update(item Item) error
Delete(id int) error
}
// MemoryRepository menyimpan item di map. Implementasi ini dipakai
// untuk demo dan test cepat tanpa database aktif.
type MemoryRepository struct {
mu sync.Mutex
berikut int
penyimpanan map[int]Item
}
func NewMemoryRepository() *MemoryRepository {
return &MemoryRepository{berikut: 1, penyimpanan: map[int]Item{}}
}
func (r *MemoryRepository) List() ([]Item, error) {
r.mu.Lock()
defer r.mu.Unlock()
hasil := make([]Item, 0, len(r.penyimpanan))
for _, id := range slices.Sorted(maps.Keys(r.penyimpanan)) {
hasil = append(hasil, r.penyimpanan[id])
}
return hasil, nil
}
func (r *MemoryRepository) Get(id int) (Item, error) {
r.mu.Lock()
defer r.mu.Unlock()
item, ada := r.penyimpanan[id]
if !ada {
return Item{}, ErrNotFound
}
return item, nil
}
func (r *MemoryRepository) Create(nama string, harga int) (Item, error) {
r.mu.Lock()
defer r.mu.Unlock()
item := Item{ID: r.berikut, Nama: nama, Harga: harga}
r.penyimpanan[r.berikut] = item
r.berikut++
return item, nil
}
func (r *MemoryRepository) Update(item Item) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ada := r.penyimpanan[item.ID]; !ada {
return ErrNotFound
}
r.penyimpanan[item.ID] = item
return nil
}
func (r *MemoryRepository) Delete(id int) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ada := r.penyimpanan[id]; !ada {
return ErrNotFound
}
delete(r.penyimpanan, id)
return nil
}
func main() {
var repo ItemRepository = NewMemoryRepository()
repo.Create("Kopi Gayo", 45000)
repo.Create("Teh Melati", 25000)
item, _ := repo.Get(1)
fmt.Printf("get(1) : %+v\n", item)
item.Harga = 50000
if err := repo.Update(item); err != nil {
fmt.Println("update :", err)
return
}
daftar, _ := repo.List()
for _, it := range daftar {
fmt.Printf("list : %s Rp%d\n", it.Nama, it.Harga)
}
if err := repo.Delete(2); err != nil {
fmt.Println("delete(2):", err)
return
}
if _, err := repo.Get(2); err != nil {
fmt.Println("get(2) :", err)
}
}