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:#fffSSOT 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:#fffSSOT 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:#fffWhen 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.”