The Singleton pattern ensures that only one instance of a type exists for the lifetime of the program, and provides a single global access point to it. It sits in the Creational family because it controls how (and how many times) an object is constructed.
- Shared, stateful resources that must be consistent across the program: config, loggers, connection pools, caches.
- When construction is expensive and repeated construction would be wasteful.
- When multiple instances would lead to inconsistent state (e.g., two independent caches diverging).
Avoid it when the "global" nature makes testing hard, hides dependencies, or couples unrelated parts of the system.
In languages like Java or C++, Singleton relies on a private constructor. Go has no constructors — instead, the pattern is enforced through:
| Mechanism | Purpose |
|---|---|
| Unexported struct type | Callers outside the package cannot instantiate it directly |
| Package-level pointer variable | Holds the single instance across the program |
| Exported constructor function | The only controlled entry point |
sync.Once |
Guarantees the initialization runs exactly once, even under concurrency |
singleton/
├── singleton_struct.go # The type definition and constructor
└── singleton_struct_test.go # Behavioural and concurrency tests
main.go # Usage demonstration
singleton/singleton_struct.go defines an unexported struct singletonStruct. Because it is unexported, no code outside the singleton package can write singletonStruct{} — the only way to obtain one is through NewSingletonStructInstance().
A package-level var instance *singletonStruct acts as the slot that holds the one true instance. It starts as nil and is populated on the first call.
NewSingletonStructInstance is the sole entry point. Its logic is:
- Fast path — if
instance != nil, increment the access counter and return immediately. No lock is taken. - Slow path — delegate to
once.Do(...), which wraps the actual allocation.sync.Onceensures the closure runs at most once, regardless of how many goroutines race to this point simultaneously.
This is sometimes called a lazy-initialization Singleton: the instance is not created until the first call, rather than at program startup.
sync.Once (from the standard library sync package) provides a Do(f func()) method. No matter how many goroutines call Do concurrently, f executes exactly once. All concurrent callers block until f returns, then proceed. This replaces the double-checked locking idiom that is notoriously hard to implement correctly in C++ or Java.
This implementation has a subtle race worth understanding in an interview:
The nil-check on line 23 (if instance != nil) happens outside once.Do. Under high concurrency, multiple goroutines can simultaneously see instance == nil, all fall through to once.Do, and then race to return instance before once.Do has finished assigning it — potentially returning a nil pointer.
The test TestConcurrentCallsNoPanic documents this explicitly and verifies no panics occur. Running with go test -race will surface the data race.
The fix: move the nil-check inside once.Do, or remove the early return and rely solely on once.Do for all paths.
Singleton's global state is hostile to isolated unit tests. This implementation solves it with a resetSingleton() helper that resets the package-level variables (instance, count, once) between test cases.
Why tests must run serially: because all test cases share the same package-level state, running them in parallel would cause them to interfere. The comment at the top of the test file documents this explicitly.
| Category | What it verifies |
|---|---|
| Creation | First call creates exactly one instance |
| Identity | Repeated calls return the same pointer (s1 == s2) |
| Concurrency | 1000 goroutines produce only one [X] Object created log line |
| Access counter | data["access"] increments on every call |
| Immutability | data["count"] stays at 1 across all calls |
| Panic safety | 100 racing goroutines produce zero panics |
| Side effects | Stdout output matches expected log messages |
Q: Why not just use a package-level var initialized at declaration?
Lazy initialization defers allocation until it is actually needed — useful when construction has side effects (DB connection, file open) that should not run unless the feature is used.
Q: Why sync.Once over a sync.Mutex?
sync.Once is purpose-built for one-time initialization. A Mutex would require the double-checked locking pattern which is easy to get wrong. sync.Once is simpler, idiomatic, and has the memory-ordering guarantees built in.
Q: Is the Singleton pattern an anti-pattern? It can be. Global state makes code harder to test and reason about. Dependency injection is generally preferred in production Go code — pass shared instances explicitly rather than accessing them through a global. Singleton is appropriate when the resource is truly singular and stateless-from-the-caller's-perspective (e.g., a logger).
Q: How would you make this production-safe?
Remove the nil-check early return and route all calls through once.Do to eliminate the race condition. Alternatively, initialize the instance at package init time if lazy loading is not required.