Setelah tanggung jawab terpisah, kita butuh cara baku untuk menyusun lapisan-lapisan tersebut menjadi struktur proyek yang bisa dipahami satu tim. Layered architecture menjawab kebutuhan itu dengan aturan sederhana: kode disusun berlapis, request mengalir ke bawah, response dan error mengalir ke atas. Pada chapter ini kita membangun alur lengkap handler → use case → repository, melihat mengapa interface repository justru didefinisikan di lapisan service, lalu membahas kelebihan dan biaya pola berlapis, termasuk kasus transaksi yang melintasi beberapa service.

Anatomi Lapisan dan Arah Alur #

Bentuk paling umum di aplikasi Go memakai empat lapisan: handler (presentation), use case atau service (application), domain, dan repository (data). Diagram berikut memperlihatkan perjalanan satu request dan balasannya:

Garis penuh menunjukkan arah request yang turun, garis putus-putus menunjukkan data, hasil, dan error yang naik. Lapisan domain tidak muncul sebagai kotak terpisah pada diagram karena lapisan itu berupa tipe data yang dipakai service, bukan proses yang menerima panggilan.

Aturan Satu Arah #

Aturan intinya satu kalimat: sebuah lapisan hanya boleh memanggil lapisan di bawahnya. Handler boleh memanggil service, service boleh memanggil repository, tetapi repository tidak boleh memanggil service dan handler tidak boleh menyentuh repository. Pelanggaran paling umum adalah service yang mengimpor package handler untuk memformat output; format adalah tanggung jawab handler, bukan service.

go
// dilarang: repository tidak boleh tahu bagaimana hasilnya ditampilkan
func (r *RepoItem) Simpan(item Item) (string, error) {
	return fmt.Sprintf("item %s tersimpan", item.Nama), nil // tanggung jawab handler
}

Fragmen di atas melanggar arah alur karena repository ikut merakit teks yang ditampilkan ke pengguna. Cukup kembalikan error dan biarkan handler yang memutuskan bentuk tampilannya.

Sebelum commit pada pola ini, mari kita timbang biayanya secara jujur:

AspekKelebihanKekurangan
KeterbacaanAlur request bisa dilacak dari atas ke bawahFile dan package bertambah banyak
PengujianTiap lapisan bisa diuji dengan dependency palsuTest boilerplate untuk lapisan tipis
PerubahanGanti database atau format output tanpa sentuhan silangLapisan tambahan untuk logika sederhana terasa berlebihan
TimBatas kepemilikan kode jelas antar anggotaRapat koordinasi interface lebih sering

Demo: Perjalanan Satu Request #

Berkas layer-demo.go menerapkan alur lengkap dalam satu program. Setiap lapisan dicatat dengan komentar pembatas agar batasnya terlihat, dan handler mencetak jejak di awal serta akhir sehingga kita bisa membaca urutan eksekusinya:

go
package main

import (
	"errors"
	"fmt"
	"strings"
)

// ===== Domain: tipe data inti =====

type Item struct {
	Nama  string
	Harga int
}

// ===== Lapisan Service / Use Case =====
// Interface repository didefinisikan DI SINI (dependency inversion):
// service menentukan kontrak penyimpanan yang dibutuhkannya.

type RepoItem interface {
	Simpan(item Item) error
}

var ErrItemKosong = errors.New("nama item tidak boleh kosong")

type UseCasePesan struct {
	repo RepoItem
}

func NewUseCasePesan(repo RepoItem) *UseCasePesan {
	return &UseCasePesan{repo: repo}
}

func (u *UseCasePesan) BuatPesan(nama string, harga int) (Item, error) {
	if strings.TrimSpace(nama) == "" {
		return Item{}, ErrItemKosong
	}
	item := Item{Nama: nama, Harga: harga}
	if err := u.repo.Simpan(item); err != nil {
		return Item{}, fmt.Errorf("gagal menyimpan pesanan: %w", err)
	}
	return item, nil
}

// ===== Lapisan Data: adapter penyimpanan =====

type RepoMemori struct {
	items []Item
}

func (r *RepoMemori) Simpan(item Item) error {
	r.items = append(r.items, item)
	return nil
}

// ===== Lapisan Presentation: handler =====

type HandlerPesan struct {
	useCase *UseCasePesan
}

func (h *HandlerPesan) Buat(nama string, harga int) string {
	fmt.Printf("[handler] menerima permintaan pesanan %q\n", nama)
	item, err := h.useCase.BuatPesan(nama, harga)
	if err != nil {
		fmt.Printf("[handler] error naik ke atas: %v\n", err)
		return "400 Bad Request"
	}
	fmt.Printf("[handler] merakit response 201 untuk item %q\n", item.Nama)
	return "201 Created"
}

func main() {
	repo := &RepoMemori{}
	handler := &HandlerPesan{useCase: NewUseCasePesan(repo)}

	fmt.Println(handler.Buat("Buku Go", 95000))
	fmt.Println(handler.Buat("", 0))
	fmt.Println(handler.Buat("Mouse Kantor", 150000))
	fmt.Printf("isi repository: %d item\n", len(repo.items))
}

Jalankan program dengan perintah berikut dari direktori chapter:

bash
go run layer-demo.go

Output yang Dihasilkan:

text
[handler] menerima permintaan pesanan "Buku Go"
[handler] merakit response 201 untuk item "Buku Go"
201 Created
[handler] menerima permintaan pesanan ""
[handler] error naik ke atas: nama item tidak boleh kosong
400 Bad Request
[handler] menerima permintaan pesanan "Mouse Kantor"
[handler] merakit response 201 untuk item "Mouse Kantor"
201 Created
isi repository: 2 item

Mari kita telaah bagian-bagian penting program di atas:

  1. Baris 20-22 (type RepoItem interface ...): kontrak penyimpanan dideklarasikan di lapisan service, bukan di lapisan data. Inilah dependency inversion: lapisan bawah (adapter RepoMemori) yang menyesuaikan diri ke kontrak lapisan atas.
  2. Baris 24 (var ErrItemKosong ...): sentinel error milik service. Handler memakai nilai error untuk memilih status 400 tanpa perlu tahu detail validasinya.
  3. Baris 34-43 (func (u *UseCasePesan) BuatPesan ...): use case memvalidasi input, membentuk Item, lalu mendelegasikan penyimpanan ke u.repo. Error dari repository dibungkus dengan %w agar rantai penyebab tetap utuh.
  4. Baris 62-71 (func (h *HandlerPesan) Buat ...): handler mencatat jejak saat menerima permintaan dan saat membalas. Baris 66 memperlihatkan jalur error yang naik dari service dan diterjemahkan menjadi 400 Bad Request.
  5. Baris 77-80 (fmt.Println(handler.Buat(...))): tiga permintaan berurutan. Permintaan kedua gagal validasi di service sehingga tidak pernah menyentuh repository — itulah sebabnya repository hanya berisi 2 item.

Transaksi Lintas Service #

Kadang satu operasi bisnis menyentuh beberapa service sekaligus: membuat pesanan sekaligus memotong stok dan mencatat pembayaran. Aturan satu arah tetap berlaku, tetapi transaksi database tidak boleh tersebar di banyak service karena BEGIN dan COMMIT harus berada di satu tempat. Solusinya: satu use case menjadi pemilik transaksi, membuka *sql.Tx lewat db.BeginTx, lalu mengoper objek transaksi tersebut ke repository yang ikut terlibat. Kita sudah mempraktikkan pola ini pada chapter Transaction ; pada arsitektur, poin pentingnya adalah transaction boundary selalu berada di use case, bukan di handler atau repository.

Layered architecture memberi struktur yang bisa dipahami tim dengan cepat. Namun ketika kita ingin aturan dependensi yang lebih tegas lagi — semua kode hanya boleh menunjuk ke domain murni — kita memasuki wilayah Clean Architecture pada chapter berikutnya.

Referensi #

  • Effective Go — konvensi penamaan dan penataan package yang menjadi fondasi lapisan idiomatik.
  • Use cases for Go — gambaran umum pola aplikasi yang umum dibangun dengan Go.
  • Errors are values — penanganan error yang mengalir naik antar lapisan.

Berkas Sumber

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

layer-demo.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
	"strings"
)

// ===== Domain: tipe data inti =====

type Item struct {
	Nama  string
	Harga int
}

// ===== Lapisan Service / Use Case =====
// Interface repository didefinisikan DI SINI (dependency inversion):
// service menentukan kontrak penyimpanan yang dibutuhkannya.

type RepoItem interface {
	Simpan(item Item) error
}

var ErrItemKosong = errors.New("nama item tidak boleh kosong")

type UseCasePesan struct {
	repo RepoItem
}

func NewUseCasePesan(repo RepoItem) *UseCasePesan {
	return &UseCasePesan{repo: repo}
}

func (u *UseCasePesan) BuatPesan(nama string, harga int) (Item, error) {
	if strings.TrimSpace(nama) == "" {
		return Item{}, ErrItemKosong
	}
	item := Item{Nama: nama, Harga: harga}
	if err := u.repo.Simpan(item); err != nil {
		return Item{}, fmt.Errorf("gagal menyimpan pesanan: %w", err)
	}
	return item, nil
}

// ===== Lapisan Data: adapter penyimpanan =====

type RepoMemori struct {
	items []Item
}

func (r *RepoMemori) Simpan(item Item) error {
	r.items = append(r.items, item)
	return nil
}

// ===== Lapisan Presentation: handler =====

type HandlerPesan struct {
	useCase *UseCasePesan
}

func (h *HandlerPesan) Buat(nama string, harga int) string {
	fmt.Printf("[handler] menerima permintaan pesanan %q\n", nama)
	item, err := h.useCase.BuatPesan(nama, harga)
	if err != nil {
		fmt.Printf("[handler] error naik ke atas: %v\n", err)
		return "400 Bad Request"
	}
	fmt.Printf("[handler] merakit response 201 untuk item %q\n", item.Nama)
	return "201 Created"
}

func main() {
	repo := &RepoMemori{}
	handler := &HandlerPesan{useCase: NewUseCasePesan(repo)}

	fmt.Println(handler.Buat("Buku Go", 95000))
	fmt.Println(handler.Buat("", 0))
	fmt.Println(handler.Buat("Mouse Kantor", 150000))
	fmt.Printf("isi repository: %d item\n", len(repo.items))
}