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 →