Aplikasi kecil bisa saja menampung seluruh logika di satu fungsi, tetapi aplikasi yang terus bertambah fitur akan berubah menjadi kode yang sulit dibaca dan diuji. Prinsip yang menjawab masalah ini bernama separation of concerns atau pemisahan tanggung jawab: satu unit kode hanya memikul satu alasan untuk berubah. Pada chapter ini kita akan melihat bentuk nyata kode yang mencampur tanggung jawab, memetakannya ke empat lapisan, lalu membangun versi bersihnya dengan handler, service, dan repository yang masing-masing bisa diuji sendiri.

Masalah: Handler yang Mengerjakan Segalanya #

Bayangkan sebuah endpoint daftar produk yang ditulis langsung dari query database sampai format JSON. Fragmen berikut memperlihatkan pola yang sering disebut spaghetti handler:

go
func handlerDaftarProduk(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	row := db.QueryRow("SELECT nama, harga FROM produk WHERE id = $1", id)
	var nama string
	var harga int
	if err := row.Scan(&nama, &harga); err != nil {
		http.Error(w, "query gagal", 500)
		return
	}
	if r.Header.Get("X-Member") == "ya" && harga >= 100000 {
		harga = harga - harga/10
	}
	fmt.Fprintf(w, `{"nama":%q,"harga":%d}`, nama, harga)
}

Fungsi di atas memikul empat tanggung jawab sekaligus: membaca permintaan HTTP, menjalankan query SQL, menerapkan aturan bisnis diskon member, dan memformat response JSON. Ketika aturan diskon berubah, kita harus menyentuh kode HTTP. Ketika skema tabel berubah, kita juga menyentuh kode yang sama. Fragmen ini bukan contoh yang bisa dijalankan utuh, melainkan potongan kontras yang sengaja menunjukkan masalahnya.

Versi yang bersih memindahkan seluruh pekerjaan non-HTTP keluar dari handler:

go
func handlerDaftarProduk(w http.ResponseWriter, r *http.Request) {
	member := r.Header.Get("X-Member") == "ya"
	harga, err := layanan.HargaFinal(r.PathValue("id"), member)
	if err != nil {
		http.Error(w, "produk tidak ditemukan", 404)
		return
	}
	fmt.Fprintf(w, `{"harga":%d}`, harga)
}

Handler kedua hanya membaca input, memanggil service, dan memformat keluaran. Query SQL hidup di repository, aturan diskon hidup di service, dan keduanya bisa berubah tanpa saling mengganggu.

Empat Lapisan Tanggung Jawab #

Kita bisa memetakan tanggung jawab aplikasi ke empat lapisan yang umum dipakai di Go:

LapisanTanggung jawabContoh isi
PresentationBaca input, format outputHandler HTTP, encoding JSON
Application / ServiceAturan bisnis dan orkestrasiHargaFinal, perhitungan diskon
DomainTipe data intiStruct Produk
Data / RepositoryBaca-tulis penyimpananRepoProduk, query SQL

Diagram berikut memperlihatkan arah panggilan antar lapisan:

Demo: Handler, Service, Repository #

Berkas srf-demo.go di bawah menerapkan ketiga peran dalam satu program yang bisa dijalankan. Handler disimulasikan dengan method Tangani yang menerima struct Permintaan dan mengembalikan string response, repository memakai map di memori, dan service menjaga aturan diskon member:

go
package main

import "fmt"

// ===== Domain: tipe data murni tanpa dependensi =====

type Produk struct {
	ID    string
	Nama  string
	Harga int // dalam Rupiah
}

// ===== Data: akses data lewat interface, bukan SQL di handler =====

type RepoProduk interface {
	Cari(id string) (Produk, error)
}

type RepoMemori struct {
	data map[string]Produk
}

func NewRepoMemori() *RepoMemori {
	return &RepoMemori{data: map[string]Produk{
		"P-001": {ID: "P-001", Nama: "Buku Go", Harga: 95000},
		"P-002": {ID: "P-002", Nama: "Mouse Kantor", Harga: 150000},
	}}
}

func (r *RepoMemori) Cari(id string) (Produk, error) {
	p, ok := r.data[id]
	if !ok {
		return Produk{}, fmt.Errorf("produk %s tidak ditemukan", id)
	}
	return p, nil
}

// ===== Service: logika bisnis (diskon member) =====

type LayananProduk struct {
	repo RepoProduk
}

func NewLayananProduk(repo RepoProduk) *LayananProduk {
	return &LayananProduk{repo: repo}
}

func (s *LayananProduk) HargaFinal(id string, member bool) (int, error) {
	p, err := s.repo.Cari(id)
	if err != nil {
		return 0, err
	}
	if member && p.Harga >= 100000 {
		return p.Harga - p.Harga/10, nil
	}
	return p.Harga, nil
}

// ===== Presentation: handler hanya membaca input dan memformat =====

type Permintaan struct {
	ID     string
	Member bool
}

type HandlerProduk struct {
	layanan *LayananProduk
}

func (h *HandlerProduk) Tangani(req Permintaan) string {
	harga, err := h.layanan.HargaFinal(req.ID, req.Member)
	if err != nil {
		return fmt.Sprintf("HTTP 404 — %v", err)
	}
	return fmt.Sprintf(`{"id":%q,"harga":%d}`, req.ID, harga)
}

func main() {
	repo := NewRepoMemori()
	handler := &HandlerProduk{layanan: NewLayananProduk(repo)}

	for _, req := range []Permintaan{
		{ID: "P-001", Member: true},
		{ID: "P-002", Member: true},
		{ID: "P-999", Member: false},
	} {
		fmt.Println(handler.Tangani(req))
	}
}

Jalankan program dengan perintah berikut dari direktori chapter:

bash
go run srf-demo.go

Output yang Dihasilkan:

text
{"id":"P-001","harga":95000}
{"id":"P-002","harga":135000}
HTTP 404 — produk P-999 tidak ditemukan

Mari kita telaah bagian-bagian penting program di atas:

  1. Baris 15-16 (type RepoProduk interface ...): mendefinisikan kontrak akses data. Service hanya mengenal interface ini, sehingga SQL bisa diganti tanpa menyentuh service.
  2. Baris 23-28 (func NewRepoMemori ...): constructor repository memori sekaligus mengisi dua data contoh. Di produksi, peran ini diganti adapter PostgreSQL seperti pada proyek REST API dengan PostgreSQL .
  3. Baris 48-57 (func (s *LayananProduk) HargaFinal ...): seluruh aturan diskon member hidup di sini. Handler tidak tahu rumus apa pun; handler hanya bertanya hasil akhirnya.
  4. Baris 70-76 (func (h *HandlerProduk) Tangani ...): handler membaca Permintaan, memanggil service, lalu memformat hasil. Ketika service mengembalikan error, handler menerjemahkannya menjadi status 404.
  5. Baris 82-87 (for _, req := range ...): tiga permintaan diuji berurutan. P-002 mendapat potongan 10% karena harga produk Rp150.000 melewati ambang Rp100.000, sedangkan P-001 tidak; permintaan P-999 memperlihatkan jalur error yang tetap rapi.

Menguji Tiap Lapisan Secara Terpisah #

Pemisahan ini terasa paling besar manfaatnya saat menulis test. Aturan diskon di HargaFinal bisa diuji dengan repository palsu berisi dua produk tanpa perlu database atau HTTP. Handler bisa diuji dengan httptest karena handler hanya bergantung pada service, bukan pada *sql.DB. Pola pengujian berlapis seperti ini sudah kita latih pada chapter Unit Test dan httptest .

Dengan tanggung jawab yang sudah terpisah rapi, langkah berikutnya adalah menyusun lapisan-lapisan tersebut menjadi arsitektur berlapis yang utuh. Chapter berikutnya membentuk alur request yang turun dan response yang naik melalui layer handler, use case, dan repository.

Referensi #

  • Effective Go — panduan resmi gaya kode yang mendorong interface kecil dan pemisahan yang jelas.
  • Errors are values — cara idiomatik Go menangani error yang naik antar lapisan.
  • Package testing — dasar pengujian tiap lapisan secara terpisah.

Berkas Sumber

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

srf-demo.go

Unduh berkas
go
package main

import "fmt"

// ===== Domain: tipe data murni tanpa dependensi =====

type Produk struct {
	ID    string
	Nama  string
	Harga int // dalam Rupiah
}

// ===== Data: akses data lewat interface, bukan SQL di handler =====

type RepoProduk interface {
	Cari(id string) (Produk, error)
}

type RepoMemori struct {
	data map[string]Produk
}

func NewRepoMemori() *RepoMemori {
	return &RepoMemori{data: map[string]Produk{
		"P-001": {ID: "P-001", Nama: "Buku Go", Harga: 95000},
		"P-002": {ID: "P-002", Nama: "Mouse Kantor", Harga: 150000},
	}}
}

func (r *RepoMemori) Cari(id string) (Produk, error) {
	p, ok := r.data[id]
	if !ok {
		return Produk{}, fmt.Errorf("produk %s tidak ditemukan", id)
	}
	return p, nil
}

// ===== Service: logika bisnis (diskon member) =====

type LayananProduk struct {
	repo RepoProduk
}

func NewLayananProduk(repo RepoProduk) *LayananProduk {
	return &LayananProduk{repo: repo}
}

func (s *LayananProduk) HargaFinal(id string, member bool) (int, error) {
	p, err := s.repo.Cari(id)
	if err != nil {
		return 0, err
	}
	if member && p.Harga >= 100000 {
		return p.Harga - p.Harga/10, nil
	}
	return p.Harga, nil
}

// ===== Presentation: handler hanya membaca input dan memformat =====

type Permintaan struct {
	ID     string
	Member bool
}

type HandlerProduk struct {
	layanan *LayananProduk
}

func (h *HandlerProduk) Tangani(req Permintaan) string {
	harga, err := h.layanan.HargaFinal(req.ID, req.Member)
	if err != nil {
		return fmt.Sprintf("HTTP 404 — %v", err)
	}
	return fmt.Sprintf(`{"id":%q,"harga":%d}`, req.ID, harga)
}

func main() {
	repo := NewRepoMemori()
	handler := &HandlerProduk{layanan: NewLayananProduk(repo)}

	for _, req := range []Permintaan{
		{ID: "P-001", Member: true},
		{ID: "P-002", Member: true},
		{ID: "P-999", Member: false},
	} {
		fmt.Println(handler.Tangani(req))
	}
}