Monolithic Architecture #

Ada tekanan industri yang kuat untuk selalu memilih arsitektur yang paling “modern”: microservices, serverless, event-driven. Tekanan ini begitu kuat sehingga banyak tim memulai proyek baru langsung dengan 15 service terpisah sebelum domain bisnis dipahami dengan baik, sebelum ada cukup traffic untuk membenarkan kompleksitas distribusi, dan sebelum tim punya kapasitas untuk mengelola operational overhead yang menyertainya. Hasilnya sering disebut distributed monolith — semua kerugian sistem terdistribusi tanpa manfaatnya. Monolithic Architecture bukan antitesis dari arsitektur yang baik — ia adalah fondasi yang rational, efisien, dan sering kali pilihan terbaik untuk sistem yang sedang tumbuh. Stack Overflow melayani jutaan request per hari sebagai monolith. Shopify beroperasi bertahun-tahun sebagai monolith sebelum berevolusi. Basecamp memilih kembali ke monolith setelah eksperimen microservices. Memahami kapan monolith adalah pilihan yang tepat — dan bagaimana membangunnya dengan benar — adalah keahlian yang sama pentingnya dengan memahami kapan harus berpisah.

Tiga Varian Monolith #

“Monolith” bukan satu hal tunggal — ada spektrum dari yang paling sederhana hingga yang paling terstruktur:

flowchart LR
    subgraph BOM["Big Ball of Mud"]
        B1["Semua campur:\nUI, logic, DB\ndalam satu file/class"]
    end

    subgraph TM["Traditional Monolith"]
        T1["Handler"] --> T2["Service"] --> T3["Repository"]
        T3 --> T4[(Database)]
    end

    subgraph MM["Modular Monolith"]
        subgraph UM["User Module"]
            UH["Handler"] --> US["Service"] --> UR["Repo"]
        end
        subgraph OM["Order Module"]
            OH["Handler"] --> OS["Service"] --> OR["Repo"]
        end
        subgraph PM["Payment Module"]
            PH["Handler"] --> PS["Service"] --> PR["Repo"]
        end
        UM & OM & PM --> DB2[(Shared DB)]
    end

    BOM -->|"tambahkan struktur"| TM
    TM -->|"tambahkan boundary"| MM
Varian Ciri Khas Skalabilitas Tim
Big Ball of Mud Tidak ada struktur, semua terikat, tidak ada yang berani mengubah 1–2 developer
Traditional Monolith Ada layer (handler/service/repo) tapi modul tidak dibatasi 2–8 developer
Modular Monolith Modul dengan boundary tegas, satu deployment 8–30+ developer

Tujuan artikel ini adalah menunjukkan cara membangun Traditional Monolith yang sehat yang bisa berevolusi ke Modular Monolith saat tim dan sistem berkembang — dan kemudian ke arsitektur terdistribusi jika benar-benar diperlukan.


Karakteristik Utama Monolith #

flowchart TD
    subgraph MONO["Monolithic Application"]
        subgraph PROC["Satu Proses Runtime"]
            API["API Layer\n/users, /orders, /payments"]
            BL["Business Logic\nValidation, Rules, Calculations"]
            DAL["Data Access Layer\nSQL, ORM, Cache"]
        end
        PROC -->|"satu binary"| BUILD["Satu Build Artifact"]
        BUILD -->|"satu pipeline"| DEPLOY["Satu Deployment"]
    end

    DEPLOY --> DB[(Database)]
    DEPLOY --> CACHE[(Cache)]

Empat karakteristik yang mendefinisikan monolith:

Satu codebase — semua kode ada di satu repository, semua developer bekerja di tempat yang sama. Ini memudahkan refactoring lintas modul dan tidak ada overhead contract antar service.

Satu artefak build — compile sekali menghasilkan satu binary atau package. Tidak ada dependency management antar service, tidak ada versioning API internal.

Satu proses runtime — semua komponen berjalan dalam satu proses OS. Komunikasi antar modul adalah function call langsung — nol latency network, nol serialisasi/deserialisasi.

Satu pipeline deployment — deploy satu kali, semua fitur langsung tersedia. Tidak ada koordinasi deployment antar tim.


Mengapa Monolith Lebih Cepat di Awal #

Perbedaan overhead antara monolith dan microservices menjadi sangat nyata ketika sistem sedang aktif dikembangkan:

sequenceDiagram
    participant Dev as Developer
    participant Code as Codebase

    Note over Dev,Code: Monolith: siklus development cepat
    Dev->>Code: ubah business logic
    Dev->>Code: go build ./...
    Dev->>Code: go test ./...
    Code-->>Dev: selesai dalam hitungan detik

    participant MS1 as User Service
    participant MS2 as Order Service
    participant MS3 as Inventory Service

    Note over Dev,MS3: Microservices: overhead koordinasi
    Dev->>MS1: ubah User API contract
    Dev->>MS2: update client code untuk User API
    Dev->>MS3: update client code untuk User API
    Dev->>MS1: rebuild + redeploy User Service
    Dev->>MS2: rebuild + redeploy Order Service
    Dev->>MS3: rebuild + redeploy Inventory Service
    Note over Dev,MS3: satu perubahan = N deployment + koordinasi

Kecepatan ini bukan hanya soal build time — ini soal cognitive overhead. Di monolith, seorang developer bisa melakukan ctrl+click untuk melompat ke implementasi fungsi manapun di seluruh sistem. Di microservices, kamu perlu tahu service mana yang mengimplementasikan kontrak tertentu, versi API mana yang sedang digunakan, dan bagaimana mendebug alur lintas service.


Implementasi Go: Monolith yang Terstruktur #

Berikut adalah struktur monolith Go yang sehat — terorganisir tapi tidak over-engineered:

// main.go — entry point
package main

import (
	"database/sql"
	"log"
	"net/http"

	_ "github.com/lib/pq"

	"myapp/internal/user"
	"myapp/internal/order"
	"myapp/internal/payment"
	"myapp/internal/middleware"
)

func main() {
	db, err := sql.Open("postgres", "postgres://localhost/myapp?sslmode=disable")
	if err != nil {
		log.Fatal("koneksi database gagal:", err)
	}
	defer db.Close()

	// Inisialisasi setiap modul
	userModule := user.NewModule(db)
	orderModule := order.NewModule(db, userModule.Service())
	paymentModule := payment.NewModule(db, orderModule.Service())

	// Setup router — semua routes dalam satu binary
	mux := http.NewServeMux()

	// Mount routes per modul
	userModule.RegisterRoutes(mux)
	orderModule.RegisterRoutes(mux)
	paymentModule.RegisterRoutes(mux)

	// Middleware global
	handler := middleware.Chain(mux,
		middleware.Logger(),
		middleware.Recovery(),
		middleware.Auth(),
	)

	log.Println("Server berjalan di :8080")
	log.Fatal(http.ListenAndServe(":8080", handler))
}
// internal/user/module.go — User module yang self-contained
package user

import (
	"database/sql"
	"net/http"
)

// Module mengenkapsulasi semua komponen user dalam satu unit
type Module struct {
	service    *Service
	handler    *Handler
	repository *PostgresRepository
}

func NewModule(db *sql.DB) *Module {
	repo := NewPostgresRepository(db)
	svc := NewService(repo)
	handler := NewHandler(svc)
	return &Module{
		service:    svc,
		handler:    handler,
		repository: repo,
	}
}

// Service mengekspos internal service untuk digunakan modul lain
// ✓ Modul lain menggunakan interface, bukan struct konkret
func (m *Module) Service() UserServiceInterface {
	return m.service
}

// RegisterRoutes mendaftarkan semua route user ke mux
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}", m.handler.Update)
	mux.HandleFunc("DELETE /users/{id}", m.handler.Delete)
	mux.HandleFunc("GET /users", m.handler.List)
}
// internal/user/service.go
package user

import (
	"context"
	"errors"
	"time"
)

// UserServiceInterface adalah kontrak yang diekspos ke modul lain
// ✓ Modul lain bergantung pada interface, bukan implementation
type UserServiceInterface interface {
	GetByID(ctx context.Context, id string) (*UserOutput, error)
	ValidateExists(ctx context.Context, id string) error
}

type UserRepository interface {
	Save(ctx context.Context, user *User) error
	FindByID(ctx context.Context, id string) (*User, error)
	FindByEmail(ctx context.Context, email string) (*User, error)
	FindAll(ctx context.Context) ([]*User, error)
	Delete(ctx context.Context, id string) error
}

// User adalah domain entity
type User struct {
	ID        string
	FullName  string
	Email     string
	Role      string
	CreatedAt time.Time
	IsActive  bool
}

func (u *User) Deactivate() error {
	if !u.IsActive {
		return errors.New("user sudah tidak aktif")
	}
	u.IsActive = false
	return nil
}

type UserOutput struct {
	ID       string `json:"id"`
	FullName string `json:"full_name"`
	Email    string `json:"email"`
	Role     string `json:"role"`
}

// Service mengimplementasikan UserServiceInterface
type Service struct {
	repo UserRepository
}

func NewService(repo UserRepository) *Service {
	return &Service{repo: repo}
}

func (s *Service) GetByID(ctx context.Context, id string) (*UserOutput, error) {
	user, err := s.repo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}
	return &UserOutput{
		ID:       user.ID,
		FullName: user.FullName,
		Email:    user.Email,
		Role:     user.Role,
	}, nil
}

func (s *Service) ValidateExists(ctx context.Context, id string) error {
	_, err := s.repo.FindByID(ctx, id)
	return err
}

// CreateInput adalah DTO input
type CreateInput struct {
	FullName string
	Email    string
	Password string
}

func (s *Service) Create(ctx context.Context, input CreateInput) (*UserOutput, error) {
	// Cek email duplikat
	existing, _ := s.repo.FindByEmail(ctx, input.Email)
	if existing != nil {
		return nil, errors.New("email sudah terdaftar")
	}

	user := &User{
		ID:        generateID(),
		FullName:  input.FullName,
		Email:     input.Email,
		Role:      "user",
		CreatedAt: time.Now(),
		IsActive:  true,
	}

	if err := s.repo.Save(ctx, user); err != nil {
		return nil, err
	}

	return &UserOutput{
		ID:       user.ID,
		FullName: user.FullName,
		Email:    user.Email,
		Role:     user.Role,
	}, nil
}
// internal/order/module.go — Order module bergantung ke User module via interface
package order

import (
	"database/sql"
	"net/http"

	"myapp/internal/user"
)

type Module struct {
	service *Service
	handler *Handler
}

func NewModule(db *sql.DB, userSvc user.UserServiceInterface) *Module {
	repo := NewPostgresRepository(db)
	svc := NewService(repo, userSvc)
	handler := NewHandler(svc)
	return &Module{service: svc, handler: handler}
}

func (m *Module) Service() OrderServiceInterface {
	return m.service
}

func (m *Module) RegisterRoutes(mux *http.ServeMux) {
	mux.HandleFunc("POST /orders", m.handler.Create)
	mux.HandleFunc("GET /orders/{id}", m.handler.GetByID)
	mux.HandleFunc("POST /orders/{id}/cancel", m.handler.Cancel)
	mux.HandleFunc("GET /users/{user_id}/orders", m.handler.GetByUser)
}

Struktur Direktori yang Scalable #

Struktur direktori yang baik untuk monolith Go yang bisa tumbuh:

myapp/
├── cmd/
│   └── server/
│       └── main.go              ← Entry point dan wiring
│
├── internal/                    ← Kode internal — tidak bisa diimport dari luar
│   ├── user/                    ← User module
│   │   ├── module.go            ← Wiring internal modul
│   │   ├── service.go           ← Business logic + interface
│   │   ├── handler.go           ← HTTP handler
│   │   └── repository.go        ← Data access
│   │
│   ├── order/                   ← Order module
│   │   ├── module.go
│   │   ├── service.go
│   │   ├── handler.go
│   │   └── repository.go
│   │
│   ├── payment/                 ← Payment module
│   │   ├── module.go
│   │   ├── service.go
│   │   ├── handler.go
│   │   └── repository.go
│   │
│   └── middleware/              ← Shared middleware
│       ├── auth.go
│       ├── logger.go
│       └── recovery.go
│
├── pkg/                         ← Shared utilities (bisa diimport eksternal)
│   ├── config/
│   │   └── config.go
│   ├── database/
│   │   └── postgres.go
│   └── response/
│       └── response.go
│
└── migrations/                  ← Database migrations
    ├── 001_create_users.sql
    ├── 002_create_orders.sql
    └── 003_create_payments.sql
Gunakan package internal/ di Go. Package yang berada di bawah direktori internal/ hanya bisa diimport oleh kode yang berada di parent directory yang sama. Ini memberikan penegakan boundary yang dilakukan oleh Go compiler — modul yang tidak seharusnya saling mengimport akan gagal dikompilasi.

Scaling Monolith #

Sering ada asumsi bahwa monolith tidak bisa discale. Ini tidak benar — ada beberapa teknik scaling yang efektif:

flowchart TD
    subgraph VS["Vertical Scaling"]
        APP1["Monolith\n4 CPU, 8GB RAM"] -->|"upgrade"| APP2["Monolith\n16 CPU, 64GB RAM"]
    end

    subgraph HS["Horizontal Scaling"]
        LB["Load Balancer"] --> M1["Monolith\nInstance 1"]
        LB --> M2["Monolith\nInstance 2"]
        LB --> M3["Monolith\nInstance 3"]
        M1 & M2 & M3 --> DB[(Shared Database)]
    end

    subgraph RS["Read Replica"]
        WDB[(Primary DB\nWrite)] --> RDB1[(Read Replica 1)]
        WDB --> RDB2[(Read Replica 2)]
        APP3["Monolith"] -->|"writes"| WDB
        APP3 -->|"reads"| RDB1
    end

Teknik scaling yang efektif untuk monolith:

Horizontal scaling — jalankan multiple instance di belakang load balancer. Ini bekerja dengan baik selama aplikasi stateless (tidak menyimpan state di memory). Go sangat cocok untuk ini karena goroutine-based concurrency model-nya memungkinkan satu instance menangani ribuan request bersamaan.

Read replica — pisahkan read dan write di level database. Write ke primary, read dari replica. Ini bisa menghilangkan sebagian besar bottleneck database tanpa mengubah arsitektur aplikasi sama sekali.

Vertical scaling — tambahkan CPU dan RAM. Tidak elegan, tapi efektif dan murah untuk tahap awal. Seringkali lebih cost-effective daripada biaya operasional microservices.

Caching agresif — pasang Redis di depan query yang sering. Satu cache layer bisa menghilangkan 80–90% load database.


Tanda-Tanda Monolith Perlu Berevolusi #

Monolith bukan solusi permanen untuk semua situasi. Ada tanda-tanda konkret yang menunjukkan sudah saatnya berevolusi:

Tanda-tanda teknikal:
  □ Build time > 10 menit — semua developer menunggu pipeline yang sama
  □ Test suite > 30 menit — fast feedback loop hilang
  □ Database menjadi bottleneck yang tidak bisa diselesaikan dengan caching atau read replica
  □ Satu fitur yang berat (image processing, ML inference) membuat seluruh aplikasi lambat
  □ Memory leak di satu modul crash seluruh aplikasi

Tanda-tanda organisasional:
  □ > 20 developer bekerja di codebase yang sama, konflik merge sering terjadi
  □ Tim A harus menunggu tim B untuk deploy karena satu pipeline yang sama
  □ Onboarding developer baru butuh > 2 minggu hanya untuk memahami codebase
  □ Tidak ada tim yang berani mengubah modul tertentu karena takut efek samping

Tanda-tanda yang BUKAN alasan untuk berevolusi:
  □ "Microservices adalah standar industri" — bukan alasan teknikal
  □ "Traffic kita akan naik 100x bulan depan" — prediksi traffic bukan fakta sekarang
  □ "Kompetitor kita pakai microservices" — tidak relevan dengan masalahmu
  □ "Satu module punya 2000 baris" — panjang kode bukan indikator arsitektur

Strangler Fig: Migrasi Bertahap dari Monolith #

Ketika monolith perlu berevolusi, cara yang paling aman adalah Strangler Fig Pattern — mengekstrak satu modul pada satu waktu, tanpa big bang rewrite:

flowchart TD
    subgraph PHASE1["Phase 1: Identifikasi kandidat ekstraksi"]
        M1["Monolith\n(semua modul)"]
        NOTE1["Modul Inventory:\n- high traffic\n- paling sering bottleneck\n- boundary sudah jelas"]
    end

    subgraph PHASE2["Phase 2: Buat service baru, redirect traffic bertahap"]
        LB["API Gateway /\nLoad Balancer"]
        M2["Monolith\n(tanpa inventory routes)"]
        IS["Inventory Service\n(baru)"]
        LB -->|"GET /inventory/*"| IS
        LB -->|"semua route lain"| M2
    end

    subgraph PHASE3["Phase 3: Monolith mengakses inventory via API"]
        M3["Monolith"]
        IS3["Inventory Service"]
        M3 -->|"HTTP / gRPC"| IS3
        Note3["Modul inventory dihapus dari monolith"]
    end

    PHASE1 --> PHASE2 --> PHASE3

Strangler Fig memungkinkan:

  • Tidak ada downtime
  • Bisa di-rollback jika ada masalah
  • Tim bisa belajar microservices pada satu modul tanpa risiko besar
  • Domain yang diekstrak sudah teruji di production sebelum fully independent

Monolith vs Modular Monolith vs Microservices #

flowchart LR
    M["Monolith\n1 deployment\n1 codebase\ntight coupling"] -->|"+ boundary disiplin"| MM["Modular Monolith\n1 deployment\n1 codebase\nboundary tegas"]
    MM -->|"+ independent deployment\n+ network boundary"| MS["Microservices\nN deployment\nN codebase\nloose coupling"]
Dimensi Monolith Modular Monolith Microservices
Deployment 1 unit 1 unit N unit independen
Komunikasi Function call Function call Network (HTTP/gRPC)
Latency antar modul Nol Nol 1–10ms+ per hop
Data consistency ACID transactions ACID transactions Eventual consistency
Scaling Per-aplikasi Per-aplikasi Per-service
Debugging Satu log stream Satu log stream Distributed tracing needed
Team autonomy Rendah Sedang Tinggi
Ops complexity Sangat rendah Sangat rendah Sangat tinggi
Cocok untuk tim 1–10 developer 5–30 developer 20+ developer

Anti-Pattern yang Harus Dihindari #

// ✗ "Big Ball of Mud" — semua campur dalam satu file/handler
func handleRequest(w http.ResponseWriter, r *http.Request) {
	// ✗ parsing HTTP
	body, _ := io.ReadAll(r.Body)

	// ✗ business logic langsung di handler
	if len(body) == 0 {
		http.Error(w, "empty body", 400)
		return
	}

	// ✗ SQL langsung di handler
	db.Exec("INSERT INTO orders (data) VALUES ($1)", body)

	// ✗ email langsung di handler
	smtp.SendMail("smtp.gmail.com:587", nil, "[email protected]",
		[]string{"[email protected]"}, []byte("Order created"))
}

// ✓ Pisahkan tanggung jawab — handler, service, repository
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
	var req CreateOrderRequest
	json.NewDecoder(r.Body).Decode(&req)
	output, err := h.service.CreateOrder(r.Context(), req)
	if err != nil {
		writeError(w, 422, err.Error())
		return
	}
	writeJSON(w, 201, output)
}

// ✗ Modul saling import langsung (circular atau tight coupling)
// internal/order/service.go
package order

import "myapp/internal/user" // ✗ import langsung ke user package

func (s *Service) CreateOrder(userID string) error {
	userRepo := user.NewPostgresRepository(s.db) // ✗ coupling ke implementasi konkret
	user, _ := userRepo.FindByID(userID)
	// ...
}

// ✓ Modul berkomunikasi via interface
package order

// UserValidator adalah interface yang didefinisikan oleh order module
type UserValidator interface {
	ValidateExists(ctx context.Context, id string) error
}

type Service struct {
	repo      OrderRepository
	userSvc   UserValidator // ✓ interface — tidak tahu implementasinya
}

// ✗ Shared mutable global state antar modul
var globalUserCache = map[string]*User{} // ✗ semua modul bisa mengubah ini

// ✓ State dikelola oleh modul yang memilikinya, diakses melalui method
type UserService struct {
	cache sync.Map // ✓ state di dalam struct, tidak global
}

Checklist Review Monolithic Architecture #

STRUKTUR:
  □ Setiap modul ada di direktori terpisah di bawah internal/
  □ Modul tidak saling import langsung — komunikasi via interface
  □ Tidak ada circular dependency antar modul
  □ Shared code ada di pkg/ atau internal/shared/, bukan copy-paste

BOUNDARY:
  □ Setiap modul mengekspos interface, bukan struct konkret
  □ Modul lain bergantung pada interface modul, bukan implementation
  □ Data sharing antar modul melalui method call dengan DTO, bukan shared struct

DATABASE:
  □ Schema per modul terdefinisi dengan jelas (meskipun satu database)
  □ Tidak ada cross-module JOIN yang kompleks (pertanda boundary tidak tepat)
  □ Migration files terorganisir dan bisa dijalankan otomatis

STATELESS:
  □ Tidak ada state yang disimpan di in-memory yang tidak bisa dishare antar instance
  □ Session menggunakan external store (Redis, database) bukan in-memory
  □ Aplikasi bisa di-scale horizontal tanpa perubahan kode

OBSERVABILITY:
  □ Structured logging di semua endpoint
  □ Request ID dipropagasi melalui semua layer
  □ Health check endpoint tersedia
  □ Metrics (request rate, error rate, latency) diekspor

PENGUJIAN:
  □ Unit test per modul tidak membutuhkan database atau HTTP server
  □ Integration test ada untuk flow utama
  □ go test -race dijalankan secara rutin
  □ Test coverage minimal 70% untuk business logic

Ringkasan #

  • Monolith bukan arsitektur yang inferior — pilihan antara monolith dan microservices adalah keputusan kontekstual, bukan soal mana yang lebih “modern”; banyak sistem sukses besar beroperasi sebagai monolith.
  • Ada tiga varian monolith — Big Ball of Mud (tidak terstruktur), Traditional Monolith (berlayer tapi tidak ada boundary), dan Modular Monolith (berlayer dengan boundary tegas); targetkan yang ketiga.
  • Monolith lebih cepat di tahap awal — tidak ada overhead koordinasi deployment, tidak ada latency network antar modul, debugging lebih sederhana, onboarding lebih mudah.
  • Gunakan package internal/ untuk menegakkan boundary — Go compiler mencegah import dari luar parent directory; ini adalah cara yang paling efektif untuk menjaga boundary modul.
  • Modul berkomunikasi via interface, bukan import langsung — ketika Order module butuh data User, ia menggunakan UserServiceInterface, bukan mengimport user.PostgresRepository; ini adalah langkah pertama menuju independence.
  • Horizontal scaling adalah solusi yang sering diabaikan — multiple instance di belakang load balancer bisa menangani load yang sangat besar; ini lebih murah dan lebih sederhana dari microservices untuk sebagian besar sistem.
  • Kenali tanda-tanda bahwa sudah saatnya berevolusi — bukan dari tekanan industri, tapi dari rasa sakit nyata: build time yang lama, deployment yang saling memblokir, bottleneck yang tidak bisa diselesaikan secara vertikal.
  • Gunakan Strangler Fig untuk migrasi bertahap — ekstrak satu modul pada satu waktu, redirect traffic secara bertahap; tidak ada big bang rewrite yang berisiko.
  • Modular Monolith bisa menjadi end-game architecture — tidak semua sistem perlu microservices; modular monolith yang boundary-nya terjaga dengan ketat bisa melayani puluhan developer dengan efisien.
  • Prioritaskan simplicity sampai ada alasan kuat untuk kompleksitas — setiap tambahan kompleksitas arsitektur harus dibayar oleh manfaat yang nyata dan terukur; jangan bayar biaya itu sebelum mendapat manfaatnya.

← Sebelumnya: Layered Architecture   Berikutnya: Modular Monolith Architecture →

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