Monolit modular — dan kenapa biasanya bukan microservice
Batas modul yang jelas adalah tujuan yang benar. Microservice adalah salah satu cara mencapainya — dan hampir selalu bukan cara termurah, apalagi untuk tim yang belum punya batas yang jelas di dalam satu proses.
Intisari
- Ambil batas modulnya dulu di dalam satu binari; memisahkan proses bisa menyusul kalau memang perlu.
- Go menegakkan sebagian batas secara gratis:
internal/dan larangan impor melingkar. - Uji arsitektur mengubah aturan "modul A tidak boleh mengimpor B" jadi kegagalan CI.
- Microservice menukar pemanggilan fungsi yang pasti berhasil dengan panggilan jaringan yang bisa gagal — dan transaksi lintas modul hilang.
- Pisahkan layanan saat ada alasan operasional: profil skala berbeda, batas kepatuhan, atau tim yang benar-benar terpisah.
Bentuk monolit modular
toko/
├── cmd/
│ ├── server/ # satu binari HTTP
│ └── worker/ # satu binari worker
└── internal/
├── platform/ # db, redis, otel, logger — dipakai semua modul
├── katalog/ # ← modul
│ ├── katalog.go tipe domain + aturan
│ ├── layanan.go API PUBLIK modul ini
│ ├── penyimpan.go interface yang dibutuhkannya
│ ├── postgres.go implementasi
│ └── internal/ ← benar-benar privat, bahkan untuk modul lain
├── pesanan/
├── pembayaran/
└── http/ # transport: merakit rute dari semua modul
internal/ bersarang adalah alat yang kurang dikenal.
internal/katalog/internal/harga hanya bisa diimpor dari dalam
internal/katalog/ — modul pesanan tidak bisa menyentuhnya sama sekali, dan
compiler yang menegakkannya. Ini memberi enkapsulasi sungguhan di tingkat modul, tanpa memisahkan
proses.
Aturan antar modul
// ✅ Modul berkomunikasi lewat API publiknya saja.
// internal/pesanan/layanan.go
type Katalog interface {
Ambil(ctx context.Context, id int64) (katalog.Produk, error)
}
// ❌ JANGAN: pesanan menyentuh tabel milik katalog
// SELECT * FROM produk WHERE ... ← batas modul dilanggar diam-diam
| Aturan | Ditegakkan oleh |
|---|---|
Modul tidak mengimpor internal/ modul lain | Compiler |
| Tidak ada impor melingkar | Compiler |
Domain tidak mengimpor net/http/database/sql | depguard (Fase 8) |
| Modul tidak membaca tabel modul lain | Review + konvensi penamaan tabel |
Modul hanya dipanggil lewat Layanan | Uji arsitektur |
// Uji arsitektur: aturan jadi tes yang gagal, bukan dokumen yang dilanggar.
func TestBatasModul(t *testing.T) {
larangan := map[string][]string{
"internal/katalog": {"internal/pesanan", "internal/pembayaran"},
"internal/pembayaran": {"internal/katalog"},
}
for modul, terlarang := range larangan {
out, err := exec.Command("go", "list", "-deps", "./"+modul+"/...").Output()
if err != nil {
t.Fatal(err)
}
for _, t2 := range terlarang {
if strings.Contains(string(out), t2) {
t.Errorf("%s mengimpor %s — batas modul dilanggar", modul, t2)
}
}
}
}
Ongkos microservice yang jarang dihitung
| Di monolit | Di microservice |
|---|---|
| Panggilan fungsi — pasti berhasil | Panggilan jaringan — bisa timeout, bisa gagal separuh |
| Transaksi database lintas modul | Tidak ada. Butuh saga, kompensasi, konsistensi akhir |
| Refactor lintas modul: satu commit | Beberapa repo, beberapa deploy, terurut |
| Debug: satu stack trace | Trace terdistribusi, wajib |
Lingkungan lokal: go run | Docker Compose dengan banyak layanan |
| Satu pipeline deploy | Satu per layanan; versi harus kompatibel |
| Ubah kontrak: compiler menolak | Ubah kontrak: gagal saat runtime, di produksi |
Baris kedua adalah yang paling mahal dan paling sering diremehkan. "Buat pesanan lalu kurangi stok" adalah satu transaksi di monolit — atomik, gratis. Setelah dipisah jadi dua layanan, ia butuh saga dengan langkah kompensasi, penanganan kegagalan parsial, dan cara memperbaiki data saat kompensasi itu sendiri gagal. Semua itu kode yang harus ditulis, diuji, dan dirawat — untuk mendapatkan hasil yang sebelumnya sudah benar.
Kapan memisahkan benar-benar tepat
| Alasan | Sah? |
|---|---|
| Profil skala berbeda ekstrem (pemrosesan video vs API) | ✅ Ya |
| Batas kepatuhan (data kartu terisolasi) | ✅ Ya |
| Tim benar-benar terpisah dengan siklus rilis sendiri | ✅ Ya |
| Kebutuhan teknologi berbeda (ML dengan Python) | ✅ Ya |
| Satu bagian butuh isolasi kegagalan yang kuat | ✅ Ya |
| "Kodenya sudah terlalu besar" | ❌ Perbaiki batas modul dulu |
| "Supaya lebih mudah diskalakan" | ❌ Tambah task lebih murah (Fase 9) |
| "Karena perusahaan besar melakukannya" | ❌ Mereka punya tim platform |
Kabar baiknya: monolit modular yang benar membuat pemisahan jadi murah kalau nanti benar-benar
dibutuhkan. Kalau modul pembayaran hanya dipanggil lewat interface Layanan
dan tidak menyentuh tabel modul lain, memindahkannya ke proses sendiri berarti mengganti implementasi
interface itu dengan klien HTTP atau gRPC — bukan menulis ulang aplikasi. Urutannya: batas dulu,
proses belakangan.
Satu binari, beberapa peran
Satu image Docker, tiga service ECS (Fase 10):
toko-server command: ["/server"] 4 task, di belakang ALB
toko-worker command: ["/worker"] 2 task, tanpa ALB
toko-cron command: ["/cron"] 1 task
Yang didapat tanpa memisahkan basis kode:
• skala per peran secara independen
• worker yang mati tidak menjatuhkan API
• satu versi, satu commit, satu pipeline
Latihan: pilih dua modul di aplikasimu dan tulis uji arsitektur yang melarang salah satunya
mengimpor yang lain. Jalankan dan lihat apakah kodemu sudah sebersih yang kamu kira. Lalu pindahkan satu
kelompok tipe yang benar-benar privat ke internal/<modul>/internal/ dan buktikan modul
lain tidak bisa mengimpornya.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.