Service-Based Architecture #

Di antara monolith yang terlalu besar untuk satu tim dan microservices yang terlalu kompleks untuk tim yang belum siap, ada ruang yang sering diabaikan: Service-Based Architecture (SBA). Ini bukan kompromi yang setengah-setengah — ini adalah keputusan pragmatis yang disengaja. Sistem dipecah menjadi 4 hingga 12 service yang lebih besar dari microservice, biasanya masih berbagi satu database, berkomunikasi via API yang sederhana, dan bisa dideploy secara terpisah. Hasilnya: tim yang berbeda bisa bekerja pada service yang berbeda tanpa terus-menerus mengganggu satu sama lain, deployment menjadi lebih granular, dan codebase lebih mudah dipahami — tanpa harus menanggung overhead penuh distributed system. Banyak sistem bisnis sukses beroperasi seumur hidup sebagai SBA dan tidak perlu berevolusi lebih jauh.

Posisi dalam Spektrum Arsitektur #

Service-Based Architecture menempati posisi yang jelas di antara beberapa pilihan:

flowchart LR
    M["Monolith\n1 deployment\n1 codebase\n1 DB"] -->|"+ deployment granularity"| SBA
    SBA["Service-Based\n4–12 service\nbisa dideploy terpisah\nshared DB"] -->|"+ DB isolation\n+ full autonomy"| MS["Microservices\nN service\nN DB\nfull isolation"]

    MM["Modular Monolith\n1 deployment\nmodule boundary tegas\n1 DB"] -->|"+ separate deployment"| SBA
Dimensi Monolith Service-Based Microservices
Jumlah deployment unit 1 4–12 10–100+
Database 1 shared 1 shared (atau partial) 1 per service
Komunikasi In-process HTTP/REST antar service HTTP/gRPC + event
Team autonomy Rendah Sedang Tinggi
Ops complexity Sangat rendah Rendah–sedang Tinggi
Transaction ACID penuh ACID penuh (shared DB) Eventual consistency
Cocok untuk tim 1–10 5–30 20+

Keunggulan kritis SBA yang sering diabaikan adalah kemampuan menggunakan database transaction lintas tabel yang dimiliki service berbeda — sesuatu yang tidak mungkin di microservices tanpa saga pattern yang kompleks.


Karakteristik Utama SBA #

flowchart TD
    subgraph SBA["Service-Based Architecture"]
        GW["API Gateway / Load Balancer"]

        subgraph US["User Service\n:8081"]
            UH["Handler"] --> USVC["Service"] --> UREPO["Repository"]
        end

        subgraph OS["Order Service\n:8082"]
            OH["Handler"] --> OSVC["Service"] --> OREPO["Repository"]
        end

        subgraph PS["Payment Service\n:8083"]
            PH["Handler"] --> PSVC["Service"] --> PREPO["Repository"]
        end

        subgraph NS["Notification Service\n:8084"]
            NH["Handler"] --> NSVC["Service"] --> NREPO["Repository"]
        end

        DB[(Shared Database\nPostgreSQL)]

        GW --> US & OS & PS & NS
        UREPO & OREPO & PREPO & NREPO --> DB
    end

Tiga karakteristik yang mendefinisikan SBA:

Service granularity lebih besar — setiap service mencakup satu domain bisnis yang cukup besar untuk berdiri sendiri sebagai unit kerja tim. Bukan satu function = satu service (microservices), tapi satu domain = satu service.

Shared database adalah norma, bukan exception — berbeda dari microservices di mana shared DB adalah anti-pattern, di SBA ini adalah pilihan yang disengaja untuk mendapatkan kemudahan transaksional.

Deployment terpisah, bukan runtime terpisah — setiap service punya pipeline CI/CD dan artefak build sendiri, tapi boleh saja berjalan di infrastruktur yang sama.


Mengelola Shared Database dengan Benar #

Shared database adalah kekuatan sekaligus risiko terbesar SBA. Risiko terbesar adalah ketika service mulai mengakses tabel milik service lain secara langsung — ini adalah jalan menuju distributed monolith.

// ✓ Setiap service hanya akses tabel yang "dimilikinya"
// Kepemilikan tabel didefinisikan secara eksplisit

// User Service: pemilik tabel users, user_profiles, user_sessions
// Order Service: pemilik tabel orders, order_items, order_status_history
// Payment Service: pemilik tabel payments, payment_methods, refunds
// Notification Service: pemilik tabel notifications, notification_templates

// user-service/internal/repository/user_repo.go
package repository

// UserRepo hanya mengakses tabel yang dimiliki User Service
type UserRepo struct {
	db *sql.DB
}

func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
	var u User
	err := r.db.QueryRowContext(ctx,
		`SELECT id, full_name, email, is_active FROM users WHERE id = $1`, id,
	).Scan(&u.ID, &u.FullName, &u.Email, &u.IsActive)
	return &u, err
}

// ✗ JANGAN: User Service mengakses tabel orders
func (r *UserRepo) GetUserOrders(ctx context.Context, userID string) ([]Order, error) {
	// ✗ Ini adalah coupling yang salah — User Service tidak memiliki tabel orders
	rows, _ := r.db.QueryContext(ctx,
		`SELECT id, total FROM orders WHERE user_id = $1`, userID,
	)
	_ = rows
	return nil, nil
}
// Strategi ownership tabel: definisikan secara eksplisit di dokumentasi dan kode

// db/ownership.go — package khusus untuk mendokumentasikan kepemilikan tabel
package db

// TableOwnership mendefinisikan service mana yang "memiliki" setiap tabel
// Service lain tidak boleh write ke tabel ini, dan sebaiknya tidak read langsung
var TableOwnership = map[string]string{
	"users":                   "user-service",
	"user_profiles":           "user-service",
	"user_sessions":           "user-service",
	"orders":                  "order-service",
	"order_items":             "order-service",
	"order_status_history":    "order-service",
	"payments":                "payment-service",
	"payment_methods":         "payment-service",
	"refunds":                 "payment-service",
	"notifications":           "notification-service",
	"notification_templates":  "notification-service",
}

Implementasi: Struktur Service yang Konsisten #

Setiap service dalam SBA sebaiknya punya struktur internal yang konsisten — ini memudahkan developer berpindah antar service:

// order-service/main.go
package main

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

	_ "github.com/lib/pq"
)

func main() {
	// Baca config dari environment variable — tiap service punya config sendiri
	dbURL := os.Getenv("DATABASE_URL")
	userServiceURL := os.Getenv("USER_SERVICE_URL")
	port := os.Getenv("PORT")
	if port == "" {
		port = "8082"
	}

	db, err := sql.Open("postgres", dbURL)
	if err != nil {
		log.Fatal("db connection failed:", err)
	}
	defer db.Close()

	// Inisialisasi layer — sama seperti monolith tapi per service
	repo := repository.NewOrderRepository(db)
	userClient := client.NewUserServiceClient(userServiceURL)
	svc := service.NewOrderService(repo, userClient)
	handler := handler.NewOrderHandler(svc)

	mux := http.NewServeMux()
	handler.RegisterRoutes(mux)

	log.Printf("Order Service berjalan di :%s", port)
	log.Fatal(http.ListenAndServe(":"+port, mux))
}
// order-service/internal/service/order_service.go
package service

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

// UserClient adalah interface untuk memanggil User Service
// ✓ Setiap service mendefinisikan interface untuk dependency-nya
type UserClient interface {
	ValidateActive(ctx context.Context, userID string) error
	GetName(ctx context.Context, userID string) (string, error)
}

// OrderRepository adalah interface data access
type OrderRepository interface {
	Save(ctx context.Context, order *Order) error
	FindByID(ctx context.Context, id string) (*Order, error)
	FindByUserID(ctx context.Context, userID string) ([]*Order, error)
	UpdateStatus(ctx context.Context, id string, status OrderStatus) error
}

type OrderService struct {
	repo       OrderRepository
	userClient UserClient
}

func NewOrderService(repo OrderRepository, userClient UserClient) *OrderService {
	return &OrderService{repo: repo, userClient: userClient}
}

// PlaceOrder adalah use case utama Order Service
func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*OrderOutput, error) {
	// Validasi user via User Service — komunikasi antar service
	if err := s.userClient.ValidateActive(ctx, input.UserID); err != nil {
		return nil, errors.New("user tidak valid: " + err.Error())
	}

	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 := &Order{
		ID:         generateID(),
		UserID:     input.UserID,
		Status:     StatusPending,
		Items:      items,
		TotalCents: total,
		CreatedAt:  time.Now(),
	}

	// Simpan ke shared database — karena shared DB, bisa ACID
	if err := s.repo.Save(ctx, order); err != nil {
		return nil, err
	}

	return &OrderOutput{
		OrderID:    order.ID,
		Status:     string(order.Status),
		TotalCents: order.TotalCents,
	}, nil
}
// order-service/internal/client/user_client.go
// Client untuk memanggil User Service via HTTP
package client

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

type UserServiceClient struct {
	baseURL    string
	httpClient *http.Client
}

func NewUserServiceClient(baseURL string) *UserServiceClient {
	return &UserServiceClient{
		baseURL: baseURL,
		httpClient: &http.Client{
			Timeout: 3 * time.Second, // ✓ selalu ada timeout
		},
	}
}

type userResponse struct {
	ID       string `json:"id"`
	FullName string `json:"full_name"`
	IsActive bool   `json:"is_active"`
}

func (c *UserServiceClient) ValidateActive(ctx context.Context, userID string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		fmt.Sprintf("%s/users/%s", c.baseURL, userID), nil)
	if err != nil {
		return err
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("user service tidak tersedia: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		return fmt.Errorf("user %s tidak ditemukan", userID)
	}
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("user service error: status %d", resp.StatusCode)
	}

	var user userResponse
	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
		return err
	}

	if !user.IsActive {
		return fmt.Errorf("user %s tidak aktif", userID)
	}
	return nil
}

func (c *UserServiceClient) GetName(ctx context.Context, userID string) (string, error) {
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
		fmt.Sprintf("%s/users/%s", c.baseURL, userID), nil)
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	var user userResponse
	json.NewDecoder(resp.Body).Decode(&user)
	return user.FullName, nil
}

Struktur Repository Monorepo vs Polyrepo #

SBA bisa menggunakan monorepo (semua service dalam satu repository) atau polyrepo (setiap service punya repository sendiri):

flowchart LR
    subgraph MONO["Monorepo — Semua service dalam satu repo"]
        R["root/"]
        R --> US2["user-service/"]
        R --> OS2["order-service/"]
        R --> PS2["payment-service/"]
        R --> NS2["notification-service/"]
        R --> DB2["db/migrations/"]
        R --> LIB2["shared/\n(common library)"]
    end

    subgraph POLY["Polyrepo — Setiap service punya repo sendiri"]
        R1["user-service/\n(repo terpisah)"]
        R2["order-service/\n(repo terpisah)"]
        R3["payment-service/\n(repo terpisah)"]
        R4["shared-lib/\n(go module terpisah)"]
    end
Aspek Monorepo Polyrepo
Refactor lintas service Mudah — satu PR Sulit — koordinasi multi-PR
CI/CD Lebih kompleks — perlu detect perubahan Lebih sederhana per service
Shared code Mudah — direct import Perlu versioning Go module
Team independence Lebih rendah Lebih tinggi
Cocok untuk Tim yang sering refactor lintas service Tim yang ingin full independence

Untuk SBA dengan tim kecil–menengah, monorepo sering lebih produktif — perubahan lintas service bisa dilakukan dalam satu PR, migration database bisa dikelola bersama, dan shared utilities bisa di-import langsung.


Memanfaatkan Shared Database: Transaksi Lintas Tabel #

Keunggulan paling praktis SBA dibanding microservices adalah kemampuan melakukan ACID transaction yang span tabel milik “service” berbeda — ketika bisnis membutuhkannya:

// order-service/internal/repository/order_repo.go
// Contoh: PlaceOrder + UpdateInventory dalam satu ACID transaction
// Ini TIDAK MUNGKIN di microservices tanpa saga yang kompleks

func (r *OrderRepository) PlaceOrderWithInventory(
	ctx context.Context,
	order *Order,
	productID string,
	quantity int,
) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// Insert order
	_, err = tx.ExecContext(ctx,
		`INSERT INTO orders (id, user_id, status, total_cents, created_at)
		 VALUES ($1, $2, $3, $4, NOW())`,
		order.ID, order.UserID, order.Status, order.TotalCents,
	)
	if err != nil {
		return fmt.Errorf("gagal insert order: %w", err)
	}

	// Update inventory — tabel milik "inventory domain" tapi dalam DB yang sama
	// ✓ Di SBA ini valid — satu DB, satu transaction
	result, err := tx.ExecContext(ctx,
		`UPDATE products SET stock = stock - $1
		 WHERE id = $2 AND stock >= $1`,
		quantity, productID,
	)
	if err != nil {
		return fmt.Errorf("gagal update inventory: %w", err)
	}

	rows, _ := result.RowsAffected()
	if rows == 0 {
		return errors.New("stok tidak cukup")
	}

	return tx.Commit()
}
Gunakan shared transaction dengan bijak. Meskipun sah di SBA, ketergantungan antar tabel yang terlalu dalam di transaction akan mempersulit ekstraksi service di masa depan. Dokumentasikan setiap cross-service transaction dan pertimbangkan apakah bisa digantikan dengan eventual consistency saat sistem berevolusi.

Deployment: Terpisah tapi Terkoordinasi #

# docker-compose.yml — contoh deployment SBA untuk development
version: '3.8'

services:
  # Shared database — satu DB untuk semua service
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"

  # User Service — deployment terpisah
  user-service:
    build: ./user-service
    ports:
      - "8081:8081"
    environment:
      DATABASE_URL: postgres://app:secret@postgres/appdb
      PORT: "8081"
    depends_on:
      - postgres

  # Order Service — deployment terpisah, tahu URL User Service
  order-service:
    build: ./order-service
    ports:
      - "8082:8082"
    environment:
      DATABASE_URL: postgres://app:secret@postgres/appdb
      USER_SERVICE_URL: http://user-service:8081
      PORT: "8082"
    depends_on:
      - postgres
      - user-service

  # Payment Service
  payment-service:
    build: ./payment-service
    ports:
      - "8083:8083"
    environment:
      DATABASE_URL: postgres://app:secret@postgres/appdb
      ORDER_SERVICE_URL: http://order-service:8082
      PORT: "8083"
    depends_on:
      - postgres
      - order-service

  # Notification Service
  notification-service:
    build: ./notification-service
    ports:
      - "8084:8084"
    environment:
      DATABASE_URL: postgres://app:secret@postgres/appdb
      PORT: "8084"
    depends_on:
      - postgres

  # API Gateway — routing semua request ke service yang tepat
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - user-service
      - order-service
      - payment-service

SBA vs Microservices: Kapan Cukup, Kapan Perlu Berevolusi #

flowchart TD
    Q1{Ada bottleneck\nyang konkret?} -->|Tidak| SBA_OK["SBA sudah cukup\n✓ Pertahankan"]
    Q1 -->|Ya| Q2{Bottleneck di mana?}

    Q2 -->|"Scaling: satu service\nbuttleneck resource"| MS1["Ekstrak service itu\nke microservice dengan DB sendiri"]
    Q2 -->|"Deployment: satu service\nblokir yang lain"| MS2["Pisahkan pipeline CI/CD\n(sudah ada di SBA)"]
    Q2 -->|"Database: shared DB\njadi bottleneck"| MS3["Mulai pisahkan schema\nper service\n→ menuju microservices"]
    Q2 -->|"Tim: terlalu besar\nuntuk satu codebase"| MS4["Pertimbangkan polyrepo\ndan full microservices"]
SBA tetap menjadi pilihan tepat selama:
  ✓ Tim 5–30 developer dengan ownership domain yang jelas
  ✓ Tidak ada satu service yang butuh resource 10x lebih banyak dari yang lain
  ✓ Shared database belum menjadi bottleneck (bisa di-tune dengan index dan connection pool)
  ✓ ACID transaction lintas domain bisnis masih dibutuhkan secara reguler
  ✓ Biaya operasional microservices tidak justify manfaatnya

Pertimbangkan berevolusi ke full microservices jika:
  ✗ Shared database menjadi bottleneck yang tidak bisa diselesaikan
  ✗ Satu service butuh teknologi yang incompatible (Python untuk ML, dll)
  ✗ Tim sudah > 30 developer dengan full domain ownership
  ✗ Satu service perlu scaling 100x dibanding yang lain
  ✗ Regulatory/compliance membutuhkan isolasi data penuh

Anti-Pattern yang Harus Dihindari #

// ✗ Service memanggil database service lain langsung
// order-service mengakses tabel milik user-service
func (r *OrderRepo) GetOrderWithUserDetails(ctx context.Context, orderID string) (*OrderDetail, error) {
	// ✗ ORDER service langsung JOIN ke tabel USER — coupling database langsung
	row := r.db.QueryRowContext(ctx, `
		SELECT o.id, o.total_cents, u.full_name, u.email
		FROM orders o
		JOIN users u ON u.id = o.user_id  -- ✗ tabel users bukan milik order-service
		WHERE o.id = $1
	`, orderID)
	_ = row
	return nil, nil
}

// ✓ Ambil data user via API, bukan via DB JOIN
func (s *OrderService) GetOrderWithUserDetails(ctx context.Context, orderID string) (*OrderDetail, error) {
	order, err := s.repo.FindByID(ctx, orderID)
	if err != nil {
		return nil, err
	}

	// ✓ Ambil data user dari User Service via HTTP API
	userName, err := s.userClient.GetName(ctx, order.UserID)
	if err != nil {
		userName = "Unknown" // ✓ graceful degradation
	}

	return &OrderDetail{
		OrderID:    order.ID,
		UserName:   userName,
		TotalCents: order.TotalCents,
	}, nil
}

// ✗ Tidak ada timeout pada inter-service call — goroutine menunggu selamanya
func (c *UserClient) GetUser(ctx context.Context, id string) (*User, error) {
	resp, err := http.Get(c.baseURL + "/users/" + id) // ✗ no timeout!
	_ = resp
	return nil, err
}

// ✓ Selalu ada timeout — baik di context maupun di http.Client
func (c *UserClient) GetUser(ctx context.Context, id string) (*User, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second) // ✓ bounded wait
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/users/"+id, nil)
	resp, err := c.http.Do(req)
	_ = resp
	return nil, err
}

// ✗ Service lain write langsung ke tabel milik service berbeda
// notification-service menulis langsung ke tabel orders
func (n *NotificationService) MarkOrderNotified(ctx context.Context, orderID string) error {
	// ✗ notification-service tidak boleh write ke tabel orders
	_, err := n.db.ExecContext(ctx,
		`UPDATE orders SET notified = TRUE WHERE id = $1`, orderID,
	)
	return err
}

// ✓ Gunakan API untuk meminta service pemilik melakukan update
func (n *NotificationService) MarkOrderNotified(ctx context.Context, orderID string) error {
	// ✓ Kirim request ke Order Service untuk update status notifikasi
	return n.orderClient.MarkNotified(ctx, orderID)
}

Checklist Review Service-Based Architecture #

SERVICE BOUNDARY:
  □ Setiap service punya domain bisnis yang jelas dan tidak tumpang tindih
  □ Jumlah service 4–12 — tidak terlalu sedikit (= monolith) atau terlalu banyak (= microservices)
  □ Setiap service punya pipeline CI/CD sendiri
  □ Developer tahu dengan jelas tabel mana yang "dimiliki" oleh service mana

DATABASE OWNERSHIP:
  □ Tabel ownership terdokumentasi secara eksplisit
  □ Tidak ada service yang write langsung ke tabel milik service lain
  □ Cross-service read via JOIN terdokumentasi dan diminimalkan
  □ Cross-service transaction yang ada di-audit secara berkala

KOMUNIKASI ANTAR SERVICE:
  □ Setiap inter-service HTTP call punya timeout
  □ Graceful degradation jika service lain tidak tersedia
  □ Interface (Go interface) digunakan untuk dependency ke service lain
  □ API contract terdokumentasi (minimal README per service)

SHARED CODE:
  □ Shared utilities ada di package terpisah (shared/ atau common/)
  □ Tidak ada circular dependency antar service
  □ DTOs yang dibagikan antar service menggunakan struktur yang stabil

OBSERVABILITY:
  □ Structured logging di semua service
  □ Request ID / trace ID dipropagasi lintas service
  □ Health check endpoint tersedia di setiap service
  □ Centralized logging sudah dikonfigurasi

PENGUJIAN:
  □ Unit test setiap service bisa berjalan tanpa service lain (mock client)
  □ Integration test ada untuk flow utama lintas service
  □ go test -race dijalankan secara rutin

Ringkasan #

  • SBA adalah jembatan pragmatis antara monolith dan microservices — bukan kompromi yang setengah-setengah, tapi keputusan disengaja yang memberikan deployment granularity tanpa overhead penuh distributed system.
  • Shared database adalah fitur, bukan bug — kemampuan menggunakan ACID transaction lintas domain adalah keunggulan nyata SBA yang tidak dimiliki microservices; gunakan ini secara bijak.
  • Tabel ownership harus didokumentasikan eksplisit — setiap tabel dimiliki oleh satu service; service lain tidak boleh write dan sebaiknya tidak read langsung; ini mencegah distributed monolith.
  • 4 hingga 12 service adalah sweet spot — di bawah itu tidak berbeda jauh dari monolith; di atasnya overhead koordinasi meningkat signifikan.
  • Inter-service communication via HTTP client dengan timeout — setiap call ke service lain harus punya timeout; gunakan Go interface untuk dependency antar service.
  • Graceful degradation untuk dependency yang tidak kritis — jika User Service tidak tersedia, Order Service masih bisa menampilkan order dengan nama “Unknown”; jangan fail hard untuk semua dependency.
  • Monorepo sering lebih produktif untuk tim kecil–menengah — refactor lintas service lebih mudah, migration database bisa dikelola bersama, shared code bisa di-import langsung.
  • Dokumentasikan cross-service transaction — setiap ACID transaction yang span tabel milik service berbeda adalah technical debt potensial; catat ini untuk memudahkan evolusi ke microservices jika diperlukan.
  • SBA bisa menjadi end-game, bukan hanya stepping stone — banyak sistem bisnis tidak perlu microservices; SBA yang dikelola dengan baik bisa melayani puluhan developer selama bertahun-tahun.
  • Evolusi bertahap jika ada kebutuhan nyata — ekstrak service dengan shared DB menjadi service dengan DB sendiri saat ada bottleneck konkret, bukan karena tekanan industri.

← Sebelumnya: Microservice Architecture   Berikutnya: Event-Driven Architecture →

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