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
DimensiMonolithService-BasedMicroservices
Jumlah deployment unit14–1210–100+
Database1 shared1 shared (atau partial)1 per service
KomunikasiIn-processHTTP/REST antar serviceHTTP/gRPC + event
Team autonomyRendahSedangTinggi
Ops complexitySangat rendahRendah–sedangTinggi
TransactionACID penuhACID penuh (shared DB)Eventual consistency
Cocok untuk tim1–105–3020+

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
AspekMonorepoPolyrepo
Refactor lintas serviceMudah — satu PRSulit — koordinasi multi-PR
CI/CDLebih kompleks — perlu detect perubahanLebih sederhana per service
Shared codeMudah — direct importPerlu versioning Go module
Team independenceLebih rendahLebih tinggi
Cocok untukTim yang sering refactor lintas serviceTim 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