Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

Singleton — Creational Design Pattern

What is it?

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.


When to use it

  • 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.


Go's approach vs. OOP languages

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

Structure of this implementation

singleton/
├── singleton_struct.go        # The type definition and constructor
└── singleton_struct_test.go   # Behavioural and concurrency tests
main.go                        # Usage demonstration

The singleton type

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.

The constructor / accessor

NewSingletonStructInstance is the sole entry point. Its logic is:

  1. Fast path — if instance != nil, increment the access counter and return immediately. No lock is taken.
  2. Slow path — delegate to once.Do(...), which wraps the actual allocation. sync.Once ensures 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 — the thread-safety primitive

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.


Known race condition

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.


Testing challenges

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.

Test categories covered

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

Key interview talking points

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.