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:#fff

SSOT 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:#fff

SSOT 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:#fff

Ketika 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.”

← Sebelumnya: SRP   Berikutnya: SoC →

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