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:

go
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:

bash
go run repository-demo.go

Output yang Dihasilkan:

text
get(1)   : {ID:1 Nama:Kopi Gayo Harga:45000}
list     : Kopi Gayo Rp50000
list     : Teh Melati Rp25000
get(2)   : item tidak ditemukan

Mari kita telaah bagian-bagian penting berkas tersebut:

  1. Baris 11-13 (ErrNotFound): sentinel error domain. Pemanggil memeriksa errors.Is(err, ErrNotFound) tanpa tahu database apa pun.
  2. Baris 16-20 (type Item): struct domain murni — hanya tipe dasar, tanpa tag database.
  3. 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.
  4. Baris 34-38 (MemoryRepository): fake berbasis map dengan sync.Mutex — aman dipakai dari beberapa goroutine test.
  5. Baris 44-52 (List): iterasi map diurutkan lewat slices.Sorted(maps.Keys(...)) karena urutan map Go diacak; tanpa pengurutan, output List tidak deterministik.
  6. 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 :

go
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:

bash
go test -v ./...

Output yang Dihasilkan:

text
=== 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)
PASS

Setiap 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 :

go
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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

repository_test.go

Unduh berkas
go
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)
		})
	}
}

repository-demo.go

Unduh berkas
go
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)
	}
}