SSOT — Single Source of Truth #

In a system that’s been running for several years, there’s one question that eats up investigation time the most: “Which data is correct?” The order service stores user status as "ACTIVE", the notification service uses "ENABLED", and the frontend has its own mapping between the two. Nothing is technically wrong — each works within its own context. But when there’s a bug where notifications aren’t sent to active users, the investigation drags on because there’s no single answer to the question “what does ‘active’ mean?” This is the problem SSOT — Single Source of Truth — solves. The principle: every piece of data, business rule, or important fact in a system must have a single, clear, authoritative source. Not because good design feels tidier, but because without SSOT, the hardest bugs to trace are the ones that arise not from wrong code, but from two places holding different “truths”.

What Is SSOT? #

Single Source of Truth is a design principle stating:

For every piece of data, rule, or important fact in a system, there must be one and only one authoritative source. All other parts of the system refer to that source — rather than keeping their own copies.

“Source” here doesn’t have to be a database. SSOT can be a Go package, a domain struct, a config file, an API from a particular service, or a published schema. What matters isn’t its location, but its authority being clear — everyone knows, “for fact X, check here”.

SSOT isn't only about databases:

  Fact / Rule                     SSOT could be
  ─────────────────────────       ──────────────────────────────────
  Valid user statuses             domain/user/status.go (typed enum)
  VAT calculation rules           domain/pricing/tax.go
  Phone number format             pkg/validator/phone.go
  Timeout configuration           Config struct from env variables
  Inter-service API contracts     Proto files or OpenAPI specs
  Kafka event schemas             JSON Schema or Avro schemas
  Per-tier discount rules         domain/pricing/discount.go

SSOT and DRY often look similar but operate at different levels. DRY is about not duplicating code implementation. SSOT is about not duplicating authority over a fact. They complement each other:

DRY:  "Don't write the same logic twice."
SSOT: "Don't create two places that both claim
       to be the truth for the same fact."

An example of the difference:
  Two functions computing the same thing = DRY violation
  Two services each storing user status
  and both can differ = SSOT violation

Three Categories of Problems Without SSOT #

When SSOT is violated, the problems that appear usually fall into one of these three categories:

1. Hard-to-trace data inconsistency. Two representations that should mean the same thing turn out to have different values. A user with status "ACTIVE" in one service turns out to be "ENABLED" in another, and nobody knows when the divergence happened or which one is “correct”. Bugs arising from this condition are often hard to reproduce because they depend on the order of operations between services.

2. Out-of-sync business rule duplication. The same rule is reimplemented independently in several places. Results match at first, but over time one gets updated and the other doesn’t. Discount calculations in the API and in the background worker become different after a pricing sprint that only touched one of them.

3. Disproportionate change costs. Changing one business rule requires grepping the whole codebase to find every place implementing that rule. Every change becomes high-risk because there’s no guarantee all copies have been updated.


SSOT for Domain Status and Enums #

One of the most common SSOT violations — and the easiest to slip in undetected — is status or enums redefined in every place that needs them.

// ANTI-PATTERN: user status redefined in every place that uses it
// Each claims authority over the meaning of "active user"

// order/handler.go
func canPlaceOrder(userStatus string) bool {
    return userStatus == "ACTIVE" // assumption: "ACTIVE" = active
}

// notification/worker.go
func shouldSendPromo(userStatus string) bool {
    return userStatus == "ENABLED" // different assumption: "ENABLED" = active
}

// billing/service.go
func canCharge(userStatus string) bool {
    return userStatus == "active" // case-sensitive typo that's syntactically valid
}

// report/query.go — direct SQL with string literals
// WHERE status = 'ACTIVE' OR status = 'enabled' -- emergency patch

// Result: four different "truths" about what an active user means
// Bug: a user with status "ENABLED" doesn't get promos,
//      but can place orders — technically valid inconsistency

// CORRECT: one definition, everyone uses the same one
// domain/user/status.go
package user

// Status is a typed string to prevent arbitrary string literals
type Status string

const (
    StatusActive    Status = "ACTIVE"
    StatusInactive  Status = "INACTIVE"
    StatusSuspended Status = "SUSPENDED"
    StatusPending   Status = "PENDING"
)

// IsActive becomes the single definition of "what it means to be active"
func (s Status) IsActive() bool {
    return s == StatusActive
}

// CanReceiveNotification is a domain rule attached to the status
func (s Status) CanReceiveNotification() bool {
    return s == StatusActive || s == StatusPending
}

// CanPlaceOrder is another domain rule
func (s Status) CanPlaceOrder() bool {
    return s == StatusActive
}

// IsValid validates that an externally received status is a known value
func (s Status) IsValid() bool {
    switch s {
    case StatusActive, StatusInactive, StatusSuspended, StatusPending:
        return true
    }
    return false
}

With a typed enum like this, the compiler rejects comparing user.Status with arbitrary string literals — it must go through the defined constants. This turns inconsistencies that were only detectable at runtime (or not at all) into compile errors.

// All consumers use the same definition
// order/handler.go
func canPlaceOrder(u user.User) bool {
    return u.Status.CanPlaceOrder() // no string literals
}

// notification/worker.go
func shouldSendNotification(u user.User) bool {
    return u.Status.CanReceiveNotification() // the same definition
}

// billing/service.go
func canCharge(u user.User) bool {
    return u.Status.CanPlaceOrder() // consistent
}
flowchart TD
    SSOT["domain/user/status.go\nSSOT for user status"]
    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 for Business Rules #

Business rules scattered across several places are prime candidates for SSOT. This is especially important for rules involving numbers or thresholds — because the same number in two places can mean different things, and when it must change, one place often gets missed.

// ANTI-PATTERN: business rules scattered without a single source

// order/service.go
func applyDiscount(price float64, userTier string) float64 {
    switch userTier {
    case "gold":
        return price * 0.85   // 15% discount
    case "platinum":
        return price * 0.75   // 25% discount
    default:
        return price
    }
}

// cart/service.go — thinks the rules are the same, but there's a subtle difference
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%, not 15%! Unintended divergence
    case "platinum":
        return total * 0.75
    default:
        return total
    }
}

// invoice/generator.go — a third version with yet another format
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 and cart use multipliers, invoice uses rates — different ways, different numbers

// CORRECT: one domain model as the SSOT for all pricing rules

// domain/pricing/membership.go
package pricing

// MemberTier represents a membership tier with all its business rules
type MemberTier string

const (
    TierRegular  MemberTier = "regular"
    TierGold     MemberTier = "gold"
    TierPlatinum MemberTier = "platinum"
)

// DiscountRate is the only official definition of discount per tier
func (t MemberTier) DiscountRate() float64 {
    switch t {
    case TierGold:
        return 0.15 // 15% — the only place this number is defined
    case TierPlatinum:
        return 0.25 // 25% — the only place this number is defined
    default:
        return 0
    }
}

// Apply applies the discount to a price and returns the result with details
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
}

// All consumers use MemberTier.Apply() — no scattered magic numbers
// 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)
}
// When the gold discount changes to 20%, change one line in pricing/membership.go
// All services automatically use the new value

SSOT for Configuration #

Hardcoded or scattered configuration is one of the most common SSOT violations causing environment-specific bugs — code that works in development but behaves differently in production because a config value differs in two places.

// ANTI-PATTERN: config scattered without a single source

// db/connection.go
func connectDB() *sql.DB {
    db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    db.SetMaxOpenConns(10)  // ← hardcoded, different in staging vs production
    db.SetConnMaxLifetime(5 * time.Minute) // ← inconsistent
    return db
}

// http/client.go
func newHTTPClient() *http.Client {
    return &http.Client{
        Timeout: 30 * time.Second, // ← different from the gRPC client timeout
    }
}

// grpc/client.go
func newGRPCConn() *grpc.ClientConn {
    ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) // ← 10s, not 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, // ← different from the HTTP client's 5 retries
    })
}

// CORRECT: all config read from one struct, shared with every component

// 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: read once, inject into every component
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)
    // All use the same Config struct — one source of truth
}

SSOT for API Schemas and Contracts #

In systems with multiple services or multiple clients (web, mobile, backend), schemas are among the most critical SSOTs. When API schemas are defined informally in documentation or in each service’s own code, drift between producer and consumer can’t be prevented.

// ANTI-PATTERN: request/response structs redefined in every communicating
// place — producer and consumer each have their own definition

// 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, not 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, will fail to unmarshal
    // the "email" field is sometimes empty but there's no nil check — latent bug
}

// Problems:
// - These two structs are never formally compared
// - If user-service adds a "phone" field, order-service doesn't know
// - Different createdAt formats → silent parse errors in production

// CORRECT: a shared contract in one package imported by everyone

// Option 1: a shared Go module (for internal monorepos)
// 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 uses this contract for its responses
// order-service uses the same contract for parsing
// If the struct changes, all consumers get compile errors — not silent failures

// Option 2: protobuf/gRPC for services in different languages (more common)
// user.proto — one definition file, generates code for all languages
flowchart LR
    CONTRACT["pkg/contract/user/v1\n(SSOT for schemas)"]
    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 in Microservices Architecture #

In microservices architecture, SSOT is often misunderstood as “all services can read the same database”. That’s not SSOT — that’s dangerous coupling. The correct principle:

SSOT in microservices does NOT mean:
  ✗ All services share one database
  ✗ All services can directly query the users table
  ✗ Data replicated to every service that needs it

SSOT in microservices means:
  ✓ Each service is the SSOT for the domain it owns
  ✓ Other services access data through APIs or events, not directly to the DB
  ✓ Data "copied" to other services (eventual consistency) has a clear owner
flowchart TD
    US["User Service\nSSOT: user data, user status,\nuser preferences"]
    OS["Order Service\nSSOT: order data, order status,\ntransaction history"]
    IS["Inventory Service\nSSOT: product stock,\nreservations"]
    NS["Notification Service\nSSOT: notification preferences,\nnotification history"]

    OS -->|"GET /users/:id (not 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

When the Order Service needs a user’s name for an invoice, it calls the User Service API — not storing the user’s name in the Order Service’s own database. The User Service is the SSOT for all user data. If a user’s name changes, the Order Service naturally always gets the correct name because it fetches from the source.

However, there’s a trade-off: network calls are slower than local database queries. For frequently accessed, rarely changing data, caching with an appropriate TTL is the right approach — not duplicating data into a local database.

// A caching pattern that still respects SSOT
type UserCache struct {
    client  UserServiceClient
    cache   *redis.Client
    ttl     time.Duration
}

func (c *UserCache) GetUser(ctx context.Context, id string) (*User, error) {
    // Try the cache first
    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: fetch from the SSOT (User Service)
    u, err := c.client.GetUser(ctx, id)
    if err != nil {
        return nil, err
    }

    // Store in cache with a TTL — not a permanent copy
    data, _ := json.Marshal(u)
    c.cache.Set(ctx, "user:"+id, data, c.ttl)
    return u, nil
}
// A cache is a performance optimization, not SSOT duplication
// The User Service remains the only source of truth for user data

SSOT for Error Messages and UI Labels #

SSOT also applies to things often considered trivial, like error messages and labels. The same error message rewritten in every place that can produce it is an SSOT violation affecting user experience consistency.

// ANTI-PATTERN: the same error message in various variations
// handler A: "email already exists"
// handler B: "email already registered"
// service C: "duplicate email"
// repository D: "email constraint violation"
// All mean the same thing, but users get different messages

// CORRECT: defined sentinel errors with consistent messages

// domain/user/errors.go
package user

import "errors"

// Sentinel errors — SSOT for all user domain errors
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")
)

// The handler translates domain errors to HTTP statuses
// All error messages are defined in one place

// 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"
    }
}

When SSOT Can Become a Burden #

SSOT isn’t free — there are trade-offs to consider:

SSOT CAN BECOME A BURDEN WHEN:

  1. The shared package is too big and generic
     If a "domain package" holds every domain at once, it becomes a
     coupling point — a change in one domain can force all consumers
     to recompile, even unrelated ones.
     Solution: one package per bounded context, not one "god domain package"

  2. Over-sharing in microservices
     If several services depend on the same shared library,
     a library update forces all services to redeploy together.
     Solution: shared contracts only for API boundaries, not internal logic

  3. Too much indirection for data that isn't really shared
     If every constant must be imported from a domain package even though
     it's only used in one place, the overhead outweighs the benefit.
     Solution: apply SSOT to things genuinely shared and frequently changing

  4. SSOT as a bottleneck
     In microservices, if all services must synchronously call one
     "source service" for every operation, SSOT creates an SPOF.
     Solution: event-driven with eventual consistency, or TTL-based caching
A shared package isn’t good SSOT if it’s too big. A package imported by all services containing every domain is dangerous coupling — not SSOT. Good SSOT is specific to one domain or one bounded context, and only what truly needs sharing is exposed.

Anti-Patterns at a Glance #

// ✗ Status string literals scattered without an official type
func canOrder(status string) bool { return status == "ACTIVE" }
func canNotify(status string) bool { return status == "ENABLED" } // different!

// ✗ Business rule numbers scattered without constants
func goldDiscount(p float64) float64 { return p * 0.85 }  // order service
func goldDiscount(p float64) float64 { return p * 0.80 }  // cart service — diverged!

// ✗ Config hardcoded in every place that needs it
db.SetMaxOpenConns(10)  // db package
http.Client{Timeout: 30s} // http package — no connection between them

// ✗ Request/response structs redefined in every service
// user-service: CreateUserResponse{CreatedAt: string}
// order-service: UserData{CreatedAt: time.Time} — silent parse error

// ✗ Error messages in various variations for the same thing
// "email already exists" / "duplicate email" / "email taken" / "email registered"
// All mean the same thing; users get different messages depending on the code path

// ✗ Services directly querying another service's database
// order-service: SELECT name FROM users WHERE id = ? -- bypassing User Service

SSOT Review Checklist #

DOMAIN STATUS AND ENUMS:
  □ All statuses and enums use typed string/int, not raw literals
  □ No string literal comparisons for status outside the domain package
  □ Business rules depending on status exist as methods on the type itself

BUSINESS RULES:
  □ Every threshold/rate number exists as a named constant in the domain package
  □ No identical business calculation reimplemented in two places
  □ Changing one business rule only requires a change in one file

CONFIGURATION:
  □ All config values read from one Config struct
  □ No configuration magic numbers hardcoded inside components
  □ The Config struct is passed to components, not re-read from env per component

SCHEMAS AND CONTRACTS:
  □ Request/response structs for the same API live in one place
  □ No "similar" structs between producer and consumer without a shared source
  □ Schema changes produce compile errors in all affected consumers

MICROSERVICES:
  □ No service directly queries another service's database
  □ Data copied for performance (caching) has a clear TTL and owner
  □ Data changes in the "source service" propagate to consumers via events or cache invalidation

Summary #

  • SSOT is about authority, not location — what matters isn’t where the source lives, but that everyone knows this is the only source of truth for that fact. SSOT can be a Go package, a Config struct, a proto file, or an API.
  • SSOT vs DRY: DRY is about not duplicating code implementation; SSOT is about not duplicating authority over a fact. They complement each other — DRY prevents copy-pasted logic, SSOT prevents truth divergence.
  • Domain status as typed enums: use typed strings and define business rules as methods — not scattered raw string literals. The compiler becomes the consistency guard.
  • Business rules in a domain package: threshold numbers, rates, and business rules may only exist in one place. When a rule changes, one line of code — all consumers follow automatically.
  • Config in one Config struct: read from environment variables once at startup, inject into all components. No configuration magic numbers hardcoded in implementations.
  • Shared contracts for API schemas: the same request/response structs used by producer and consumer from one source. Schema changes produce compile errors — not silent parse failures in production.
  • SSOT in microservices isn’t a shared database: each service is the SSOT for its domain, and other services access through APIs or events. TTL caching is a performance optimization that still respects SSOT.
  • When SSOT becomes a burden: an oversized shared package is dangerous coupling; apply SSOT only to things genuinely shared and frequently changing. One package per bounded context, not one “god domain package”.
  • Key quote: “If a truth lives in two places, sooner or later one of them will lie.”

← Previous: SRP   Next: SoC →

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