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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact