Aplikasi berlapis yang terus bertambah fitur pelan-pelan menjadi satu tumpukan besar: ratusan file di package yang sama, semua saling mengenal. Modular monolith menjawab situasi ini tanpa melompat ke microservice — aplikasi tetap satu binary dengan satu database, tetapi dibagi menjadi module yang batasnya tegas. Go punya alat bawaan untuk pola ini: package, direktori internal/, dan interface sebagai kontrak antar modul. Pada chapter ini kita membangun dua modul dalam satu binary, menetapkan kontrak di antara keduanya, lalu menimbang kapan modular monolith lebih tepat daripada microservice.
Modul adalah Package dengan Batas #
Dalam Go, modul aplikasi diwujudkan sebagai package di bawah internal/. Kata kunci internal memberlakukan aturan compiler: package di dalamnya hanya bisa diimpor oleh kode pada pohon direktori yang sama, sehingga batas modul tidak bisa dilewati dari luar aplikasi. Struktur proyek yang sehat terlihat seperti ini:
aplikasi-toko/
├── cmd/
│ └── server/main.go # composition root: satu-satunya perakit
├── internal/
│ ├── pesanan/ # modul pesanan
│ │ ├── service.go # logika pesanan
│ │ └── gerbang.go # kontrak stok yang dibutuhkan
│ ├── inventaris/ # modul inventaris
│ │ ├── service.go
│ │ └── repo.go # tabel stok milik modul ini
│ └── pembayaran/
└── go.mod # satu module Go untuk seluruh aplikasiAturan mainnya dua: data dan tabel milik satu modul, serta modul lain tidak boleh menyentuh keduanya secara langsung. Modul pesanan tidak membaca tabel stok dengan query sendiri; modul pesanan memanggil API publik modul inventaris. Bila skema tabel stok berubah, hanya modul inventaris yang menyentuh kode.
Kontrak Antar Modul #
Kontrak antar modul dinyatakan sebagai interface yang didefinisikan di sisi pemakai — sama seperti pola consumer-side pada chapter Dependency Injection , tetapi kini batasnya setinggi package. Modul pesanan hanya mendeklarasikan method yang dibutuhkannya; modul inventaris mengimplementasikannya tanpa mengimpor modul pesanan. Diagram berikut memperlihatkan arah hubungan yang sehat:
Tidak ada panah dari modul inventaris ke modul pesanan. Ketergantungan hanya terjadi lewat kontrak kecil yang bisa ditepati implementasi apa pun — termasuk fake saat pengujian.
Demo: Dua Modul dalam Satu Binary #
Berkas modulith-demo.go menyusun dua modul tersebut dalam satu program. Komentar pembatas menandai batas yang pada proyek nyata berupa direktori internal/pesanan dan internal/inventaris:
package main
import (
"fmt"
"maps"
"slices"
"strings"
)
// ===== MODULE inventaris (seolah-olah internal/inventaris) =====
// Hanya API publik modul ini yang boleh dipakai modul lain.
type LayananInventaris struct {
stok map[string]int
}
func NewLayananInventaris() *LayananInventaris {
return &LayananInventaris{stok: map[string]int{
"Buku Go": 5,
"Mouse": 2,
}}
}
func (l *LayananInventaris) AmbilStok(nama string, qty int) error {
sisa, ok := l.stok[nama]
if !ok {
return fmt.Errorf("produk %q tidak dikenal", nama)
}
if qty > sisa {
return fmt.Errorf("stok %q tersisa %d, diminta %d", nama, sisa, qty)
}
l.stok[nama] = sisa - qty
return nil
}
func (l *LayananInventaris) Laporan() string {
bagian := make([]string, 0, len(l.stok))
for _, nama := range slices.Sorted(maps.Keys(l.stok)) {
bagian = append(bagian, fmt.Sprintf("%s=%d", nama, l.stok[nama]))
}
return strings.Join(bagian, ", ")
}
// ===== MODULE pesanan (seolah-olah internal/pesanan) =====
// Modul pesanan tidak mengakses data inventaris secara langsung.
// Modul ini hanya mendefinisikan gerbang (interface) yang dibutuhkannya.
type GerbangStok interface {
AmbilStok(nama string, qty int) error
}
type LayananPesanan struct {
gerbang GerbangStok
}
func (l *LayananPesanan) Buat(nomor, produk string, qty int) string {
if err := l.gerbang.AmbilStok(produk, qty); err != nil {
return fmt.Sprintf("%s DITOLAK: %v", nomor, err)
}
return fmt.Sprintf("%s OK: %s x%d", nomor, produk, qty)
}
// ===== main: satu binary, dua modul berinteraksi lewat interface =====
func main() {
inventaris := NewLayananInventaris()
pesanan := &LayananPesanan{gerbang: inventaris}
fmt.Println(pesanan.Buat("PSN-1", "Buku Go", 2))
fmt.Println(pesanan.Buat("PSN-2", "Mouse", 5))
fmt.Println(pesanan.Buat("PSN-3", "Buku Go", 4))
fmt.Println("stok akhir:", inventaris.Laporan())
}Jalankan program dengan perintah berikut dari direktori chapter:
go run modulith-demo.goOutput yang Dihasilkan:
PSN-1 OK: Buku Go x2
PSN-2 DITOLAK: stok "Mouse" tersisa 2, diminta 5
PSN-3 DITOLAK: stok "Buku Go" tersisa 3, diminta 4
stok akhir: Buku Go=3, Mouse=2Mari kita telaah bagian-bagian penting program di atas:
- Baris 24-34 (
func (l *LayananInventaris) AmbilStok ...): API publik modul inventaris. Seluruh pengecekan produk tidak dikenal dan stok kurang terjadi di dalam modul pemilik data, bukan di modul pemakai. - Baris 36-42 (
func (l *LayananInventaris) Laporan ...): pencetakan stok diurutkan lewatslices.Sorted(maps.Keys(...))karena iterasi map Go berurutan acak; tanpa pengurutan, output laporan tidak deterministik. - Baris 48-50 (
type GerbangStok interface ...): kontrak milik modul pesanan. Modul ini tidak mengimpor apa pun dari inventaris; panah ketergantungan tetap satu arah. - Baris 56-61 (
func (l *LayananPesanan) Buat ...): modul pesanan memakai gerbang tanpa tahu di baliknya ada map, PostgreSQL, atau service jarak jauh.Buatbisa diuji dengan gerbang palsu satu baris. - Baris 65-67 (
func main ...): composition root menyambungkan modul:inventarisdisuntikkan kepesananlewat field bertipe interface. Pada struktur nyata, baris ini hidup dicmd/server/main.go.
Modular Monolith vs Microservice #
Perbandingan berikut membantu menempatkan modular monolith secara jujur:
| Aspek | Modular monolith | Microservice |
|---|---|---|
| Deployment | Satu binary, satu jadwal rilis | Tiap service rilis sendiri |
| Transaksi lintas modul | Satu database, transaksi biasa | Saga atau konsistensi akhir |
| Komunikasi | Panggilan lewat interface (in-process) | HTTP, gRPC, atau queue |
| Skalabilitas | Seluruh aplikasi bersama-sama | Per service sesuai beban |
| Mulai proyek | Cepat, satu go.mod | Lambat, banyak infrastruktur |
| Jalur evolusi | Modul bisa diekstrak jadi service | Sudah terpisah sejak awal |
Perhatikan baris terakhir: modular monolith bukan lawan microservice, melainkan jalur menuju sana. Modul dengan kontrak interface yang bersih adalah calon service yang sudah terbungkus rapi; ekstraksi menjadi binary terpisah tinggal mengganti pemanggilan fungsi dengan panggilan jaringan.
Kontrak interface menjaga arah panggilan, tetapi ada pola lain yang melepas arah sama sekali: penerbit event tidak perlu tahu siapa pemrosesnya. Chapter berikutnya membahas event-driven design dengan bus in-memory.
Referensi #
- Go modules reference: go.work — workspace multi-module untuk modul terpisah.
- Effective Go — package, visibilitas, dan komposisi sebagai fondasi modul.
- Use cases for Go — pola aplikasi besar yang umum dibangun dengan Go.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
modulith-demo.go
Unduh berkaspackage main
import (
"fmt"
"maps"
"slices"
"strings"
)
// ===== MODULE inventaris (seolah-olah internal/inventaris) =====
// Hanya API publik modul ini yang boleh dipakai modul lain.
type LayananInventaris struct {
stok map[string]int
}
func NewLayananInventaris() *LayananInventaris {
return &LayananInventaris{stok: map[string]int{
"Buku Go": 5,
"Mouse": 2,
}}
}
func (l *LayananInventaris) AmbilStok(nama string, qty int) error {
sisa, ok := l.stok[nama]
if !ok {
return fmt.Errorf("produk %q tidak dikenal", nama)
}
if qty > sisa {
return fmt.Errorf("stok %q tersisa %d, diminta %d", nama, sisa, qty)
}
l.stok[nama] = sisa - qty
return nil
}
func (l *LayananInventaris) Laporan() string {
bagian := make([]string, 0, len(l.stok))
for _, nama := range slices.Sorted(maps.Keys(l.stok)) {
bagian = append(bagian, fmt.Sprintf("%s=%d", nama, l.stok[nama]))
}
return strings.Join(bagian, ", ")
}
// ===== MODULE pesanan (seolah-olah internal/pesanan) =====
// Modul pesanan tidak mengakses data inventaris secara langsung.
// Modul ini hanya mendefinisikan gerbang (interface) yang dibutuhkannya.
type GerbangStok interface {
AmbilStok(nama string, qty int) error
}
type LayananPesanan struct {
gerbang GerbangStok
}
func (l *LayananPesanan) Buat(nomor, produk string, qty int) string {
if err := l.gerbang.AmbilStok(produk, qty); err != nil {
return fmt.Sprintf("%s DITOLAK: %v", nomor, err)
}
return fmt.Sprintf("%s OK: %s x%d", nomor, produk, qty)
}
// ===== main: satu binary, dua modul berinteraksi lewat interface =====
func main() {
inventaris := NewLayananInventaris()
pesanan := &LayananPesanan{gerbang: inventaris}
fmt.Println(pesanan.Buat("PSN-1", "Buku Go", 2))
fmt.Println(pesanan.Buat("PSN-2", "Mouse", 5))
fmt.Println(pesanan.Buat("PSN-3", "Buku Go", 4))
fmt.Println("stok akhir:", inventaris.Laporan())
}