Logika bisnis tidak seharusnya tahu di mana datanya tidur: di map, di berkas JSON, atau di PostgreSQL. Setiap detail storage yang bocor ke layanan — nama berkas, format kolom, pesan error driver — menjadi beban saat penyimpanan diganti atau diuji. Repository pattern memotong kebocoran itu dengan satu interface: layanan hanya melihat method Save dan Find, implementasi yang memegang sisanya. Chapter ini membangun repository generik Repository[T] dengan dua implementasi nyata — map dalam memori dan berkas JSON — lalu menukar keduanya tanpa menyentuh kode layanan.

Kontrak: Repository[T] Generik #

Satu interface untuk semua entitas menjadi mungkin berkat type parameter. Entitas apa pun memakai kontrak yang sama dan hanya berbeda instansiasi:

go
type Repository[T any] interface {
	Save(id string, entitas T) error
	Find(id string) (T, error)
	Delete(id string) error
}

Sentinel error ErrNotFound dideklarasikan berdampingan dengan interface dan ikut menjadi bagian kontrak. Saat entitas tidak ditemukan, repository mengembalikan nilai nol T beserta error terbungkus %w, sehingga pemanggil cukup memeriksa errors.Is(err, ErrNotFound) tanpa mengenal storage di belakangnya.

Dua Implementasi, Satu Layanan #

Implementasi pertama, memoryRepo[T], menyimpan map map[string]T yang dijaga sync.Mutex — sederhana dan cocok sebagai pengganti saat pengujian. Implementasi kedua, fileRepo[T], membaca berkas JSON ke map, mengubahnya, lalu menulis kembali dengan json.MarshalIndent. Berkas yang belum ada dianggap map kosong, bukan error, karena repository baru memang sah belum punya data.

Kedua implementasi dibangun lewat constructor generik. Layanan LayananPengguna hanya menyimpan satu field bertipe Repository[Pengguna] — tidak ada import os maupun encoding/json. Detail seperti izin berkas 0o600 dan nama file sementara hidup sepenuhnya di dalam implementasi.

Demo: Memory dan File JSON #

Berkas repo-demo.go pada bagian bawah halaman menjalankan layanan yang sama dengan dua repository, lalu menampilkan isi berkas JSON yang dihasilkan. Kode lengkapnya:

go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sync"
)

// ErrNotFound adalah error domain: entitas dengan ID tersebut tidak ada.
// Pemanggil memeriksa error ini dengan errors.Is, bukan mengenali storage.
var ErrNotFound = errors.New("entitas tidak ditemukan")

// Repository adalah kontrak penyimpanan generik.
// Type parameter T membuat satu interface dipakai untuk semua entitas.
type Repository[T any] interface {
	Save(id string, entitas T) error
	Find(id string) (T, error)
	Delete(id string) error
}

// memoryRepo menyimpan entitas di map dalam memori.
type memoryRepo[T any] struct {
	mu   sync.Mutex
	data map[string]T
}

func NewMemoryRepo[T any]() *memoryRepo[T] {
	return &memoryRepo[T]{data: map[string]T{}}
}

func (r *memoryRepo[T]) Save(id string, entitas T) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.data[id] = entitas
	return nil
}

func (r *memoryRepo[T]) Find(id string) (T, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	v, ok := r.data[id]
	if !ok {
		var zero T
		return zero, fmt.Errorf("cari %q: %w", id, ErrNotFound)
	}
	return v, nil
}

func (r *memoryRepo[T]) Delete(id string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.data[id]; !ok {
		return fmt.Errorf("hapus %q: %w", id, ErrNotFound)
	}
	delete(r.data, id)
	return nil
}

// fileRepo menyimpan seluruh map sebagai satu berkas JSON.
type fileRepo[T any] struct {
	nama string
	mu   sync.Mutex
}

func NewFileRepo[T any](nama string) *fileRepo[T] {
	return &fileRepo[T]{nama: nama}
}

// muat membaca isi berkas; berkas yang belum ada dianggap kosong.
func (r *fileRepo[T]) muat() (map[string]T, error) {
	isi, err := os.ReadFile(r.nama)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return map[string]T{}, nil
		}
		return nil, err
	}
	var data map[string]T
	if err := json.Unmarshal(isi, &data); err != nil {
		return nil, err
	}
	return data, nil
}

func (r *fileRepo[T]) simpan(data map[string]T) error {
	isi, err := json.MarshalIndent(data, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(r.nama, isi, 0o600)
}

func (r *fileRepo[T]) Save(id string, entitas T) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	data, err := r.muat()
	if err != nil {
		return err
	}
	data[id] = entitas
	return r.simpan(data)
}

func (r *fileRepo[T]) Find(id string) (T, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	data, err := r.muat()
	if err != nil {
		var zero T
		return zero, err
	}
	v, ok := data[id]
	if !ok {
		var zero T
		return zero, fmt.Errorf("cari %q: %w", id, ErrNotFound)
	}
	return v, nil
}

func (r *fileRepo[T]) Delete(id string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	data, err := r.muat()
	if err != nil {
		return err
	}
	if _, ok := data[id]; !ok {
		return fmt.Errorf("hapus %q: %w", id, ErrNotFound)
	}
	delete(data, id)
	return r.simpan(data)
}

// Pengguna adalah struct domain murni, tidak mengimport storage.
type Pengguna struct {
	Nama  string
	Email string
}

// LayananPengguna hanya mengenal interface Repository.
// Implementasi bisa ditukar tanpa menyentuh baris pun di sini.
type LayananPengguna struct {
	repo Repository[Pengguna]
}

func NewLayananPengguna(repo Repository[Pengguna]) *LayananPengguna {
	return &LayananPengguna{repo: repo}
}

func (l *LayananPengguna) Daftar(id, nama, email string) error {
	return l.repo.Save(id, Pengguna{Nama: nama, Email: email})
}

func (l *LayananPengguna) Cari(id string) (Pengguna, error) {
	return l.repo.Find(id)
}

func main() {
	// Implementasi pertama: in-memory.
	mem := NewLayananPengguna(NewMemoryRepo[Pengguna]())
	_ = mem.Daftar("u1", "Budi", "[email protected]")
	_ = mem.Daftar("u2", "Siti", "[email protected]")

	p, err := mem.Cari("u1")
	if err != nil {
		fmt.Println("error:", err)
	}
	fmt.Printf("memory  : u1 = %s <%s>\n", p.Nama, p.Email)

	_, err = mem.Cari("u9")
	fmt.Println("memory  : cari u9 ->", err)
	fmt.Println("memory  : errors.Is(ErrNotFound) =", errors.Is(err, ErrNotFound))

	// Implementasi kedua: file JSON, layanan yang sama tanpa perubahan.
	berkas := filepath.Join(os.TempDir(), "repo-demo-pengguna.json")
	os.Remove(berkas) // mulai dari kondisi bersih agar demo dapat diulang

	file := NewLayananPengguna(NewFileRepo[Pengguna](berkas))
	_ = file.Daftar("u1", "Budi", "[email protected]")

	q, err := file.Cari("u1")
	if err != nil {
		fmt.Println("error:", err)
	}
	fmt.Printf("file    : u1 = %s <%s>\n", q.Nama, q.Email)

	isi, err := os.ReadFile(berkas)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("file    : isi %s:\n%s\n", filepath.Base(berkas), string(isi))
}

Output yang Dihasilkan:

text
memory  : u1 = Budi <[email protected]>
memory  : cari u9 -> cari "u9": entitas tidak ditemukan
memory  : errors.Is(ErrNotFound) = true
file    : u1 = Budi <[email protected]>
file    : isi repo-demo-pengguna.json:
{
  "u1": {
    "Nama": "Budi",
    "Email": "[email protected]"
  }
}

Mari kita telaah baris demi baris program di atas:

  1. Baris 14 (ErrNotFound): sentinel error domain yang dideklarasikan berdampingan dengan kontrak.
  2. Baris 18–22 (Repository[T any]): kontrak generik; T diinstansiasi per entitas, tanpa interface terpisah untuk tiap jenis data.
  3. Baris 25–35 (memoryRepo): map plus sync.Mutex; seluruh method mengunci sebelum menyentuh map.
  4. Baris 41–49 (Find memory): entitas yang tidak ada mengembalikan nilai nol T dan error terbungkus %w berisi ID.
  5. Baris 73–94 (muat, simpan): os.ErrNotExist diterjemahkan menjadi map kosong; penulisan memakai MarshalIndent dan izin 0o600.
  6. Baris 149–159 (LayananPengguna): layanan hanya mengenal Repository[Pengguna] — titik yang membuat penukaran implementasi bebas efek samping.
  7. Baris 163 dan 181 (dua perakitan): layanan pertama memakai NewMemoryRepo, kedua NewFileRepo — kode layanan sama sekali tidak berubah.
  8. Baris 178–179 (berkas demo): path sementara dihapus dulu agar tiap eksekusi mulai dari kondisi bersih.

Error Domain dan Pemetaan #

Repository adalah tempat terjemahan error. Dunia storage berbicara bahasa masing-masing: berkas hilang melontarkan os.ErrNotExist, database melontarkan sql.ErrNoRows, JSON rusak melontarkan error sintaks. Di dalam repository semua itu dipetakan ke kosakata domain: entitas ada atau tidak ada. Layanan cukup menangani ErrNotFound — misalnya menerjemahkannya menjadi respons 404 — tanpa mengimport os maupun database/sql.

Batasan Generics pada Method #

Type parameter T pada Repository[T] melekat pada tipe, dan di situlah batasnya: method tidak boleh membawa type parameter sendiri. Deklarasi func (r *memoryRepo[T]) AmbilSemua[U any](f func(T) U) []U ditolak kompiler dengan pesan method must have no type parameters. Jalan keluarnya: fungsi bebas di level package boleh membawa type parameter sendiri:

go
// Fungsi bebas boleh membawa type parameter — method tidak.
func AmbilSemua[T, U any](r Repository[T], konversi func(T) U) []U

Pola campuran ini — tipe generik menyimpan data, fungsi generik mengoperasikannya — adalah idiom lazim di standard library. Batasannya dibahas di chapter Generic Type dan Keterbatasan Method Generik .

Versi SQL dari pola ini — pemetaan sql.ErrNoRows, connection pooling, dan fake untuk test database — dibahas di chapter Repository Pattern pada Database . Lalu muncul pertanyaan alami: jika semua komponen bergantung pada interface, siapa yang menyambungkannya? Dependency Injection Pattern menjawab dengan satu tempat perakitan.

Referensi #

Berkas Sumber

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

repo-demo.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sync"
)

// ErrNotFound adalah error domain: entitas dengan ID tersebut tidak ada.
// Pemanggil memeriksa error ini dengan errors.Is, bukan mengenali storage.
var ErrNotFound = errors.New("entitas tidak ditemukan")

// Repository adalah kontrak penyimpanan generik.
// Type parameter T membuat satu interface dipakai untuk semua entitas.
type Repository[T any] interface {
	Save(id string, entitas T) error
	Find(id string) (T, error)
	Delete(id string) error
}

// memoryRepo menyimpan entitas di map dalam memori.
type memoryRepo[T any] struct {
	mu   sync.Mutex
	data map[string]T
}

func NewMemoryRepo[T any]() *memoryRepo[T] {
	return &memoryRepo[T]{data: map[string]T{}}
}

func (r *memoryRepo[T]) Save(id string, entitas T) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.data[id] = entitas
	return nil
}

func (r *memoryRepo[T]) Find(id string) (T, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	v, ok := r.data[id]
	if !ok {
		var zero T
		return zero, fmt.Errorf("cari %q: %w", id, ErrNotFound)
	}
	return v, nil
}

func (r *memoryRepo[T]) Delete(id string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.data[id]; !ok {
		return fmt.Errorf("hapus %q: %w", id, ErrNotFound)
	}
	delete(r.data, id)
	return nil
}

// fileRepo menyimpan seluruh map sebagai satu berkas JSON.
type fileRepo[T any] struct {
	nama string
	mu   sync.Mutex
}

func NewFileRepo[T any](nama string) *fileRepo[T] {
	return &fileRepo[T]{nama: nama}
}

// muat membaca isi berkas; berkas yang belum ada dianggap kosong.
func (r *fileRepo[T]) muat() (map[string]T, error) {
	isi, err := os.ReadFile(r.nama)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return map[string]T{}, nil
		}
		return nil, err
	}
	var data map[string]T
	if err := json.Unmarshal(isi, &data); err != nil {
		return nil, err
	}
	return data, nil
}

func (r *fileRepo[T]) simpan(data map[string]T) error {
	isi, err := json.MarshalIndent(data, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(r.nama, isi, 0o600)
}

func (r *fileRepo[T]) Save(id string, entitas T) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	data, err := r.muat()
	if err != nil {
		return err
	}
	data[id] = entitas
	return r.simpan(data)
}

func (r *fileRepo[T]) Find(id string) (T, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	data, err := r.muat()
	if err != nil {
		var zero T
		return zero, err
	}
	v, ok := data[id]
	if !ok {
		var zero T
		return zero, fmt.Errorf("cari %q: %w", id, ErrNotFound)
	}
	return v, nil
}

func (r *fileRepo[T]) Delete(id string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	data, err := r.muat()
	if err != nil {
		return err
	}
	if _, ok := data[id]; !ok {
		return fmt.Errorf("hapus %q: %w", id, ErrNotFound)
	}
	delete(data, id)
	return r.simpan(data)
}

// Pengguna adalah struct domain murni, tidak mengimport storage.
type Pengguna struct {
	Nama  string
	Email string
}

// LayananPengguna hanya mengenal interface Repository.
// Implementasi bisa ditukar tanpa menyentuh baris pun di sini.
type LayananPengguna struct {
	repo Repository[Pengguna]
}

func NewLayananPengguna(repo Repository[Pengguna]) *LayananPengguna {
	return &LayananPengguna{repo: repo}
}

func (l *LayananPengguna) Daftar(id, nama, email string) error {
	return l.repo.Save(id, Pengguna{Nama: nama, Email: email})
}

func (l *LayananPengguna) Cari(id string) (Pengguna, error) {
	return l.repo.Find(id)
}

func main() {
	// Implementasi pertama: in-memory.
	mem := NewLayananPengguna(NewMemoryRepo[Pengguna]())
	_ = mem.Daftar("u1", "Budi", "[email protected]")
	_ = mem.Daftar("u2", "Siti", "[email protected]")

	p, err := mem.Cari("u1")
	if err != nil {
		fmt.Println("error:", err)
	}
	fmt.Printf("memory  : u1 = %s <%s>\n", p.Nama, p.Email)

	_, err = mem.Cari("u9")
	fmt.Println("memory  : cari u9 ->", err)
	fmt.Println("memory  : errors.Is(ErrNotFound) =", errors.Is(err, ErrNotFound))

	// Implementasi kedua: file JSON, layanan yang sama tanpa perubahan.
	berkas := filepath.Join(os.TempDir(), "repo-demo-pengguna.json")
	os.Remove(berkas) // mulai dari kondisi bersih agar demo dapat diulang

	file := NewLayananPengguna(NewFileRepo[Pengguna](berkas))
	_ = file.Daftar("u1", "Budi", "[email protected]")

	q, err := file.Cari("u1")
	if err != nil {
		fmt.Println("error:", err)
	}
	fmt.Printf("file    : u1 = %s <%s>\n", q.Nama, q.Email)

	isi, err := os.ReadFile(berkas)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("file    : isi %s:\n%s\n", filepath.Base(berkas), string(isi))
}