โ† Semua pembelajaran / Go Nol โ†’ Enterprise
Fase 5 ยท Arsitektur Aplikasi

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.

Sumber asli go.dev Resmi Rangkuman ~9 menit baca

Intisari

  • Tiga lapisan cukup: transport (HTTP), domain (aturan bisnis), infrastruktur (database, API luar).
  • Panahnya selalu menunjuk ke domain. Paket domain tidak boleh mengimpor net/http maupun 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 aplikasiSusunan yang masuk akal
Beberapa endpoint, satu tabelHandler langsung memanggil repository. Tidak perlu lapisan layanan.
Beberapa domain, transaksi lintas tabelTiga lapisan seperti di atas
Banyak tim, banyak modulTiga 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

GejalaYang sebenarnya terjadi
Paket domain mengimpor net/httpStatus HTTP jadi konsep domain โ€” worker tidak bisa memakainya
Struct domain punya tag json dan dbSkema database dan kontrak API terkunci jadi satu (Fase 1)
Handler menulis SQLAturan tersebar; tidak ada satu tempat yang bisa dipercaya
Repository memutuskan aturan bisnisAturan yang sama diulang di beberapa kueri
Semua paket mengimpor paket commoncommon jadi tempat sampah; tak ada lagi batas
Butuh database untuk menguji perhitunganLogika 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.