Modular Monolith Architecture #
Banyak tim terjebak dalam satu dari dua kesalahan. Kesalahan pertama: membangun monolith tanpa struktur, membiarkan semua modul saling mengimport secara bebas sampai tidak ada yang tahu efek samping dari setiap perubahan — inilah yang disebut big ball of mud. Kesalahan kedua: langsung melompat ke microservices sebelum domain dipahami dengan baik, menghasilkan distributed monolith yang punya semua kerugian distribusi tapi tidak mendapat manfaatnya. Modular Monolith Architecture berada di tengah kedua ekstrem itu: satu deployment, satu binary, satu pipeline — tapi dengan boundary antar modul yang setegas boundary antar microservice. Setiap modul punya domain, service, dan repository-nya sendiri. Modul lain tidak bisa mengakses internal modul secara langsung. Komunikasi terjadi melalui interface atau event. Hasilnya: semua keunggulan operasional monolith, dengan kualitas desain yang memungkinkan evolusi ke microservices kapanpun diperlukan.
Apa yang Membedakan Modular Monolith dari Traditional Monolith? #
Perbedaannya bukan di infrastruktur — keduanya satu deployment. Perbedaannya ada di disiplin boundary:
flowchart TD
subgraph TM["Traditional Monolith — Boundary Tidak Dijaga"]
U1["User code"] -->|"import langsung"| O1["Order code"]
O1 -->|"import langsung"| P1["Payment code"]
P1 -->|"import langsung"| U1
U1 -->|"akses DB order"| DB1[(Satu DB\ntanpa batas)]
O1 -->|"akses DB user"| DB1
end
subgraph MM["Modular Monolith — Boundary Tegas"]
subgraph UM["User Module"]
UH["Handler"] --> US["Service"] --> UR["Repo"]
UDB[(user schema)]
UR --> UDB
end
subgraph OM["Order Module"]
OH["Handler"] --> OS["Service"] --> OR["Repo"]
ODB[(order schema)]
OR --> ODB
end
subgraph PM["Payment Module"]
PH["Handler"] --> PS["Service"] --> PR["Repo"]
PDB[(payment schema)]
PR --> PDB
end
UM -->|"via interface"| OM
OM -->|"via domain event"| PM
end
Tiga pilar yang membuat modular monolith berbeda dari monolith biasa:
| Pilar | Traditional Monolith | Modular Monolith |
|---|---|---|
| Boundary modul | Tidak ada — semua bisa import semua | Tegas — hanya boleh via interface publik |
| Komunikasi antar modul | Import langsung ke internal | Via interface atau domain event |
| Schema database | Satu schema, semua tabel campur | Schema terisolasi per modul, tidak ada cross-module JOIN |
Tiga Pilar Implementasi #
Pilar 1: Setiap Modul Mengekspos Interface, Bukan Struct Konkret #
Modul hanya mengekspos interface yang mendefinisikan apa yang bisa dilakukan, bukan bagaimana ia melakukannya. Modul lain hanya boleh bergantung pada interface tersebut.
// internal/user/api.go — public API modul User
// Ini adalah satu-satunya file yang boleh diimport oleh modul lain
package user
import "context"
// API adalah kontrak publik modul User
// ✓ Modul lain bergantung pada interface ini, bukan implementasinya
type API interface {
GetByID(ctx context.Context, id string) (*UserDTO, error)
ValidateActive(ctx context.Context, id string) error
GetFullName(ctx context.Context, id string) (string, error)
}
// UserDTO adalah DTO yang dibagikan ke modul lain
// ✓ Bukan domain entity internal — hanya data transfer object
type UserDTO struct {
ID string
FullName string
Email string
IsActive bool
}
// Module adalah entry point modul User yang mengekspos API-nya
type Module struct {
api *service // implementasi internal, tidak diekspos
handler *handler
}
func NewModule(db *sql.DB) *Module {
repo := newPostgresRepository(db)
svc := newService(repo)
h := newHandler(svc)
return &Module{api: svc, handler: h}
}
// API mengembalikan implementasi API interface
// ✓ Tipe kembalian adalah interface, bukan *service
func (m *Module) API() API {
return m.api
}
// RegisterRoutes mendaftarkan HTTP routes modul ini
func (m *Module) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /users", m.handler.create)
mux.HandleFunc("GET /users/{id}", m.handler.getByID)
mux.HandleFunc("PUT /users/{id}/deactivate", m.handler.deactivate)
}
// internal/user/service.go — implementasi internal, TIDAK diekspos ke modul lain
package user
import (
"context"
"errors"
"time"
)
// service mengimplementasikan API interface
// Ini adalah detail implementasi internal modul User
type service struct {
repo repository
}
func newService(repo repository) *service {
return &service{repo: repo}
}
// GetByID mengimplementasikan API.GetByID
func (s *service) GetByID(ctx context.Context, id string) (*UserDTO, error) {
u, err := s.repo.findByID(ctx, id)
if err != nil {
return nil, err
}
return &UserDTO{
ID: u.id,
FullName: u.fullName,
Email: u.email,
IsActive: u.isActive,
}, nil
}
// ValidateActive mengimplementasikan API.ValidateActive
func (s *service) ValidateActive(ctx context.Context, id string) error {
u, err := s.repo.findByID(ctx, id)
if err != nil {
return err
}
if !u.isActive {
return errors.New("user tidak aktif")
}
return nil
}
func (s *service) GetFullName(ctx context.Context, id string) (string, error) {
u, err := s.repo.findByID(ctx, id)
if err != nil {
return "", err
}
return u.fullName, nil
}
// user adalah domain entity INTERNAL — tidak diekspos ke luar
type user struct {
id string
fullName string
email string
isActive bool
createdAt time.Time
}
func (u *user) deactivate() error {
if !u.isActive {
return errors.New("user sudah tidak aktif")
}
u.isActive = false
return nil
}
Pilar 2: Komunikasi Antar Modul via Event Bus Internal #
Ketika modul A perlu memberi tahu modul B bahwa sesuatu terjadi tanpa memiliki referensi langsung ke modul B, gunakan event bus internal. Ini adalah bentuk loose coupling yang paling kuat dalam modular monolith.
// internal/shared/eventbus/eventbus.go — shared component
package eventbus
import (
"context"
"fmt"
"sync"
)
// Event adalah interface untuk semua domain event
type Event interface {
EventName() string
}
// Handler adalah fungsi yang menangani event
type Handler func(ctx context.Context, event Event) error
// EventBus adalah in-process event bus untuk komunikasi antar modul
type EventBus struct {
mu sync.RWMutex
handlers map[string][]Handler
}
func New() *EventBus {
return &EventBus{
handlers: make(map[string][]Handler),
}
}
// Subscribe mendaftarkan handler untuk event tertentu
func (b *EventBus) Subscribe(eventName string, handler Handler) {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers[eventName] = append(b.handlers[eventName], handler)
}
// Publish mempublikasikan event ke semua subscriber
// ✓ Publisher tidak tahu siapa subscribernya
func (b *EventBus) Publish(ctx context.Context, event Event) error {
b.mu.RLock()
handlers := b.handlers[event.EventName()]
b.mu.RUnlock()
var errs []error
for _, handler := range handlers {
if err := handler(ctx, event); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("%d handler gagal memproses event %s", len(errs), event.EventName())
}
return nil
}
// internal/order/events.go — domain events yang dipublikasikan oleh Order module
package order
import "time"
// OrderPlacedEvent dipublikasikan ketika order berhasil dibuat
// ✓ Modul lain (notification, inventory) subscribe ke event ini
type OrderPlacedEvent struct {
OrderID string
CustomerID string
TotalCents int64
Items []OrderItemEvent
OccurredAt time.Time
}
type OrderItemEvent struct {
ProductID string
Quantity int
}
func (e OrderPlacedEvent) EventName() string { return "order.placed" }
// OrderCancelledEvent dipublikasikan ketika order dibatalkan
type OrderCancelledEvent struct {
OrderID string
CustomerID string
Reason string
OccurredAt time.Time
}
func (e OrderCancelledEvent) EventName() string { return "order.cancelled" }
// internal/order/service.go — Order module menggunakan User API dan EventBus
package order
import (
"context"
"errors"
"time"
"myapp/internal/shared/eventbus"
"myapp/internal/user" // ✓ hanya import package user untuk tipenya
)
type Service struct {
repo Repository
userAPI user.API // ✓ interface dari modul User, bukan struct konkret
eventBus *eventbus.EventBus
}
func NewService(repo Repository, userAPI user.API, bus *eventbus.EventBus) *Service {
return &Service{repo: repo, userAPI: userAPI, eventBus: bus}
}
type PlaceOrderInput struct {
CustomerID string
Items []PlaceOrderItem
}
type PlaceOrderItem struct {
ProductID string
Quantity int
PriceCents int64
}
func (s *Service) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Order, error) {
// Validasi customer via User module API
if err := s.userAPI.ValidateActive(ctx, input.CustomerID); err != nil {
return nil, errors.New("customer tidak valid: " + err.Error())
}
// Buat order
order := &Order{
ID: generateID(),
CustomerID: input.CustomerID,
Status: StatusPending,
CreatedAt: time.Now(),
}
total := int64(0)
items := make([]OrderItem, len(input.Items))
for i, item := range input.Items {
items[i] = OrderItem{
ProductID: item.ProductID,
Quantity: item.Quantity,
PriceCents: item.PriceCents,
}
total += item.PriceCents * int64(item.Quantity)
}
order.Items = items
order.TotalCents = total
if err := s.repo.Save(ctx, order); err != nil {
return nil, err
}
// Publikasikan event — Notification dan Inventory module akan merespons
// ✓ Order tidak tahu siapa yang subscribe
eventItems := make([]OrderItemEvent, len(items))
for i, item := range items {
eventItems[i] = OrderItemEvent{ProductID: item.ProductID, Quantity: item.Quantity}
}
_ = s.eventBus.Publish(ctx, OrderPlacedEvent{
OrderID: order.ID,
CustomerID: order.CustomerID,
TotalCents: order.TotalCents,
Items: eventItems,
OccurredAt: time.Now(),
})
return order, nil
}
// internal/notification/module.go — Notification module subscribe ke Order events
package notification
import (
"context"
"fmt"
"myapp/internal/order"
"myapp/internal/shared/eventbus"
"myapp/internal/user"
)
type Module struct {
userAPI user.API
bus *eventbus.EventBus
}
func NewModule(userAPI user.API, bus *eventbus.EventBus) *Module {
m := &Module{userAPI: userAPI, bus: bus}
m.registerHandlers()
return m
}
// registerHandlers mendaftarkan semua event handler
// ✓ Notification subscribe ke event dari modul lain tanpa coupling langsung
func (m *Module) registerHandlers() {
m.bus.Subscribe("order.placed", m.handleOrderPlaced)
m.bus.Subscribe("order.cancelled", m.handleOrderCancelled)
}
func (m *Module) handleOrderPlaced(ctx context.Context, event eventbus.Event) error {
e, ok := event.(order.OrderPlacedEvent)
if !ok {
return fmt.Errorf("unexpected event type")
}
// Ambil nama customer dari User module
fullName, err := m.userAPI.GetFullName(ctx, e.CustomerID)
if err != nil {
return err
}
// Kirim notifikasi
fmt.Printf("[Notification] Order %s dibuat oleh %s, total: Rp%.2f\n",
e.OrderID, fullName, float64(e.TotalCents)/100)
return nil
}
func (m *Module) handleOrderCancelled(ctx context.Context, event eventbus.Event) error {
e, ok := event.(order.OrderCancelledEvent)
if !ok {
return fmt.Errorf("unexpected event type")
}
fmt.Printf("[Notification] Order %s dibatalkan: %s\n", e.OrderID, e.Reason)
return nil
}
Pilar 3: Schema Isolation per Modul #
Meskipun menggunakan satu database, setiap modul hanya boleh mengakses tabel dalam schema-nya sendiri. Tidak ada cross-module JOIN.
-- Migration: setiap modul punya schema sendiri
CREATE SCHEMA IF NOT EXISTS user_module;
CREATE SCHEMA IF NOT EXISTS order_module;
CREATE SCHEMA IF NOT EXISTS payment_module;
CREATE SCHEMA IF NOT EXISTS notification_module;
-- Tabel User hanya di user_module schema
CREATE TABLE user_module.users (
id UUID PRIMARY KEY,
full_name VARCHAR(200) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- Tabel Order hanya di order_module schema
CREATE TABLE order_module.orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL, -- reference ke user, tapi TANPA foreign key constraint
status VARCHAR(50) NOT NULL,
total_cents BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- ✗ Tidak ada cross-schema JOIN:
-- SELECT o.*, u.full_name FROM order_module.orders o
-- JOIN user_module.users u ON u.id = o.customer_id ← JANGAN
-- ✓ Data dari modul lain diambil via API call, bukan JOIN
Menegakkan Boundary dengan Go Tooling #
Go menyediakan mekanisme native untuk menegakkan boundary modul:
// internal/order/internal/domain/order.go
// ✓ Sub-package "internal" hanya bisa diakses dalam package order
// Compiler Go mencegah import dari luar
package domain
// Order adalah entity internal modul — tidak bisa diakses dari luar order module
type Order struct {
id string
customerID string
status Status
items []Item
}
// Verifikasi dependency antar modul dengan go build tag
// Buat file: internal/order/check_deps_test.go
//go:build deps_check
package order_test
import (
// ✓ Boleh: import shared package
_ "myapp/internal/shared/eventbus"
// ✓ Boleh: import public API modul lain
_ "myapp/internal/user"
// ✗ Tidak boleh: import internal package modul lain
// _ "myapp/internal/user/internal/domain" ← compiler error
// _ "myapp/internal/payment/service" ← compiler error
)
Atau menggunakan go-cleanarch tool untuk validasi otomatis:
# Install
go install github.com/roblaszczak/go-cleanarch@latest
# Jalankan validasi — gagal jika ada dependency yang melanggar aturan
go-cleanarch -application myapp/internal
# Integrasikan ke CI/CD
# .github/workflows/ci.yml:
# - name: Check architecture
# run: go-cleanarch -application ./internal
Struktur Direktori Lengkap #
myapp/
├── cmd/
│ └── server/
│ └── main.go ← Entry point: wiring semua modul
│
├── internal/ ← Kode internal, tidak bisa diimport dari luar
│ │
│ ├── user/ ← User module
│ │ ├── api.go ← PUBLIC: Module struct, API interface, UserDTO
│ │ ├── handler.go ← INTERNAL: HTTP handler
│ │ ├── service.go ← INTERNAL: business logic
│ │ ├── repository.go ← INTERNAL: data access
│ │ └── internal/ ← SUPER PRIVATE: hanya dalam package user
│ │ └── domain/
│ │ └── user.go ← Domain entity yang tidak diekspos sama sekali
│ │
│ ├── order/ ← Order module
│ │ ├── api.go ← PUBLIC: Module struct, API interface
│ │ ├── events.go ← PUBLIC: Domain events (bisa di-subscribe modul lain)
│ │ ├── handler.go ← INTERNAL
│ │ ├── service.go ← INTERNAL
│ │ └── repository.go ← INTERNAL
│ │
│ ├── payment/ ← Payment module
│ │ ├── api.go
│ │ ├── events.go
│ │ ├── handler.go
│ │ ├── service.go
│ │ └── repository.go
│ │
│ ├── notification/ ← Notification module
│ │ ├── module.go ← Subscribe ke events dari modul lain
│ │ └── sender.go
│ │
│ └── shared/ ← Shared components
│ ├── eventbus/
│ │ └── eventbus.go ← In-process event bus
│ ├── middleware/
│ │ └── auth.go
│ └── response/
│ └── response.go
│
└── migrations/
├── 001_create_user_schema.sql
├── 002_create_order_schema.sql
└── 003_create_payment_schema.sql
Wiring di main.go #
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/lib/pq"
"myapp/internal/notification"
"myapp/internal/order"
"myapp/internal/payment"
"myapp/internal/shared/eventbus"
"myapp/internal/user"
)
func main() {
db, err := sql.Open("postgres", "postgres://localhost/myapp?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Shared event bus — satu untuk seluruh aplikasi
bus := eventbus.New()
// Inisialisasi modul — setiap modul hanya menerima dependency yang ia butuhkan
userModule := user.NewModule(db)
orderModule := order.NewModule(db, userModule.API(), bus)
paymentModule := payment.NewModule(db, orderModule.API(), bus)
notificationModule := notification.NewModule(userModule.API(), bus)
// Semua modul siap — notification sudah subscribe ke events via konstruktor
// Setup router
mux := http.NewServeMux()
userModule.RegisterRoutes(mux)
orderModule.RegisterRoutes(mux)
paymentModule.RegisterRoutes(mux)
// notification tidak punya HTTP routes — hanya event subscriber
_ = notificationModule // tetap dalam scope agar tidak di-GC
log.Println("Modular monolith berjalan di :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
flowchart TD
MAIN["main.go\n(wiring semua modul)"]
UM["User Module\nAPI: GetByID, ValidateActive"]
OM["Order Module\nAPI: GetByID, CancelOrder"]
PM["Payment Module"]
NM["Notification Module"]
BUS["EventBus\n(shared)"]
MAIN -->|"NewModule(db)"| UM
MAIN -->|"NewModule(db, userAPI, bus)"| OM
MAIN -->|"NewModule(db, orderAPI, bus)"| PM
MAIN -->|"NewModule(userAPI, bus)"| NM
OM -->|"userAPI.ValidateActive()"| UM
OM -->|"Publish(OrderPlacedEvent)"| BUS
PM -->|"Publish(PaymentProcessedEvent)"| BUS
BUS -->|"handleOrderPlaced()"| NM
BUS -->|"handlePaymentProcessed()"| NM
Jalur Evolusi ke Microservices #
Salah satu keunggulan terbesar modular monolith yang dirancang dengan benar adalah kemudahan ekstraksi modul menjadi microservice ketika diperlukan:
flowchart TD
subgraph MM["Modular Monolith (sekarang)"]
UM2["User Module"]
OM2["Order Module"]
PM2["Payment Module"]
NM2["Notification Module"]
BUS2["EventBus (in-process)"]
OM2 -->|"via interface"| UM2
OM2 --> BUS2
BUS2 --> NM2
end
subgraph EXTRACT["Ekstrak Payment jadi microservice"]
UM3["User Module\n(masih di monolith)"]
OM3["Order Module\n(masih di monolith)"]
NM3["Notification Module\n(masih di monolith)"]
BUS3["Kafka\n(ganti EventBus)"]
PS["Payment Service\n(microservice baru)"]
OM3 -->|"via HTTP/gRPC"| PS
PS --> BUS3
BUS3 --> NM3
end
MM -->|"1. Isolasi Payment ke service terpisah\n2. Ganti in-process bus ke Kafka\n3. Ganti interface call ke HTTP"| EXTRACT
Langkah ekstraksi yang aman:
1. Verifikasi boundary — pastikan Payment module tidak mengimport modul lain
langsung (hanya via interface/event)
2. Buat Payment microservice dengan API yang identik dengan API interface lama
3. Ganti in-process EventBus dengan Kafka/RabbitMQ untuk event yang melibatkan Payment
4. Ganti interface call ke Payment dengan HTTP/gRPC client
5. Deploy Payment service secara terpisah
6. Hapus Payment module dari monolith
Proses ini bisa dilakukan bertahap tanpa downtime karena interface dan event contract sudah terdefinisi dengan jelas sejak awal.
Anti-Pattern yang Harus Dihindari #
// ✗ Import langsung ke internal modul lain
package order
import "myapp/internal/user/service" // ✗ import internal package user
func (s *Service) PlaceOrder(customerID string) error {
// ✗ mengakses implementasi internal user module
userSvc := service.NewUserService(s.db)
user, _ := userSvc.FindByID(customerID)
_ = user
return nil
}
// ✓ Gunakan API interface yang diekspos modul
package order
func (s *Service) PlaceOrder(ctx context.Context, customerID string) error {
// ✓ hanya menggunakan public API interface
if err := s.userAPI.ValidateActive(ctx, customerID); err != nil {
return err
}
return nil
}
// ✗ Cross-schema database JOIN
// repository.go di order module
func (r *repo) GetOrdersWithUserName(ctx context.Context) ([]OrderWithUser, error) {
// ✗ JOIN ke tabel user_module — melanggar schema isolation
return r.db.QueryContext(ctx, `
SELECT o.id, o.total_cents, u.full_name
FROM order_module.orders o
JOIN user_module.users u ON u.id = o.customer_id
`)
}
// ✓ Ambil data dari modul lain via API, tidak via JOIN
func (s *Service) GetOrdersWithUserName(ctx context.Context) ([]OrderDisplay, error) {
orders, err := s.repo.FindAll(ctx)
if err != nil {
return nil, err
}
result := make([]OrderDisplay, len(orders))
for i, order := range orders {
name, _ := s.userAPI.GetFullName(ctx, order.CustomerID) // ✓ via API
result[i] = OrderDisplay{OrderID: order.ID, CustomerName: name}
}
return result, nil
}
// ✗ Shared mutable state di event handler — race condition
var processedEvents = map[string]bool{} // ✗ shared tanpa lock
func handleEvent(ctx context.Context, event eventbus.Event) error {
processedEvents[event.EventName()] = true // ✗ data race
return nil
}
// ✓ Gunakan sync.Map atau hindari shared state di event handler
var processed sync.Map
func handleEventSafe(ctx context.Context, event eventbus.Event) error {
processed.Store(event.EventName(), true) // ✓ thread-safe
return nil
}
Kapan Modular Monolith Cukup dan Kapan Perlu Berevolusi #
Modular Monolith sangat cocok jika:
✓ Tim 5–30 developer dengan domain ownership yang jelas per modul
✓ Sistem masih dalam fase growth — domain sedang dipahami
✓ Waktu deployment yang cepat lebih penting dari per-service scaling
✓ Tim belum punya kapasitas untuk mengelola distributed system ops
✓ Tidak ada bottleneck yang membutuhkan scaling per modul secara independen
Pertimbangkan ekstrak modul ke microservice jika:
✗ Satu modul punya load jauh lebih tinggi dari modul lain
dan vertical/horizontal scaling sudah tidak efisien
✗ Tim ownership modul tersebut perlu deploy secara independen
tanpa menunggu release monolith
✗ Modul menggunakan teknologi yang tidak kompatibel
(misal: ML inference yang butuh Python runtime)
✗ Compliance atau security membutuhkan isolasi runtime penuh
Checklist Review Modular Monolith #
BOUNDARY MODUL:
□ Setiap modul punya file api.go yang mengekspos API interface dan DTO publik
□ Tidak ada import langsung ke internal package modul lain
□ Internal struct dan domain entity tidak diekspos ke luar modul
□ Compiler bisa memvalidasi boundary (package internal/)
KOMUNIKASI ANTAR MODUL:
□ Modul berkomunikasi sync via API interface (bukan struct konkret)
□ Modul berkomunikasi async via EventBus (bukan direct method call)
□ DTO yang dibagikan adalah data transfer object, bukan domain entity
□ Tidak ada circular dependency antar modul
DATABASE ISOLATION:
□ Setiap modul punya schema database tersendiri
□ Tidak ada cross-schema JOIN dalam kode repository
□ Data dari modul lain diambil via API call, bukan query langsung
□ Migration file diorganisir per modul
EVENT BUS:
□ Publisher tidak tahu siapa subscribernya
□ Event handler thread-safe (tidak ada shared mutable state)
□ Event type memiliki nama yang deskriptif (past tense: order.placed)
□ Failure di satu handler tidak membatalkan handler lain
VALIDASI BOUNDARY:
□ Ada tool atau test yang memvalidasi dependency antar modul
□ CI/CD menjalankan validasi dependency setiap push
□ go test -race dijalankan secara rutin
PERSIAPAN EVOLUSI:
□ API interface setiap modul stabil dan terdokumentasi
□ Schema database per modul tidak ada cross-modul foreign key constraint
□ Event contract terdokumentasi sebagai kandidat untuk Kafka/RabbitMQ
Ringkasan #
- Modular Monolith adalah sweet spot antara monolith dan microservices — satu deployment dengan disiplin boundary seolah-olah sudah terdistribusi; semua keunggulan operasional monolith, dengan kualitas desain yang memungkinkan evolusi.
- Tiga pilar yang tidak boleh dikompromikan — interface publik (modul lain hanya boleh bergantung pada API interface), event bus internal (komunikasi async tanpa coupling langsung), dan schema isolation (tidak ada cross-schema JOIN).
- Setiap modul mengekspos API interface, bukan struct konkret — ini memastikan coupling selalu melalui kontrak yang stabil, bukan melalui detail implementasi yang bisa berubah.
- EventBus in-process untuk loose coupling — modul yang mempublikasikan event tidak tahu siapa yang subscribe; ini memungkinkan penambahan subscriber baru tanpa mengubah publisher.
- Schema isolation adalah boundary yang sering diabaikan — cross-schema JOIN menciptakan coupling tersembunyi di level database yang sulit dideteksi dan sulit dipisahkan saat modul perlu diekstrak.
- Go package
internal/adalah penjaga boundary yang gratis — compiler Go mencegah import dari luar parent directory; ini adalah cara paling efektif untuk menegakkan boundary tanpa tool tambahan.- Persiapkan evolusi sejak awal — desain API interface, event contract, dan schema seolah-olah modul akan menjadi microservice; ini membuat ekstraksi di masa depan menjadi operasi yang aman dan bertahap.
- Cocok untuk tim 5–30 developer — cukup kecil untuk satu deployment, cukup besar untuk boundary yang jelas; di bawah itu Traditional Monolith sudah cukup, di atasnya pertimbangkan ekstraksi modul tertentu.
- Bukan stepping stone yang dipaksakan — modular monolith bisa menjadi end-game architecture yang sangat efektif; tidak semua sistem perlu berevolusi ke microservices.
- Disiplin tim lebih penting dari tooling — boundary yang terdefinisi dengan baik tidak berguna jika tim tidak berkomitmen untuk menghormatinya; review boundary violation dalam code review sama pentingnya dengan review logic.
← Sebelumnya: Monolithic Architecture Berikutnya: Microservice Architecture →