Creational Complexity: Low

Singleton Pattern in Go

Use sync.Once carefully for one-time initialization, and understand when dependency injection is a better fit.

The Problem

Some values should be initialized only once in a Go process: configuration loaded from disk, a metrics registry, or a client that coordinates shared state. If multiple goroutines race to build those values manually, you can end up with duplicate initialization, inconsistent state, or hidden startup bugs.

The Solution

In Go, the idiomatic singleton is usually a package-level variable protected by sync.Once. That gives you lazy, thread-safe initialization without writing your own double-checked locking. The real design question is not how to build a singleton, but whether you should expose one at all or pass a dependency explicitly.

Structure

  • Singleton accessor: GetConfiguration() owns initialization and returns the shared instance.
  • Shared value: Configuration stores the process-wide settings.
  • Concurrency guard: sync.Once protects initialization, and an internal mutex protects demo-time mutation.
  • Client code: Callers read the same instance from multiple goroutines.

Implementation

This example models process-wide application configuration. The singleton is initialized lazily, and the example prints the shared address from several goroutines to show that every caller sees the same instance.

package main

import (
	"fmt"
	"sync"
)

// Configuration represents application-wide settings.
// The internal mutex protects demo-time mutation in Update.
type Configuration struct {
	mu          sync.RWMutex
	DatabaseURL string
	RedisURL    string
	Port        int
	Debug       bool
}

var (
	configOnce     sync.Once
	configInstance *Configuration
)

// GetConfiguration lazily initializes the singleton and returns it.
func GetConfiguration() *Configuration {
	configOnce.Do(func() {
		configInstance = &Configuration{
			DatabaseURL: "postgres://localhost:5432/shop",
			RedisURL:    "redis://localhost:6379",
			Port:        8080,
			Debug:       true,
		}
	})

	return configInstance
}

// Update mutates the singleton for demo purposes.
func (c *Configuration) Update(dbURL, redisURL string, port int, debug bool) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.DatabaseURL = dbURL
	c.RedisURL = redisURL
	c.Port = port
	c.Debug = debug
}

// Snapshot returns a copy so callers can read fields without sharing locks.
func (c *Configuration) Snapshot() Configuration {
	c.mu.RLock()
	defer c.mu.RUnlock()

	return Configuration{
		DatabaseURL: c.DatabaseURL,
		RedisURL:    c.RedisURL,
		Port:        c.Port,
		Debug:       c.Debug,
	}
}

func (c *Configuration) String() string {
	snapshot := c.Snapshot()
	return fmt.Sprintf(
		"Config{DB:%s Redis:%s Port:%d Debug:%t}",
		snapshot.DatabaseURL,
		snapshot.RedisURL,
		snapshot.Port,
		snapshot.Debug,
	)
}

Best Practices

  • Prefer sync.Once over handwritten locking. It is simpler, faster after initialization, and harder to get wrong.
  • Keep singleton scope narrow. A package-scoped accessor is often enough; you do not need a global registry.
  • Avoid mutable global state when possible. If the value changes frequently, pass a dependency instead of hiding it behind a singleton.
  • Make testing an explicit concern. Code that accepts a dependency can still default to the singleton internally, but tests should be able to inject a substitute.
  • Be careful with init(). Eager initialization hides startup ordering; lazy initialization through sync.Once is easier to reason about.

When to Use

  • You have one process-wide resource whose initialization must happen exactly once.
  • Multiple goroutines may race to read that resource during startup.
  • The shared value has a clear process-level lifetime.

When NOT to Use

  • The value should differ by request, tenant, or test case.
  • Hidden global state would make behavior harder to reason about.
  • Dependency injection or plain constructors keep the code simpler and more testable.