Singleton Pattern #
Imagine an application made up of dozens of different modules — all of them need database access, all of them write logs, and all of them read the same configuration. Without proper coordination, each module would create its own instance: dozens of database connections wasting resources, dozens of loggers stepping over each other, and configuration that could differ between parts of the application. The Singleton Pattern solves this problem — ensuring that only one instance of a struct stays alive for the application’s lifetime, and that everyone accesses that exact same instance. It belongs to the creational design pattern family and is an important foundation to understand before moving on to more complex patterns.
What Is the Singleton Pattern? #
The Singleton Pattern is a creational design pattern that guarantees only one instance of a class or struct exists for the duration of the application, while also providing a single, controlled point of global access to that instance.
There are two key words here: one instance and controlled global access. It is not just an ordinary global variable — a Singleton has a mechanism that prevents a second, third, or any further instance from being created, no matter where the code tries to create one.
Three main characteristics of a Singleton:
- The instance is created exactly once — no duplicates, no parallel instances standing on their own
- The instance is shared by every part of the application that needs it
- Instance creation cannot be done carelessly from outside — there is only one controlled door
It is important to understand the problem a Singleton solves before using it. This pattern is not the answer to “I want easy access to this object from anywhere” — that is a global variable, not a Singleton. It is the answer to resources that fundamentally can only exist once: one connection pool to the same database, one logger writing to the same file, one config read from a single source.
flowchart TD
A[Module A] -->|GetInstance| S{Instance\\nalready exists?}
B[Module B] -->|GetInstance| S
C[Module C] -->|GetInstance| S
S -- No --> D[Create a new instance]
D --> E[Store as the\\nsingle instance]
E --> F[Return the instance]
S -- Yes --> F
F --> G[The same instance\\nis used by all modules]Why Is a Singleton Needed #
Before jumping into the implementation, it is important to understand why this pattern exists. There are three real problems that a Singleton solves.
Problem 1: Expensive Resources #
Opening a database connection is not a cheap operation. Every connection requires a network handshake, authentication, and resource allocation on the server side. If every piece of code opens its own connection, the application will exhaust the available connections and performance will tank.
// ANTI-PATTERN: every function opens a new connection
func GetUserByID(id int) (*User, error) {
db, err := sql.Open("postgres", dsn) // a new connection every time!
if err != nil {
return nil, err
}
defer db.Close()
// query...
}
func GetProductByID(id int) (*Product, error) {
db, err := sql.Open("postgres", dsn) // opening yet another connection!
if err != nil {
return nil, err
}
defer db.Close()
// query...
}
// CORRECT: one connection pool created once, shared by everyone
var dbPool *sql.DB
func GetDB() *sql.DB {
if dbPool == nil {
dbPool, _ = sql.Open("postgres", dsn)
}
return dbPool
}
Problem 2: State That Must Stay Consistent #
The config read by one module must be exactly the same as the config read by another. If two different Config instances exist — say, because the config file changed between two reads — the application’s behavior becomes inconsistent and hard to debug.
Problem 3: Centralized Coordination #
Loggers, metrics collectors, and cache managers need to see all the data flowing through the system. If there are two Logger instances, half the logs go to one and half to the other. Nobody sees the full picture.
Anatomy of a Go Singleton #
Go does not have classes like Java or Python. But precisely because of that, implementing a Singleton in Go is more explicit and easier to understand. Three components work together.
flowchart LR
subgraph Package Level
direction TB
I[instance *Config\\nprivate variable]
O[once sync.Once\\ninitialization control]
end
subgraph Public API
G["GetConfig() *Config\\nthe only entry point"]
end
G -->|first call| O
O -->|once.Do| I
G -->|subsequent calls| IThose three components:
- A private package-level variable — holds the instance, not directly accessible from outside the package
sync.Once— guarantees initialization happens exactly once, safe for concurrent access- A public getter function — the only way to access the instance from outside the package
Basic Implementation #
Let’s start with the simplest implementation to understand the structure, before adding a layer of safety for concurrent access.
The naive implementation below is not thread-safe, but it is useful for understanding the basic pattern:
package config
// Config stores the application configuration.
// These fields are only read after initialization — no writes afterwards.
type Config struct {
AppName string
Environment string
Port int
DatabaseDSN string
}
var instance *Config
// GetConfig returns the same Config instance every time it is called.
// WARNING: this implementation is not thread-safe.
func GetConfig() *Config {
if instance == nil {
instance = &Config{
AppName: "MyApp",
Environment: "production",
Port: 8080,
DatabaseDSN: "postgres://user:secret@localhost/mydb",
}
}
return instance
}
The code above works, but only if the application is single-threaded. In Go, which is designed for concurrency, that is not an assumption you can rely on.
The Race Condition Danger
If two goroutines call
GetConfig()at the same time whileinstanceis stillnil, both enter theif instance == nilblock simultaneously. The result: two instances get created. Which instance “wins” is non-deterministic. This is a race condition that can be very hard to reproduce and debug.
Thread-Safe Implementation with sync.Once
#
The standard, recommended solution in Go is to use sync.Once. It is a standard library primitive that guarantees a function executes exactly once, even when called concurrently from many goroutines at once.
package config
import (
"os"
"sync"
)
// Config stores all runtime configuration for the application.
type Config struct {
AppName string
Environment string
Port int
DatabaseDSN string
LogLevel string
}
var (
instance *Config
once sync.Once
)
// GetConfig returns the single Config instance.
// Safe to call from many goroutines at the same time.
func GetConfig() *Config {
once.Do(func() {
instance = &Config{
AppName: getEnv("APP_NAME", "MyApp"),
Environment: getEnv("APP_ENV", "production"),
Port: 8080,
DatabaseDSN: getEnv("DATABASE_DSN", "postgres://localhost/mydb"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}
})
return instance
}
// getEnv reads an environment variable, falling back to a default value.
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
Using it from another package is very simple:
package main
import (
"fmt"
"myapp/config"
)
func main() {
cfg := config.GetConfig()
fmt.Printf("Starting %s on port %d\n", cfg.AppName, cfg.Port)
// Calling again returns the exact same instance
cfg2 := config.GetConfig()
fmt.Println(cfg == cfg2) // true — identical pointer
}
Why sync.Once Is the Right Choice
#
sync.Once is not just a mutex written differently. Several important properties make it ideal for a Singleton:
// Without sync.Once — manual mutex implementation (more verbose, bug-prone)
var (
instance *Config
mu sync.Mutex
)
func GetConfig() *Config {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &Config{} // locked on every call — inefficient
}
return instance
}
// With sync.Once — clean and efficient
var once sync.Once
func GetConfig() *Config {
once.Do(func() {
instance = &Config{} // executed only once, no locking afterwards
})
return instance
}
Why sync.Once beats a manual mutex:
| Aspect | Manual Mutex | sync.Once |
|---|---|---|
| Locking after initialization | Every call is locked | No locking after init |
| Risk of forgetting to unlock | Yes | No |
| Code readability | Verbose | Minimal |
| “Exactly once” guarantee | Must be implemented yourself | Built-in |
| Thread-safety | Depends on the implementation | Guaranteed by the standard library |
Singleton for Real-World Use Cases #
The three most common Singleton use cases in production applications are the database connection pool, the logger, and the metrics client. Each has different characteristics that make a Singleton the right choice.
Database Connection Pool #
package database
import (
"database/sql"
"sync"
"time"
_ "github.com/lib/pq"
)
type DBPool struct {
db *sql.DB
}
var (
pool *DBPool
once sync.Once
)
// GetPool returns the configured connection pool.
// The pool is created only once and shared by the whole application.
func GetPool(dsn string) (*DBPool, error) {
var initErr error
once.Do(func() {
db, err := sql.Open("postgres", dsn)
if err != nil {
initErr = err
return
}
// Pool configuration — this is what makes the pool efficient
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
if err = db.Ping(); err != nil {
initErr = err
return
}
pool = &DBPool{db: db}
})
if initErr != nil {
return nil, initErr
}
return pool, nil
}
// Query runs a query using a connection from the pool.
func (p *DBPool) Query(query string, args ...interface{}) (*sql.Rows, error) {
return p.db.Query(query, args...)
}
Centralized Logger #
package logger
import (
"log/slog"
"os"
"sync"
)
type Logger struct {
handler *slog.Logger
}
var (
log *Logger
once sync.Once
)
// Get returns the Logger instance used by the entire application.
func Get() *Logger {
once.Do(func() {
handler := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
log = &Logger{handler: handler}
})
return log
}
func (l *Logger) Info(msg string, args ...any) {
l.handler.Info(msg, args...)
}
func (l *Logger) Error(msg string, args ...any) {
l.handler.Error(msg, args...)
}
Usage from different parts of the application:
// In an HTTP handler
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
logger.Get().Info("fetching user", "user_id", r.PathValue("id"))
// ...
}
// In a repository
func (r *UserRepository) Save(user *User) error {
logger.Get().Info("saving user", "email", user.Email)
// ...
}
// Both write to the exact same logger — no logs are lost
Singleton Lifecycle #
Understanding when the instance is created and how it lives during the application’s runtime is important for avoiding problems in scenarios like graceful shutdown or testing.
stateDiagram-v2
[*] --> Uninitialized : Application starts
Uninitialized --> Initializing : First GetInstance() call
Initializing --> Ready : once.Do completes
Ready --> Ready : GetInstance() called again\\n(returns the same instance)
Ready --> [*] : Application shuts down
note right of Initializing
Other goroutines calling
GetInstance() at this moment
will wait until
once.Do completes
end noteA few important points about this lifecycle:
- The instance is not created when the application starts — it is created lazily, on first use
- Every goroutine calling
GetInstance()while initialization is in progress will wait untilonce.Dofinishes — they will not create a new instance - The instance lives until the application shuts down — there is no built-in mechanism to reset or remove the instance mid-flight (which makes testing harder, as we’ll discuss later)
When to Use and When Not to #
A Singleton is not a universal solution. One of the biggest mistakes developers make is using it too broadly. The following guidance helps you decide:
USE a Singleton if:
✓ The resource is expensive to create (DB connection, HTTP client with a pool)
✓ State must be consistent and identical across the whole application
✓ The object is stateless or its state is genuinely shared globally (config, logger)
✓ There is only one physical source being represented (one DB server, one log file)
✓ The application is single-process (not a distributed system)
AVOID a Singleton if:
✗ The only reason is "so it's easy to access from anywhere" — use DI instead
✗ The object holds frequently changing state that must not be shared
✗ You need to create multiple instances in different contexts (e.g., testing)
✗ The object represents business logic, not infrastructure
✗ The team has not agreed on the object's lifecycle
Singleton in a Distributed System
A Singleton guarantees one instance per process. If your application is deployed as several instances (horizontal scaling, Kubernetes pods), each process has its own Singleton. For state that is truly global across a distributed system, you need an external solution like Redis, a centralized database, or a distributed lock.
Singleton vs Dependency Injection #
The “Singleton vs DI” debate is one of the most common discussions in software engineering. In reality, the two are not enemies — they complement each other when used correctly.
flowchart TD
subgraph SG1[Singleton Global Access]
S1[Module A] -->|"GetDB()"| SI[Singleton Instance]
S2[Module B] -->|"GetDB()"| SI
S3[Module C] -->|"GetDB()"| SI
end
subgraph SG2[Dependency Injection]
DI[DI Container] -->|"inject db"| D1[Module A]
DI -->|"inject db"| D2[Module B]
DI -->|"inject db"| D3[Module C]
endComparing their characteristics:
| Aspect | Singleton | Dependency Injection |
|---|---|---|
| Access pattern | GetInstance() from anywhere | Received as a parameter/constructor arg |
| Dependency visibility | Implicit, hidden | Explicit, visible in the signature |
| Testability | Hard — difficult to mock | Easy — just inject a mock |
| Flexibility | Low — one instance for everyone | High — different implementations can be injected |
| Boilerplate | Minimal | More setup |
| Best for | Infrastructure (DB, Logger, Config) | Business logic, service layer |
Practical recommendation: use Singleton for the infrastructure layer (database pool, logger, config, metrics), and use Dependency Injection for the business logic layer (service, repository, use case). Both can coexist in one well-designed application.
// Recommended pattern: Singleton in the infrastructure layer,
// DI in the business logic layer
// Infrastructure — Singleton is fine
func NewUserRepository() *UserRepository {
return &UserRepository{
db: database.GetPool(), // Singleton
log: logger.Get(), // Singleton
}
}
// Business logic — use DI, not Singleton
type UserService struct {
repo UserRepositoryInterface // interface, not a concrete Singleton
}
func NewUserService(repo UserRepositoryInterface) *UserService {
return &UserService{repo: repo}
}
With this approach, the business logic stays testable — you can inject a mock UserRepository when testing UserService without touching the Singleton.
Testing a Singleton #
One of the most commonly complained-about weaknesses of Singleton is how hard it makes testing. The same instance keeps living between one test and the next, causing tests to interfere with each other.
There are several approaches to deal with this:
Approach 1: Interface as Abstraction #
Instead of exposing a concrete struct, expose an interface. Tests can use a different implementation.
// Define an interface
type ConfigProvider interface {
GetAppName() string
GetPort() int
GetDatabaseDSN() string
}
// Production implementation — Singleton
type prodConfig struct {
appName string
port int
databaseDSN string
}
var (
cfg *prodConfig
once sync.Once
)
func GetConfig() ConfigProvider {
once.Do(func() {
cfg = &prodConfig{
appName: os.Getenv("APP_NAME"),
port: 8080,
databaseDSN: os.Getenv("DATABASE_DSN"),
}
})
return cfg
}
func (c *prodConfig) GetAppName() string { return c.appName }
func (c *prodConfig) GetPort() int { return c.port }
func (c *prodConfig) GetDatabaseDSN() string { return c.databaseDSN }
When testing, you can create a mockConfig that implements the same interface without touching the Singleton:
// In a test file
type mockConfig struct{}
func (m *mockConfig) GetAppName() string { return "TestApp" }
func (m *mockConfig) GetPort() int { return 9999 }
func (m *mockConfig) GetDatabaseDSN() string { return "sqlite://test.db" }
func TestUserService(t *testing.T) {
cfg := &mockConfig{}
svc := NewUserService(cfg) // inject the mock, no Singleton involved
// test...
}
Approach 2: Reset for Testing (Carefully) #
If you need a Singleton that can be reset between tests, you can expose a reset function — but only for testing purposes:
package config
import "sync"
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = &Config{}
})
return instance
}
// resetForTesting is only for use in tests.
// It must never be called from production code.
func resetForTesting() {
instance = nil
once = sync.Once{}
}
// In a _test.go file
func TestGetConfig(t *testing.T) {
defer resetForTesting() // cleanup after the test finishes
cfg := GetConfig()
// assert...
}
Do Not Expose Reset to Production
A reset function like
resetForTesting()must never make it into production code. If you feel the need to reset a Singleton in production, that is a strong signal that a Singleton is not the right choice for that use case.
Common Mistakes #
Understanding frequent mistakes is more effective than just reading rules. Here are the most common ones along with their solutions:
// ✗ Mistake 1: Storing mutable state that changes frequently
type AppState struct {
CurrentUser *User // changing state
ActiveJobs []Job // not suitable for a Singleton
TempData map[string]interface{}
}
// ✓ Solution: Singleton only for configuration and resources, not application state
type AppConfig struct {
AppName string // read once, never changes
Port int
}
// ✗ Mistake 2: Singleton as a parameter replacement
func ProcessOrder(orderID string) error {
db := database.GetDB() // hidden dependency
cfg := config.GetConfig() // hidden dependency
log := logger.Get() // hidden dependency
// This function is hard to test because all dependencies are hidden
}
// ✓ Solution: Use DI for business logic
type OrderProcessor struct {
db DBInterface
cfg ConfigProvider
log LoggerInterface
}
func (p *OrderProcessor) Process(orderID string) error {
// explicit dependencies, easy to test
}
// ✗ Mistake 3: Making every service a Singleton
var (
userServiceInstance *UserService
productServiceInstance *ProductService
orderServiceInstance *OrderService
// ... and so on
)
// ✓ Solution: Services are not Singletons — use a DI container or wire
func NewUserService(repo UserRepo) *UserService {
return &UserService{repo: repo}
}
// ✗ Mistake 4: Singleton without thread safety
var configInstance *Config
func GetConfig() *Config {
if configInstance == nil { // race condition here
configInstance = loadConfig()
}
return configInstance
}
// ✓ Solution: Always use sync.Once
var (
configInstance *Config
configOnce sync.Once
)
func GetConfig() *Config {
configOnce.Do(func() {
configInstance = loadConfig()
})
return configInstance
}
Singleton Review Checklist #
Use this checklist before implementing a Singleton in your project:
REQUIREMENTS:
□ There is a strong reason why exactly one instance must exist
□ Not because "it's convenient to access" — that is the wrong reason
□ The resource is expensive to create, or the state genuinely must be shared
IMPLEMENTATION:
□ Uses sync.Once for thread safety
□ The instance variable is private (lowercase)
□ There is exactly one public getter function
□ There is no other way to create an instance from outside the package
STATE:
□ State is read-only after initialization, or
□ State is deliberately designed to be shared (with proper locking in place)
□ Does not store frequently changing application state
TESTABILITY:
□ An interface abstracts the Singleton
□ Business logic receives the interface via DI, not direct Singleton access
□ There is a mechanism for testing without side effects between test cases
LIFECYCLE:
□ It is clear when this Singleton gets initialized
□ Initialization errors are handled
□ It is clear what happens at shutdown (close connections, flush buffers)
Summary #
- A Singleton guarantees one instance for the application’s lifetime and provides a single controlled access point — not just a global variable.
sync.Onceis the standard choice in Go for a thread-safe implementation with no locking overhead after initialization completes.- Best for the infrastructure layer — database pool, logger, config loader, metrics client; objects that fundamentally can only exist once.
- Not for business logic — services and repositories should use Dependency Injection to stay testable and flexible.
- Interface as abstraction — expose an interface, not a concrete struct, so code depending on the Singleton can still be tested with mocks.
- Lazy initialization — the instance is created on first use rather than at application start; this saves resources for components that may not always be used.
- Be careful with distributed systems — a Singleton only applies per process; global state in a distributed system needs an external solution.
- Test with a reset or an interface — if it is hard to test, that is a signal that Singleton is not the right choice, or that it needs a better abstraction.