Lapisan & arah ketergantungan
Go dirancang untuk basis kode besar yang dikerjakan banyak orang selama bertahun-tahun. Materi ini tentang menyusun aplikasi web supaya sifat itu benar-benar terasa: setiap potongan punya tempat, dan panah ketergantungannya cuma menunjuk satu arah.
Intisari
- Tiga lapisan cukup: transport (HTTP), domain (aturan bisnis), infrastruktur (database, API luar).
- Panahnya selalu menunjuk ke domain. Paket domain tidak boleh mengimpor
net/httpmaupun paket database. - Interface dideklarasikan di domain (yang memakainya); implementasinya di infrastruktur.
- Kelompokkan paket per fitur (
produk,pesanan), bukan per jenis teknis (models,services). - Compiler menegakkan sebagian besar aturan ini lewat larangan impor melingkar dan
internal/.
Arah panah
cmd/server/main.go โ merakit semuanya (composition root)
โ
โผ
internal/http โ transport: parse, validasi bentuk, tulis respons
โ tahu tentang domain
โผ
internal/produk โ DOMAIN: tipe, aturan, interface penyimpanan
โฒ TIDAK tahu tentang HTTP maupun SQL
โ
internal/produk/postgres โ infrastruktur: memenuhi interface domain
Uji satu kalimat yang menentukan apakah lapisanmu benar: buka setiap berkas di paket domain dan
lihat blok import-nya. Kalau ada net/http, database/sql, atau
encoding/json di sana, lapisannya sudah bocor. Domain seharusnya hanya mengimpor pustaka
standar yang netral (context, time, errors) dan paket domain lain.
Per fitur, bukan per jenis
| โ Per jenis teknis | โ Per fitur |
|---|---|
internal/models/ | internal/produk/ |
internal/services/ | internal/pesanan/ |
internal/repositories/ | internal/pembayaran/ |
internal/handlers/ | internal/http/ |
Susunan per jenis membuat satu perubahan fitur menyentuh empat folder yang berjauhan, dan membuat semua paket saling mengimpor sampai batasnya tidak berarti apa-apa. Susunan per fitur menaruh semua yang berubah bersamaan di tempat yang sama โ dan membuat "hapus fitur ini" berarti "hapus folder ini".
internal/produk/
โโโ produk.go # tipe domain + aturan + error sentinel
โโโ layanan.go # orkestrasi: cache, transaksi, kebijakan
โโโ penyimpan.go # INTERFACE yang dibutuhkan layanan
โโโ postgres.go # implementasi interface itu
โโโ layanan_test.go
Isi tiap lapisan
Domain โ apa yang benar, terlepas dari teknologi
// internal/produk/produk.go
package produk
type Produk struct {
ID int64
Nama string
Harga Rupiah
Stok int
}
var (
ErrTidakDitemukan = errors.New("produk tidak ditemukan")
ErrStokKurang = errors.New("stok tidak cukup")
)
// Aturan bisnis tinggal di tipe domainnya sendiri โ bukan di handler,
// bukan di SQL. Ini yang bisa diuji tanpa satu pun dependensi.
func (p *Produk) Ambil(jumlah int) error {
switch {
case jumlah <= 0:
return fmt.Errorf("jumlah harus positif: %w", ErrValidasi)
case jumlah > p.Stok:
return fmt.Errorf("sisa %d: %w", p.Stok, ErrStokKurang)
}
p.Stok -= jumlah
return nil
}
Interface: dideklarasikan oleh yang memakai
// internal/produk/penyimpan.go
package produk
// Hanya method yang BENAR-BENAR dipakai layanan ini. Bukan cerminan
// seluruh API repository.
type Penyimpan interface {
Ambil(ctx context.Context, id int64) (Produk, error)
Simpan(ctx context.Context, p Produk) error
DalamTx(ctx context.Context, f func(Penyimpan) error) error
}
Layanan: merangkai, tapi tidak tahu HTTP
// internal/produk/layanan.go
type Layanan struct {
simpan Penyimpan
cache Cache
log *slog.Logger
}
func NewLayanan(s Penyimpan, c Cache, log *slog.Logger) *Layanan {
return &Layanan{simpan: s, cache: c, log: log}
}
func (l *Layanan) Beli(ctx context.Context, id int64, jumlah int) error {
return l.simpan.DalamTx(ctx, func(s Penyimpan) error {
p, err := s.Ambil(ctx, id)
if err != nil {
return err
}
if err := p.Ambil(jumlah); err != nil { // aturan domain
return err
}
return s.Simpan(ctx, p)
})
}
Transport: hanya menerjemahkan
// internal/http/produk.go
func (h *Handler) Beli(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
h.tulisError(w, r, ErrIDTidakValid)
return
}
var req beliReq
if err := h.bacaJSON(w, r, &req); err != nil {
h.tulisError(w, r, err)
return
}
if err := h.produk.Beli(r.Context(), id, req.Jumlah); err != nil {
h.tulisError(w, r, err) // errors.Is memetakan ke status (Fase 1)
return
}
w.WriteHeader(http.StatusNoContent)
}
Handler yang sehat panjangnya belasan baris dan isinya cuma empat hal: baca input, panggil
domain, petakan error, tulis respons. Begitu ada if yang menyangkut aturan bisnis di dalam
handler, aturan itu jadi tidak bisa dipakai ulang oleh worker maupun perintah CLI โ dan tesnya butuh
server HTTP untuk hal yang sebenarnya cuma aritmetika.
Berapa lapisan yang benar-benar perlu
| Ukuran aplikasi | Susunan yang masuk akal |
|---|---|
| Beberapa endpoint, satu tabel | Handler langsung memanggil repository. Tidak perlu lapisan layanan. |
| Beberapa domain, transaksi lintas tabel | Tiga lapisan seperti di atas |
| Banyak tim, banyak modul | Tiga lapisan + batas modul yang ditegakkan uji arsitektur (Fase 11) |
Godaan yang perlu ditahan: membangun lapisan repository plus service plus interface untuk aplikasi yang belum punya pengguna. Lapisan yang tidak menahan perubahan apa pun cuma menambah berkas. Tambahkan lapisan saat kamu sudah bisa menyebutkan perubahan konkret yang ia permudah.
Yang menandakan lapisannya sudah bocor
| Gejala | Yang sebenarnya terjadi |
|---|---|
Paket domain mengimpor net/http | Status HTTP jadi konsep domain โ worker tidak bisa memakainya |
Struct domain punya tag json dan db | Skema database dan kontrak API terkunci jadi satu (Fase 1) |
| Handler menulis SQL | Aturan tersebar; tidak ada satu tempat yang bisa dipercaya |
| Repository memutuskan aturan bisnis | Aturan yang sama diulang di beberapa kueri |
Semua paket mengimpor paket common | common jadi tempat sampah; tak ada lagi batas |
| Butuh database untuk menguji perhitungan | Logika berada di lapisan yang salah |
Menegakkannya
# Cek cepat: apa yang diimpor paket domain?
go list -deps ./internal/produk | grep -E 'net/http|database/sql'
# hasil yang benar: kosong
Untuk aturan yang lebih kaya, golangci-lint punya linter depguard yang bisa
melarang impor tertentu per paket โ dan kegagalannya muncul di CI, bukan di review. Dibahas di Fase 8.
Latihan: ambil satu endpoint di aplikasimu dan pindahkan seluruh aturan bisnisnya keluar dari handler ke method pada tipe domain. Lalu tulis tes untuk aturan itu tanpa menyentuh HTTP maupun database. Kalau tesnya masih butuh salah satunya, pemindahannya belum selesai.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.