Microservice Architecture #
Tidak ada arsitektur yang lebih sering disalahgunakan dari microservices. Tim yang baru mengadopsinya sering datang dengan ekspektasi: deployment independen, scaling per service, dan autonomi tim. Yang mereka dapatkan — jika tidak dipersiapkan dengan benar — adalah semua kerumitan sistem terdistribusi tanpa satupun manfaatnya: latency meningkat karena setiap operasi butuh network hop, debugging menjadi mimpi buruk karena error menyebar lintas service tanpa trace yang jelas, dan setiap fitur baru butuh koordinasi deployment antara tiga tim berbeda. Ini yang disebut distributed monolith — sistem yang terdistribusi secara fisik tapi tightly coupled secara logis. Microservice Architecture yang benar mensyaratkan lebih dari sekadar memecah aplikasi menjadi banyak service: ia mensyaratkan database per service, boundary domain yang tegas, observability yang matang, dan organisasi tim yang selaras dengan struktur sistem.
Karakteristik Service yang Sehat #
Bukan semua “service” adalah microservice yang baik. Ada empat karakteristik yang harus dipenuhi:
flowchart TD
subgraph GOOD["Microservice yang sehat"]
S1["Single Business Capability\nSatu service = satu domain bisnis"]
S2["Own Database\nTidak berbagi database dengan service lain"]
S3["Independent Deployable\nBisa di-deploy tanpa koordinasi service lain"]
S4["Loosely Coupled\nKomunikasi via API atau event, tidak via shared DB"]
end
subgraph BAD["Distributed Monolith — bukan microservice"]
B1["Satu service untuk semua domain"]
B2["Semua service pakai satu database"]
B3["Deploy A harus tunggu B"]
B4["Service A langsung akses tabel service B"]
end
| Karakteristik | Benar | Salah (Distributed Monolith) |
|---|---|---|
| Scope | Satu bounded context bisnis | Banyak domain dicampur |
| Database | Satu database per service | Shared database |
| Deployment | Independen — tidak butuh koordinasi | Harus deploy bersama |
| Komunikasi | Via API contract atau event | Direct DB access lintas service |
| Ukuran | Bisa dikuasai satu tim kecil | Tim tidak tahu batas tanggung jawab |
Topologi Sistem #
flowchart TD
CLIENT["Client\n(Web / Mobile)"]
GW["API Gateway\n(routing, auth, rate-limit)"]
CLIENT --> GW
GW --> US["User Service\n:8081\nPostgreSQL"]
GW --> OS["Order Service\n:8082\nMySQL"]
GW --> PS["Payment Service\n:8083\nPostgreSQL"]
OS -->|"HTTP: validate user"| US
OS -->|"publish OrderPlaced"| MB[(Message Broker\nKafka)]
MB -->|"OrderPlaced"| PS
MB -->|"OrderPlaced"| NS["Notification Service\n:8084\nRedis"]
PS -->|"publish PaymentProcessed"| MB
MB -->|"PaymentProcessed"| OS
subgraph OBS["Observability Stack"]
LOG["Centralized Logging\n(ELK / Loki)"]
TRACE["Distributed Tracing\n(Jaeger / Tempo)"]
METRIC["Metrics\n(Prometheus / Grafana)"]
end
US & OS & PS & NS --> OBS
Komunikasi Antar Service: Sync vs Async #
Salah satu keputusan terpenting dalam microservices adalah kapan menggunakan komunikasi synchronous (HTTP/gRPC) dan kapan asynchronous (event/message broker):
flowchart LR
subgraph SYNC["Synchronous — HTTP/gRPC"]
A1["Order Service"] -->|"GET /users/{id}\nvalidate user exists"| B1["User Service"]
B1 -->|"200 OK / 404 Not Found"| A1
NOTE1["✓ Butuh response langsung\n✓ Query data terkini\n✗ Coupling temporal\n✗ Cascade failure"]
end
subgraph ASYNC["Asynchronous — Event/Message"]
A2["Order Service"] -->|"OrderPlaced event"| MB2["Kafka"]
MB2 -->|"consume"| B2["Payment Service"]
MB2 -->|"consume"| C2["Notification Service"]
NOTE2["✓ Loose coupling\n✓ High throughput\n✓ Failure isolation\n✗ Eventual consistency\n✗ Harder debugging"]
end
Panduan memilih:
| Gunakan Sync (HTTP/gRPC) jika | Gunakan Async (Event) jika |
|---|---|
| Butuh response langsung untuk melanjutkan operasi | Operasi bisa diproses di background |
| Query data real-time yang selalu terkini | Producer tidak perlu tahu hasil pemrosesan |
| Operasi simple request-response | Banyak consumer yang perlu dinotifikasi |
| Cross-service validation sebelum commit | Toleran terhadap eventual consistency |
Implementasi: HTTP Client dengan Retry dan Timeout #
Ketika service memanggil service lain via HTTP, ada beberapa concern yang wajib ditangani:
// pkg/httpclient/client.go — reusable HTTP client dengan timeout dan retry
package httpclient
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Config mengonfigurasi behavior HTTP client
type Config struct {
BaseURL string
Timeout time.Duration
MaxRetries int
RetryDelay time.Duration
}
// Client adalah HTTP client yang opinionated untuk inter-service communication
type Client struct {
config Config
http *http.Client
}
func New(config Config) *Client {
if config.Timeout == 0 {
config.Timeout = 5 * time.Second
}
if config.MaxRetries == 0 {
config.MaxRetries = 3
}
if config.RetryDelay == 0 {
config.RetryDelay = 100 * time.Millisecond
}
return &Client{
config: config,
http: &http.Client{Timeout: config.Timeout},
}
}
// Get melakukan HTTP GET dengan retry otomatis untuk transient error
func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
url := c.config.BaseURL + path
var lastErr error
for attempt := 0; attempt <= c.config.MaxRetries; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(c.config.RetryDelay * time.Duration(attempt)):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("gagal membuat request: %w", err)
}
// Propagasi trace ID untuk distributed tracing
if traceID := ctx.Value("trace_id"); traceID != nil {
req.Header.Set("X-Trace-ID", fmt.Sprint(traceID))
}
resp, err := c.http.Do(req)
if err != nil {
lastErr = err
continue // retry
}
defer resp.Body.Close()
// Jangan retry untuk client error (4xx)
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return fmt.Errorf("client error %d dari %s", resp.StatusCode, url)
}
// Retry untuk server error (5xx)
if resp.StatusCode >= 500 {
lastErr = fmt.Errorf("server error %d dari %s", resp.StatusCode, url)
continue
}
if result != nil {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("gagal decode response: %w", err)
}
}
return nil
}
return fmt.Errorf("setelah %d percobaan: %w", c.config.MaxRetries, lastErr)
}
// internal/order/user_client.go — client untuk User Service
package order
import (
"context"
"fmt"
"myapp/pkg/httpclient"
)
// UserServiceClient adalah client untuk berkomunikasi dengan User Service
type UserServiceClient struct {
client *httpclient.Client
}
func NewUserServiceClient(baseURL string) *UserServiceClient {
return &UserServiceClient{
client: httpclient.New(httpclient.Config{
BaseURL: baseURL,
Timeout: 3 * time.Second,
MaxRetries: 2,
}),
}
}
type userResponse struct {
ID string `json:"id"`
FullName string `json:"full_name"`
IsActive bool `json:"is_active"`
}
// ValidateUserActive memanggil User Service untuk memvalidasi user
func (c *UserServiceClient) ValidateUserActive(ctx context.Context, userID string) error {
var user userResponse
if err := c.client.Get(ctx, fmt.Sprintf("/users/%s", userID), &user); err != nil {
return fmt.Errorf("gagal validasi user: %w", err)
}
if !user.IsActive {
return fmt.Errorf("user %s tidak aktif", userID)
}
return nil
}
Circuit Breaker Pattern #
Tanpa circuit breaker, kegagalan satu service bisa menyebabkan cascade failure ke seluruh sistem. Service yang memanggil service yang down akan terus mencoba, menghabiskan thread pool, dan akhirnya ikut down.
// pkg/circuitbreaker/breaker.go
package circuitbreaker
import (
"errors"
"sync"
"time"
)
type State int
const (
StateClosed State = iota // Normal: request diizinkan
StateOpen // Terbuka: request ditolak langsung
StateHalfOpen // Setengah terbuka: satu request percobaan
)
var ErrCircuitOpen = errors.New("circuit breaker terbuka — service tidak tersedia")
type Breaker struct {
mu sync.Mutex
state State
failures int
maxFailures int
successCount int
lastFailure time.Time
openDuration time.Duration
}
func New(maxFailures int, openDuration time.Duration) *Breaker {
return &Breaker{
maxFailures: maxFailures,
openDuration: openDuration,
}
}
// Execute menjalankan fn melalui circuit breaker
func (b *Breaker) Execute(fn func() error) error {
b.mu.Lock()
state := b.currentState()
b.mu.Unlock()
if state == StateOpen {
return ErrCircuitOpen // ✓ fail fast — tidak mencoba sama sekali
}
err := fn()
b.mu.Lock()
defer b.mu.Unlock()
if err != nil {
b.recordFailure()
} else {
b.recordSuccess()
}
return err
}
func (b *Breaker) currentState() State {
if b.state == StateOpen {
if time.Since(b.lastFailure) > b.openDuration {
b.state = StateHalfOpen
b.successCount = 0
}
}
return b.state
}
func (b *Breaker) recordFailure() {
b.failures++
b.lastFailure = time.Now()
if b.failures >= b.maxFailures {
b.state = StateOpen
}
}
func (b *Breaker) recordSuccess() {
b.failures = 0
if b.state == StateHalfOpen {
b.successCount++
if b.successCount >= 2 {
b.state = StateClosed // ✓ pulih kembali
}
}
}
stateDiagram-v2
[*] --> Closed : Start
Closed --> Closed : Request sukses
Closed --> Open : Failures >= threshold
Open --> HalfOpen : Setelah openDuration
HalfOpen --> Closed : Sukses consecutive
HalfOpen --> Open : Failure terjadi
Open --> Open : Request ditolak (ErrCircuitOpen)
Observability: Logging, Tracing, dan Metrics #
Di microservices, observability bukan fitur tambahan — ini adalah prerequisite. Tanpa observability yang matang, debugging lintas service hampir tidak mungkin.
// internal/order/handler.go — structured logging dengan trace ID
package order
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
)
// Middleware untuk inject trace ID dan structured logging
func TracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate atau propagasi trace ID
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = uuid.New().String()
}
// Inject ke context untuk dipropagasi ke downstream calls
ctx := context.WithValue(r.Context(), "trace_id", traceID)
// Set di response header
w.Header().Set("X-Trace-ID", traceID)
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r.WithContext(ctx))
// Structured log setiap request
slog.InfoContext(ctx, "request completed",
"trace_id", traceID,
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration_ms", time.Since(start).Milliseconds(),
"service", "order-service",
)
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Handler dengan structured logging
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
traceID := r.Context().Value("trace_id")
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
slog.WarnContext(r.Context(), "invalid request body",
"trace_id", traceID,
"error", err.Error(),
)
http.Error(w, "request tidak valid", http.StatusBadRequest)
return
}
slog.InfoContext(r.Context(), "creating order",
"trace_id", traceID,
"customer_id", req.CustomerID,
"item_count", len(req.Items),
)
output, err := h.service.CreateOrder(r.Context(), req.toInput())
if err != nil {
slog.ErrorContext(r.Context(), "failed to create order",
"trace_id", traceID,
"customer_id", req.CustomerID,
"error", err.Error(),
)
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
slog.InfoContext(r.Context(), "order created successfully",
"trace_id", traceID,
"order_id", output.OrderID,
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(output)
}
Tiga pilar observability yang wajib ada:
flowchart LR
subgraph OBS["Observability Wajib di Microservices"]
LOG["Structured Logging\n• JSON format\n• trace_id di setiap log\n• level: INFO, WARN, ERROR\n• service name"]
TRACE["Distributed Tracing\n• Trace ID dipropagasi\nlintas service\n• Span per operasi\n• Jaeger / Tempo"]
METRIC["Metrics\n• Request rate\n• Error rate\n• Latency (p50, p95, p99)\n• Queue depth\n• Prometheus + Grafana"]
end
Distributed Transaction: Saga Pattern #
Di microservices, tidak ada ACID transaction yang bisa span lintas service. Solusinya adalah Saga Pattern — serangkaian local transaction yang masing-masing mempublikasikan event untuk memicu transaction berikutnya:
sequenceDiagram
participant OS as Order Service
participant MB as Kafka
participant PS as Payment Service
participant IS as Inventory Service
participant NS as Notification Service
Note over OS,NS: Happy Path — Choreography Saga
OS->>OS: CreateOrder (local transaction)
OS->>MB: publish OrderCreated
MB->>PS: OrderCreated
PS->>PS: ProcessPayment (local transaction)
PS->>MB: publish PaymentProcessed
MB->>IS: PaymentProcessed
IS->>IS: ReserveInventory (local transaction)
IS->>MB: publish InventoryReserved
MB->>NS: InventoryReserved
NS->>NS: SendConfirmation
Note over OS,NS: Compensating Transaction — jika Payment Gagal
PS->>MB: publish PaymentFailed
MB->>OS: PaymentFailed
OS->>OS: CancelOrder (compensating transaction)
OS->>MB: publish OrderCancelled
MB->>NS: OrderCancelled
NS->>NS: SendCancellationNotice
// internal/order/saga_handler.go — menangani hasil dari service lain
package order
import (
"context"
"log/slog"
"myapp/internal/shared/eventbus"
)
// PaymentFailedEvent diterima dari Payment Service
type PaymentFailedEvent struct {
OrderID string
Reason string
}
func (e PaymentFailedEvent) EventName() string { return "payment.failed" }
// SagaHandler menangani compensating transactions
type SagaHandler struct {
repo Repository
eventBus *eventbus.EventBus
}
// HandlePaymentFailed adalah compensating transaction untuk order
func (h *SagaHandler) HandlePaymentFailed(ctx context.Context, event eventbus.Event) error {
e, ok := event.(PaymentFailedEvent)
if !ok {
return nil
}
slog.InfoContext(ctx, "payment failed, cancelling order",
"order_id", e.OrderID,
"reason", e.Reason,
)
order, err := h.repo.FindByID(ctx, e.OrderID)
if err != nil {
return err
}
// Compensating transaction: batalkan order
if err := order.Cancel("payment failed: " + e.Reason); err != nil {
return err
}
if err := h.repo.Save(ctx, order); err != nil {
return err
}
// Publikasikan event kompensasi
return h.eventBus.Publish(ctx, OrderCancelledEvent{
OrderID: e.OrderID,
Reason: "payment failed",
})
}
Anti-Pattern yang Harus Dihindari #
// ✗ Shared database antar service — distributed monolith
// Order Service langsung query ke database User Service
func (r *OrderRepo) GetOrderWithUser(ctx context.Context, orderID string) (*OrderWithUser, error) {
// ✗ JOIN ke database service lain — coupling yang paling berbahaya
return r.db.QueryContext(ctx, `
SELECT o.id, u.full_name
FROM order_db.orders o
JOIN user_db.users u ON u.id = o.customer_id
WHERE o.id = $1
`, orderID)
}
// ✓ Setiap service hanya akses database-nya sendiri
// Data dari service lain diambil via API call
func (s *OrderService) GetOrderWithUser(ctx context.Context, orderID string) (*OrderDisplay, error) {
order, err := s.repo.FindByID(ctx, orderID)
if err != nil {
return nil, err
}
// Ambil nama user dari User Service via HTTP
user, err := s.userClient.GetUser(ctx, order.CustomerID)
if err != nil {
// Graceful degradation: tampilkan order meski user tidak tersedia
return &OrderDisplay{OrderID: orderID, CustomerName: "Unknown"}, nil
}
return &OrderDisplay{OrderID: orderID, CustomerName: user.FullName}, nil
}
// ✗ Synchronous chain yang terlalu panjang — cascade failure
// Order → Inventory → Warehouse → Supplier (semua sync)
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderInput) error {
// ✗ 4 hop sync — jika Supplier lambat, seluruh order creation lambat
inventory := s.inventoryClient.CheckStock(ctx, req.Items)
warehouse := s.warehouseClient.Reserve(ctx, inventory)
supplier := s.supplierClient.Confirm(ctx, warehouse)
_ = supplier
return s.repo.Save(ctx, buildOrder(req))
}
// ✓ Hanya validasi kritis yang sync, sisanya async via event
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderInput) error {
// ✓ Hanya validasi user yang sync (butuh response langsung)
if err := s.userClient.ValidateActive(ctx, req.CustomerID); err != nil {
return err
}
order := buildOrder(req)
if err := s.repo.Save(ctx, order); err != nil {
return err
}
// ✓ Sisanya async — Inventory, Warehouse, Supplier akan merespons via event
return s.eventBus.Publish(ctx, OrderCreatedEvent{OrderID: order.ID, Items: req.Items})
}
// ✗ Tidak ada timeout pada inter-service call — goroutine menunggu selamanya
func (s *Service) getUser(ctx context.Context, id string) (*User, error) {
resp, err := http.Get("http://user-service/users/" + id) // ✗ no timeout!
// ...
}
// ✓ Selalu gunakan context dengan timeout
func (s *Service) getUser(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second) // ✓ bounded timeout
defer cancel()
return s.userClient.Get(ctx, id)
}
Checklist Kesiapan Microservices #
PRASYARAT ORGANISASI:
□ Setiap service dimiliki oleh satu tim yang jelas (2–8 orang)
□ Tim bisa deploy service-nya tanpa koordinasi tim lain
□ On-call rotation ada untuk setiap service di production
□ Domain bisnis sudah dipahami dengan baik — bounded context jelas
PRASYARAT TEKNIKAL:
□ CI/CD pipeline sudah matang dan otomatis per service
□ Container (Docker) dan orchestration (Kubernetes) sudah dikuasai tim
□ Centralized logging sudah ada (ELK, Loki, atau sejenisnya)
□ Distributed tracing sudah dikonfigurasi (Jaeger, Tempo)
□ Metrics dan alerting sudah ada (Prometheus, Grafana)
□ Health check endpoint tersedia di setiap service
DESIGN SETIAP SERVICE:
□ Satu database per service — tidak ada shared database
□ API contract terdokumentasi (OpenAPI, protobuf)
□ Versioning API yang jelas — tidak ada breaking change tanpa versi baru
□ Timeout dan retry dikonfigurasi di semua inter-service call
□ Circuit breaker dipasang untuk dependency yang tidak reliable
RESILIENCE:
□ Service bisa berjalan meski dependency tidak tersedia (graceful degradation)
□ Compensating transaction (Saga) dirancang untuk semua distributed workflow
□ Idempotency key diterapkan untuk operasi yang bisa diretry
□ Dead letter queue dikonfigurasi untuk event yang gagal diproses
PENGUJIAN:
□ Unit test per service bisa berjalan tanpa service lain
□ Contract test memverifikasi API contract antar service
□ Integration test ada untuk alur utama
□ Chaos engineering atau fault injection dilakukan secara berkala
Kapan Microservices, Kapan Tidak #
Microservices justified jika ada rasa sakit nyata:
✓ Deployment bottleneck: Tim A tidak bisa deploy karena menunggu Tim B
✓ Scaling bottleneck: Modul X butuh 10x resource dari modul lain
✓ Technology bottleneck: Modul ML butuh Python, sisanya Go
✓ Reliability bottleneck: Bug di satu modul sering crash modul lain
✓ Organisasi sudah punya > 20 developer dengan domain ownership jelas
Hindari microservices jika:
✗ Memilih microservices karena ingin belajar, bukan karena kebutuhan nyata
✗ Tim < 10 developer — overhead koordinasi lebih besar dari manfaatnya
✗ Domain bisnis belum dipahami — service boundary yang salah sangat mahal
✗ Observability stack belum ada — debugging akan menjadi mimpi buruk
✗ CI/CD belum otomatis — deploy manual N service tidak sustainable
Ringkasan #
- Microservices bukan tujuan, tapi solusi untuk masalah spesifik — pilih microservices ketika ada rasa sakit nyata yang tidak bisa diselesaikan dengan monolith yang lebih baik.
- Distributed monolith adalah jebakan terbesar — service yang terdistribusi secara fisik tapi masih berbagi database atau tightly coupled secara logis mendapat semua kerugian distribusi tanpa manfaatnya.
- Database per service adalah aturan yang tidak boleh dilanggar — berbagi database antar service menciptakan coupling yang paling sulit dipecahkan dan paling berbahaya.
- Komunikasi sync untuk validasi kritis, async untuk efek samping — hindari synchronous chain yang terlalu panjang; gunakan event untuk operasi yang tidak butuh response langsung.
- Circuit breaker adalah wajib — tanpa circuit breaker, kegagalan satu service akan menyebabkan cascade failure ke seluruh sistem.
- Observability adalah prerequisite, bukan fitur — structured logging dengan trace ID, distributed tracing, dan metrics harus ada sebelum service masuk production.
- Saga pattern untuk distributed transaction — tidak ada ACID cross-service; desain compensating transaction untuk setiap workflow yang span multiple service.
- Timeout pada semua inter-service call — goroutine yang menunggu tanpa batas akan menghabiskan thread pool; setiap HTTP/gRPC call ke service lain harus punya timeout.
- Mulai dari modular monolith, ekstrak saat ada kebutuhan nyata — memahami domain dengan baik di monolith sebelum memecahnya menghasilkan service boundary yang jauh lebih tepat.
- Conway’s Law bekerja dua arah — struktur organisasi mempengaruhi arsitektur sistem; jika tim tidak bisa bekerja independen, service juga tidak akan bisa dideploy independen.
← Sebelumnya: Modular Monolith Berikutnya: Service-Based Architecture →