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 →

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