SSOT — Single Source of Truth #
Di sebuah sistem yang sudah berjalan beberapa tahun, ada satu pertanyaan yang paling sering memakan waktu investigasi: “Mana data yang benar?” Order service menyimpan status user sebagai "ACTIVE", notification service menggunakan "ENABLED", dan frontend punya mapping sendiri antara keduanya. Tidak ada yang salah secara teknis — masing-masing bekerja sesuai konteksnya. Tapi ketika ada bug di mana notifikasi tidak terkirim untuk user yang aktif, investigasinya menjadi panjang karena tidak ada jawaban tunggal untuk pertanyaan “apa artinya ‘aktif’?” Inilah masalah yang diselesaikan oleh SSOT — Single Source of Truth. Prinsipnya: setiap data, aturan bisnis, atau fakta penting dalam sistem harus memiliki satu sumber otoritatif yang tunggal dan jelas. Bukan karena desain yang bagus terasa lebih rapi, tapi karena tanpa SSOT, bug yang paling sulit dilacak adalah bug yang muncul bukan dari kode yang salah, tapi dari dua tempat yang punya “kebenaran” yang berbeda.
Apa Itu SSOT? #
Single Source of Truth adalah prinsip desain yang menyatakan:
Untuk setiap potongan data, aturan, atau fakta penting dalam sistem, harus ada satu dan hanya satu sumber yang bersifat otoritatif. Semua bagian lain sistem mengacu ke sumber itu — bukan menyimpan salinannya sendiri.
“Sumber” di sini tidak harus berupa database. SSOT bisa berupa package Go, struct domain, file konfigurasi, API dari service tertentu, atau schema yang di-publish. Yang terpenting bukan lokasinya, tapi otoritasnya jelas — semua orang tahu, “untuk fakta X, cek di sini”.
SSOT bukan hanya tentang database:
Fakta / Aturan SSOT-nya bisa berupa
───────────────────────── ──────────────────────────────────
Status user yang valid domain/user/status.go (typed enum)
Aturan perhitungan PPN domain/pricing/tax.go
Format nomor telepon pkg/validator/phone.go
Konfigurasi timeout Config struct dari env variable
Contract API antar service Proto file atau OpenAPI spec
Schema event Kafka JSON Schema atau Avro schema
Aturan diskon per tier domain/pricing/discount.go
SSOT dan DRY sering terlihat mirip tapi berbicara di level yang berbeda. DRY berbicara tentang tidak menduplikasi implementasi kode. SSOT berbicara tentang tidak menduplikasi otoritas atas sebuah fakta. Keduanya saling melengkapi:
DRY: "Jangan tulis logika yang sama dua kali."
SSOT: "Jangan buat dua tempat yang sama-sama mengklaim
sebagai kebenaran untuk fakta yang sama."
Contoh perbedaannya:
Dua fungsi yang menghitung hal yang sama = DRY violation
Dua service yang masing-masing menyimpan status user
dan keduanya bisa berbeda = SSOT violation
Tiga Kategori Masalah Tanpa SSOT #
Ketika SSOT dilanggar, masalah yang muncul biasanya masuk ke salah satu dari tiga kategori ini:
1. Inkonsistensi data yang sulit dilacak. Dua representasi yang seharusnya berarti hal yang sama ternyata berbeda nilainya. User dengan status "ACTIVE" di satu service ternyata statusnya "ENABLED" di service lain, dan tidak ada yang tahu kapan divergensi ini terjadi atau mana yang “benar”. Bug yang muncul dari kondisi ini sering sulit direproduksi karena bergantung pada urutan operasi antar service.
2. Duplikasi aturan bisnis yang tidak sinkron. Aturan yang sama diimplementasikan ulang di beberapa tempat secara independen. Awalnya hasilnya sama, tapi seiring waktu salah satu diupdate dan yang lain tidak. Perhitungan diskon di API dan di worker background menjadi berbeda setelah ada sprint perubahan pricing yang hanya menyentuh salah satu.
3. Biaya perubahan yang tidak proporsional. Mengubah satu aturan bisnis membutuhkan grep di seluruh codebase untuk menemukan semua tempat yang mengimplementasikan aturan itu. Setiap perubahan menjadi high-risk karena tidak ada jaminan semua salinan sudah diperbarui.
SSOT untuk Domain Status dan Enum #
Salah satu bentuk SSOT violation yang paling umum dan paling mudah masuk tanpa terdeteksi adalah status atau enum yang didefinisikan ulang di setiap tempat yang membutuhkannya.
// ANTI-PATTERN: status user didefinisikan di setiap tempat yang memakainya
// Masing-masing mengklaim otoritas atas makna "user aktif"
// order/handler.go
func canPlaceOrder(userStatus string) bool {
return userStatus == "ACTIVE" // asumsi: "ACTIVE" = aktif
}
// notification/worker.go
func shouldSendPromo(userStatus string) bool {
return userStatus == "ENABLED" // asumsi berbeda: "ENABLED" = aktif
}
// billing/service.go
func canCharge(userStatus string) bool {
return userStatus == "active" // case-sensitive typo yang valid secara sintaks
}
// report/query.go — SQL query langsung dengan string literal
// WHERE status = 'ACTIVE' OR status = 'enabled' -- patch darurat
// Hasilnya: empat "kebenaran" berbeda tentang apa artinya user aktif
// Bug: user yang statusnya "ENABLED" tidak mendapat promo,
// tapi bisa melakukan order — inkonsistensi yang valid secara teknis
// BENAR: satu definisi, semua pakai yang sama
// domain/user/status.go
package user
// Status adalah typed string untuk mencegah penggunaan string literal sembarangan
type Status string
const (
StatusActive Status = "ACTIVE"
StatusInactive Status = "INACTIVE"
StatusSuspended Status = "SUSPENDED"
StatusPending Status = "PENDING"
)
// IsActive menjadi satu-satunya definisi "apa artinya user aktif"
func (s Status) IsActive() bool {
return s == StatusActive
}
// CanReceiveNotification adalah domain rule yang melekat pada status
func (s Status) CanReceiveNotification() bool {
return s == StatusActive || s == StatusPending
}
// CanPlaceOrder adalah domain rule lainnya
func (s Status) CanPlaceOrder() bool {
return s == StatusActive
}
// IsValid memvalidasi bahwa status yang diterima dari luar adalah nilai yang dikenal
func (s Status) IsValid() bool {
switch s {
case StatusActive, StatusInactive, StatusSuspended, StatusPending:
return true
}
return false
}
Dengan typed enum seperti ini, compiler akan menolak perbandingan antara user.Status dengan string literal sembarang — ia harus melalui konstanta yang sudah terdefinisi. Ini membuat inkonsistensi yang tadinya hanya terdeteksi saat runtime (atau tidak terdeteksi sama sekali) menjadi compile error.
// Semua consumer menggunakan definisi yang sama
// order/handler.go
func canPlaceOrder(u user.User) bool {
return u.Status.CanPlaceOrder() // tidak ada string literal
}
// notification/worker.go
func shouldSendNotification(u user.User) bool {
return u.Status.CanReceiveNotification() // definisi yang sama
}
// billing/service.go
func canCharge(u user.User) bool {
return u.Status.CanPlaceOrder() // konsisten
}
flowchart TD
SSOT["domain/user/status.go\nSSOT untuk status user"]
A["order/handler.go\nu.Status.CanPlaceOrder()"]
B["notification/worker.go\nu.Status.CanReceiveNotification()"]
C["billing/service.go\nu.Status.CanPlaceOrder()"]
D["report/query.go\nu.Status.IsActive()"]
SSOT -->|"single definition"| A
SSOT -->|"single definition"| B
SSOT -->|"single definition"| C
SSOT -->|"single definition"| D
style SSOT fill:#4C9BE8,color:#fffSSOT untuk Business Rules #
Business rules yang tersebar di beberapa tempat adalah kandidat utama untuk dijadikan SSOT. Ini terutama penting untuk aturan yang melibatkan angka atau threshold — karena angka yang sama di dua tempat bisa berarti hal yang berbeda, dan ketika harus diubah, satu tempat sering terlewat.
// ANTI-PATTERN: business rules tersebar tanpa sumber tunggal
// order/service.go
func applyDiscount(price float64, userTier string) float64 {
switch userTier {
case "gold":
return price * 0.85 // diskon 15%
case "platinum":
return price * 0.75 // diskon 25%
default:
return price
}
}
// cart/service.go — mengira aturannya sama, tapi ada perbedaan subtle
func calculateCartTotal(items []Item, userTier string) float64 {
total := 0.0
for _, item := range items {
total += item.Price
}
switch userTier {
case "gold":
return total * 0.80 // ← 20%, bukan 15%! Divergensi yang tidak disengaja
case "platinum":
return total * 0.75
default:
return total
}
}
// invoice/generator.go — versi ketiga dengan format berbeda lagi
func applyMemberDiscount(subtotal float64, tier string) float64 {
discountRates := map[string]float64{
"gold": 0.15,
"platinum": 0.25,
}
rate, ok := discountRates[tier]
if !ok {
return subtotal
}
return subtotal * (1 - rate)
}
// order dan cart menggunakan multiplier, invoice menggunakan rate — beda cara, beda angka
// BENAR: satu domain model sebagai SSOT untuk semua aturan pricing
// domain/pricing/membership.go
package pricing
// MemberTier merepresentasikan tier keanggotaan dengan semua business rule-nya
type MemberTier string
const (
TierRegular MemberTier = "regular"
TierGold MemberTier = "gold"
TierPlatinum MemberTier = "platinum"
)
// DiscountRate adalah satu-satunya definisi resmi berapa diskon per tier
func (t MemberTier) DiscountRate() float64 {
switch t {
case TierGold:
return 0.15 // 15% — satu-satunya tempat angka ini terdefinisi
case TierPlatinum:
return 0.25 // 25% — satu-satunya tempat angka ini terdefinisi
default:
return 0
}
}
// Apply mengaplikasikan diskon ke harga dan mengembalikan hasil beserta detail
func (t MemberTier) Apply(price float64) DiscountResult {
rate := t.DiscountRate()
discount := price * rate
return DiscountResult{
OriginalPrice: price,
DiscountRate: rate,
DiscountAmount: discount,
FinalPrice: price - discount,
}
}
type DiscountResult struct {
OriginalPrice float64
DiscountRate float64
DiscountAmount float64
FinalPrice float64
}
// Semua consumer menggunakan MemberTier.Apply() — tidak ada magic number tersebar
// order/service.go
func processOrderDiscount(price float64, tier pricing.MemberTier) pricing.DiscountResult {
return tier.Apply(price)
}
// cart/service.go
func calculateCartDiscount(total float64, tier pricing.MemberTier) pricing.DiscountResult {
return tier.Apply(total)
}
// invoice/generator.go
func generateInvoiceDiscount(subtotal float64, tier pricing.MemberTier) pricing.DiscountResult {
return tier.Apply(subtotal)
}
// Ketika diskon gold berubah jadi 20%, ubah satu baris di pricing/membership.go
// Semua service otomatis menggunakan nilai baru
SSOT untuk Konfigurasi #
Konfigurasi yang hardcoded atau tersebar adalah salah satu bentuk SSOT violation yang paling sering menyebabkan environment-specific bug — kode yang berjalan di development tapi berperilaku berbeda di production karena ada nilai konfigurasi yang berbeda di dua tempat.
// ANTI-PATTERN: konfigurasi tersebar tanpa sumber tunggal
// db/connection.go
func connectDB() *sql.DB {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
db.SetMaxOpenConns(10) // ← hardcoded, berbeda di staging vs production
db.SetConnMaxLifetime(5 * time.Minute) // ← tidak konsisten
return db
}
// http/client.go
func newHTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second, // ← berbeda dengan timeout di gRPC client
}
}
// grpc/client.go
func newGRPCConn() *grpc.ClientConn {
ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) // ← 10s, bukan 30s
conn, _ := grpc.DialContext(ctx, grpcAddr, grpc.WithBlock())
return conn
}
// queue/consumer.go
func newConsumer() *kafka.Reader {
return kafka.NewReader(kafka.ReaderConfig{
MaxWait: 3 * time.Second,
MaxAttempts: 3, // ← berbeda dengan retry di HTTP client yang pakai 5
})
}
// BENAR: semua konfigurasi dibaca dari satu struct, dibagikan ke semua komponen
// config/config.go
package config
import (
"time"
"github.com/caarlos0/env/v11"
)
type Config struct {
Database DatabaseConfig
HTTP HTTPConfig
GRPC GRPCConfig
Queue QueueConfig
}
type DatabaseConfig struct {
DSN string `env:"DATABASE_URL,required"`
MaxOpenConns int `env:"DB_MAX_OPEN_CONNS" envDefault:"10"`
MaxIdleConns int `env:"DB_MAX_IDLE_CONNS" envDefault:"5"`
ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m"`
}
type HTTPConfig struct {
Timeout time.Duration `env:"HTTP_TIMEOUT" envDefault:"30s"`
MaxRetries int `env:"HTTP_MAX_RETRIES" envDefault:"3"`
}
type GRPCConfig struct {
Timeout time.Duration `env:"GRPC_TIMEOUT" envDefault:"10s"`
MaxRetries int `env:"GRPC_MAX_RETRIES" envDefault:"3"`
}
type QueueConfig struct {
Brokers string `env:"KAFKA_BROKERS,required"`
MaxWait time.Duration `env:"KAFKA_MAX_WAIT" envDefault:"3s"`
MaxAttempts int `env:"KAFKA_MAX_ATTEMPTS" envDefault:"3"`
}
func Load() (*Config, error) {
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
return cfg, nil
}
// main.go: baca sekali, inject ke semua komponen
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db := database.Connect(cfg.Database)
httpClient := httpclient.New(cfg.HTTP)
grpcConn := grpcclient.New(cfg.GRPC)
consumer := queue.NewConsumer(cfg.Queue)
// Semua menggunakan Config struct yang sama — satu sumber kebenaran
}
SSOT untuk Schema dan Contract API #
Di sistem dengan multiple service atau multiple client (web, mobile, backend), schema adalah salah satu SSOT yang paling kritikal. Ketika schema API didefinisikan secara informal di dokumentasi atau di kode masing-masing service, drift antara producer dan consumer tidak bisa dicegah.
// ANTI-PATTERN: request/response struct didefinisikan ulang di setiap tempat
// yang berkomunikasi — producer dan consumer masing-masing punya definisi sendiri
// user-service/api/handler.go (producer)
type CreateUserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt string `json:"created_at"` // ← string, bukan time.Time
}
// order-service/client/user_client.go (consumer)
type UserData struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"` // ← time.Time, akan gagal unmarshal
// field "email" kadang kosong tapi tidak ada nil check — bug laten
}
// Masalah:
// - Kedua struct ini tidak pernah dibandingkan secara formal
// - Jika user-service tambah field "phone", order-service tidak tahu
// - Format createdAt berbeda → silent parse error di production
// BENAR: shared contract dalam satu package yang di-import bersama
// Opsi 1: shared Go module (untuk internal monorepo)
// pkg/contract/user/v1/user.go
package userv1
import "time"
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// user-service menggunakan contract ini untuk response
// order-service menggunakan contract yang sama untuk parsing
// Jika struct berubah, semua consumer dapat compile error — bukan silent failure
// Opsi 2: protobuf/gRPC untuk service yang berbeda bahasa (lebih umum)
// user.proto — satu file definisi, generate code untuk semua bahasa
flowchart LR
CONTRACT["pkg/contract/user/v1\n(SSOT untuk schema)"]
US["user-service\n(producer)"]
OS["order-service\n(consumer)"]
NS["notification-service\n(consumer)"]
WEB["web-client\n(consumer)"]
CONTRACT -->|"import & use"| US
CONTRACT -->|"import & use"| OS
CONTRACT -->|"import & use"| NS
CONTRACT -->|"generate TypeScript"| WEB
style CONTRACT fill:#4C9BE8,color:#fffSSOT dalam Arsitektur Microservices #
Di arsitektur microservices, SSOT sering disalahartikan sebagai “semua service boleh baca database yang sama”. Itu bukan SSOT — itu coupling yang berbahaya. Prinsip yang benar:
SSOT di microservices bukan berarti:
✗ Semua service share satu database
✗ Semua service bisa langsung query ke tabel user
✗ Data di-replicate ke setiap service yang butuh
SSOT di microservices berarti:
✓ Setiap service adalah SSOT untuk domain yang ia miliki
✓ Service lain mengakses data melalui API atau event, bukan langsung ke DB
✓ Data yang "dicopy" ke service lain (eventual consistency) jelas owner-nya
flowchart TD
US["User Service\nSSOT: data user, status user,\npreferensi user"]
OS["Order Service\nSSOT: data order, status order,\nriwayat transaksi"]
IS["Inventory Service\nSSOT: stok produk,\nreservasi"]
NS["Notification Service\nSSOT: preferensi notifikasi,\nriwayat notifikasi"]
OS -->|"GET /users/:id (bukan direct DB)"| US
NS -->|"GET /users/:id/preferences"| US
OS -->|"POST /inventory/reserve"| IS
style US fill:#4C9BE8,color:#fff
style OS fill:#5CB85C,color:#fff
style IS fill:#F0AD4E,color:#fff
style NS fill:#9B59B6,color:#fffKetika Order Service perlu tahu nama user untuk invoice, ia memanggil User Service API — bukan menyimpan nama user di database Order Service sendiri. User Service adalah SSOT untuk semua data tentang user. Jika nama user berubah, Order Service secara natural akan selalu mendapat nama yang benar karena mengambil dari sumbernya.
Namun ada trade-off: network call lebih lambat dari database query lokal. Untuk data yang sering diakses dan jarang berubah, caching dengan TTL yang sesuai adalah pendekatan yang benar — bukan menduplikasi data ke database lokal.
// Pola caching yang tetap menghormati SSOT
type UserCache struct {
client UserServiceClient
cache *redis.Client
ttl time.Duration
}
func (c *UserCache) GetUser(ctx context.Context, id string) (*User, error) {
// Coba cache dulu
cached, err := c.cache.Get(ctx, "user:"+id).Result()
if err == nil {
var u User
json.Unmarshal([]byte(cached), &u)
return &u, nil
}
// Cache miss: ambil dari SSOT (User Service)
u, err := c.client.GetUser(ctx, id)
if err != nil {
return nil, err
}
// Simpan ke cache dengan TTL — bukan copy permanen
data, _ := json.Marshal(u)
c.cache.Set(ctx, "user:"+id, data, c.ttl)
return u, nil
}
// Cache adalah optimasi performa, bukan duplikasi SSOT
// User Service tetap satu-satunya sumber kebenaran untuk data user
SSOT untuk Pesan Error dan Label UI #
SSOT juga berlaku untuk hal-hal yang sering dianggap sepele seperti pesan error dan label. Pesan error yang sama ditulis ulang di setiap tempat yang bisa menghasilkannya adalah SSOT violation yang berdampak pada konsistensi pengalaman pengguna.
// ANTI-PATTERN: pesan error yang sama dalam berbagai variasi
// handler A: "email already exists"
// handler B: "email already registered"
// service C: "duplicate email"
// repository D: "email constraint violation"
// Semua berarti hal yang sama, tapi user mendapat pesan berbeda
// BENAR: error sentinel yang terdefinisi dengan pesan yang konsisten
// domain/user/errors.go
package user
import "errors"
// Error sentinel — SSOT untuk semua error domain user
var (
ErrEmailAlreadyRegistered = errors.New("email already registered")
ErrUserNotFound = errors.New("user not found")
ErrInvalidCredentials = errors.New("invalid email or password")
ErrAccountSuspended = errors.New("account is suspended")
ErrWeakPassword = errors.New("password does not meet requirements")
)
// Handler menerjemahkan error domain ke HTTP status
// Semua pesan error sudah terdefinisi di satu tempat
// api/error_handler.go
func domainErrToHTTP(err error) (int, string) {
switch {
case errors.Is(err, user.ErrEmailAlreadyRegistered):
return http.StatusConflict, err.Error()
case errors.Is(err, user.ErrUserNotFound):
return http.StatusNotFound, err.Error()
case errors.Is(err, user.ErrInvalidCredentials):
return http.StatusUnauthorized, err.Error()
case errors.Is(err, user.ErrAccountSuspended):
return http.StatusForbidden, err.Error()
default:
return http.StatusInternalServerError, "internal server error"
}
}
Kapan SSOT Bisa Menjadi Beban #
SSOT tidak gratis — ada trade-off yang perlu dipertimbangkan:
SSOT BISA MENJADI BEBAN KETIKA:
1. Shared package yang terlalu besar dan generik
Jika "package domain" menampung semua domain sekaligus, ia menjadi
coupling point — perubahan di satu domain bisa memaksa semua consumer
recompile, bahkan yang tidak terkait.
Solusi: satu package per bounded context, bukan satu "god domain package"
2. Over-sharing di microservices
Jika beberapa service bergantung pada shared library yang sama,
update library memaksa semua service di-deploy ulang secara bersamaan.
Solusi: shared contract hanya untuk API boundary, bukan internal logic
3. Terlalu banyak indirection untuk data yang tidak benar-benar di-share
Jika setiap konstanta harus di-import dari package domain meskipun
hanya dipakai di satu tempat, overhead lebih besar dari manfaatnya.
Solusi: terapkan SSOT untuk hal yang benar-benar di-share dan sering berubah
4. SSOT yang bottleneck
Dalam microservices, jika semua service harus synchronous call ke
satu "source service" untuk setiap operasi, SSOT menciptakan SPOF.
Solusi: event-driven dengan eventual consistency, atau caching dengan TTL
Shared package bukan SSOT yang baik jika terlalu besar. Package yang di-import oleh semua service dan berisi semua domain adalah coupling yang berbahaya — bukan SSOT. SSOT yang baik spesifik untuk satu domain atau satu bounded context, dan hanya yang benar-benar perlu di-share yang di-expose.
Anti-Pattern dalam Satu Pandangan #
// ✗ Status string literal tersebar tanpa tipe resmi
func canOrder(status string) bool { return status == "ACTIVE" }
func canNotify(status string) bool { return status == "ENABLED" } // beda!
// ✗ Business rule angka tersebar tanpa konstanta
func goldDiscount(p float64) float64 { return p * 0.85 } // order service
func goldDiscount(p float64) float64 { return p * 0.80 } // cart service — diverged!
// ✗ Konfigurasi hardcoded di setiap tempat yang butuh
db.SetMaxOpenConns(10) // db package
http.Client{Timeout: 30s} // http package — tidak ada hubungan antara keduanya
// ✗ Request/response struct didefinisikan ulang di setiap service
// user-service: CreateUserResponse{CreatedAt: string}
// order-service: UserData{CreatedAt: time.Time} — silent parse error
// ✗ Pesan error dalam berbagai variasi untuk hal yang sama
// "email already exists" / "duplicate email" / "email taken" / "email registered"
// Semua berarti hal yang sama, user mendapat pesan berbeda tergantung code path
// ✗ Service yang langsung query database service lain
// order-service: SELECT name FROM users WHERE id = ? -- bypassing User Service
Checklist Review SSOT #
DOMAIN STATUS DAN ENUM:
□ Semua status dan enum menggunakan typed string/int, bukan raw literal
□ Tidak ada perbandingan string literal untuk status di luar package domain
□ Business rules yang bergantung pada status ada sebagai method pada tipe itu sendiri
BUSINESS RULES:
□ Setiap angka threshold/rate ada sebagai konstanta bernama di domain package
□ Tidak ada perhitungan bisnis yang sama diimplementasikan ulang di dua tempat
□ Perubahan satu aturan bisnis hanya memerlukan perubahan di satu file
KONFIGURASI:
□ Semua nilai konfigurasi dibaca dari satu Config struct
□ Tidak ada magic number konfigurasi yang hardcoded di dalam komponen
□ Config struct dioper ke komponen, bukan dibaca ulang dari env di tiap komponen
SCHEMA DAN CONTRACT:
□ Request/response struct untuk API yang sama ada di satu tempat
□ Tidak ada struct yang "mirip" antara producer dan consumer tanpa shared source
□ Perubahan schema menghasilkan compile error di semua consumer yang terpengaruh
MICROSERVICES:
□ Tidak ada service yang query langsung ke database service lain
□ Data yang dicopy untuk performa (caching) jelas TTL-nya dan siapa owner-nya
□ Perubahan data di "source service" akan propagate ke consumer melalui event atau invalidasi cache
Ringkasan #
- SSOT adalah tentang otoritas, bukan lokasi — yang terpenting bukan di mana sumber itu berada, tapi bahwa semua pihak tahu inilah satu-satunya sumber kebenaran untuk fakta tersebut. SSOT bisa berupa package Go, Config struct, proto file, atau API.
- SSOT vs DRY: DRY tentang tidak menduplikasi implementasi kode; SSOT tentang tidak menduplikasi otoritas atas sebuah fakta. Keduanya saling melengkapi — DRY mencegah copy-paste logika, SSOT mencegah divergensi kebenaran.
- Domain status sebagai typed enum: gunakan typed string dan definisikan business rules sebagai method — bukan raw string literal tersebar. Compiler menjadi penjaga konsistensi.
- Business rules di domain package: angka threshold, rate, dan aturan bisnis hanya boleh ada di satu tempat. Ketika aturan berubah, satu baris kode — semua consumer otomatis ikut.
- Konfigurasi satu Config struct: baca dari environment variable sekali di startup, injeksikan ke semua komponen. Tidak ada magic number konfigurasi hardcoded di dalam implementasi.
- Shared contract untuk API schema: request/response struct yang sama digunakan oleh producer dan consumer dari satu sumber. Perubahan schema menghasilkan compile error — bukan silent parse failure di production.
- SSOT di microservices bukan shared database: setiap service adalah SSOT untuk domain-nya, dan service lain mengakses melalui API atau event. Caching dengan TTL adalah optimasi performa yang tetap menghormati SSOT.
- Kapan SSOT menjadi beban: shared package yang terlalu besar adalah coupling berbahaya; terapkan SSOT hanya untuk hal yang benar-benar di-share dan sering berubah. Satu package per bounded context, bukan satu “god domain package”.
- Kutipan kunci: “Jika sebuah kebenaran ada di dua tempat, cepat atau lambat salah satunya akan bohong.”