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 packageinternal/di Go. Package yang berada di bawah direktoriinternal/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 mengimportuser.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 →