Problem: Every subsystem in an application needs to write logs. If each creates its own file handle or output writer, you get interleaved output, duplicate handles, and split log files.
How Singleton helps: One logger instance owns the output destination (file, stdout, remote sink). All callers write through it.
Special case — concurrent writes:
A logger is a write-heavy singleton. Without synchronization, two goroutines writing simultaneously corrupt the output. The singleton must serialize writes internally, either with a sync.Mutex around the write call or by funneling all log entries through a buffered channel consumed by a single writer goroutine. The channel approach is preferred in Go because it avoids holding a lock across I/O.
Problem: Configuration (env vars, flags, YAML files) is read at startup and then consumed everywhere. Parsing it repeatedly is wasteful; passing it through every call chain is noisy.
How Singleton helps: One instance reads and caches config once. All callers read from it.
Special case — hot reload:
Static config is straightforward. When config can change at runtime (feature flags, remote config), the singleton must expose a way to refresh its internal state without replacing the instance pointer (since callers hold a reference to the original). This is usually solved with a sync.RWMutex: a write lock during reload, read lock during normal access. The pointer itself never changes — only the values inside the struct do.
Problem: Opening a new database connection on every query is expensive — TCP handshake, authentication, server-side session setup. You want to reuse connections.
How Singleton helps: One pool instance manages a fixed set of pre-opened connections. All callers borrow from it.
This is where the Singleton pattern becomes significantly more complex than the basic NewInstance() model.
A basic singleton only needs a constructor/accessor. A connection pool needs:
| Method | Purpose |
|---|---|
Acquire() (Conn, error) |
Borrow a connection from the pool |
Release(Conn) |
Return a connection back to the pool |
Close() |
Drain the pool and close all connections on shutdown |
Without Release, borrowed connections are never returned — the pool exhausts itself and new callers block forever or receive errors.
| Concern | Why it matters |
|---|---|
| Acquiring a connection | Multiple goroutines race to grab from the same pool |
| Releasing a connection | A released connection must be visible to a goroutine blocked in Acquire |
| Pool size enforcement | You must never hand out more connections than the cap allows |
| Double-release protection | Returning the same connection twice must not corrupt pool state |
The typical Go implementation uses a buffered channel as the pool. Connections sit in the channel; Acquire does a channel receive (blocking if empty), Release does a channel send. The channel's buffer size is the pool capacity. This is safe by construction because channel operations are already synchronized — no explicit mutex needed.
pool (buffered channel, cap=N)
│
├── Acquire() → receive from channel (blocks if empty)
└── Release(conn) → send back to channel (panics if over-capacity → signals double-release)
This is a resource leak. The goroutine that acquired the connection exits without returning it; the pool's effective size silently shrinks by one. Over time the pool starves. Production pools typically pair Acquire with a context deadline and log a warning if the connection is held longer than a threshold — but Go has no destructor, so the caller discipline is the only enforcement.
Because the pool is a singleton, every caller that calls Acquire is drawing from the same set of real connections. If the pool were not a singleton and two parts of the code each created their own pool, you would silently double the number of open connections at the database side — often hitting the server's max-connections limit under load.
Problem: Rate limiters and feature flag evaluators maintain shared counters or state windows. Two independent instances would each track only a fraction of the true traffic.
How Singleton helps: One instance owns the counter or token bucket. All request handlers check through it.
Special case — distributed systems: A local singleton works only within one process. In a horizontally scaled service (multiple pods), each pod has its own singleton with its own counter — they diverge. The correct fix here is not to make the singleton smarter, but to back it with a shared external store (Redis, etc.). This is a common interview follow-up: "what breaks if you scale this service?" The answer is always that local-singleton state becomes per-instance state.
Problem: A plugin system or microkernel architecture needs a central directory of available services or handlers that components can look up by name.
How Singleton helps: One registry is populated at startup; components register themselves once and are looked up by others throughout the application lifecycle.
Special case — registration ordering:
If components register during init() calls, registration order depends on import order, which Go does not guarantee across packages. The singleton registry must be safe to write to from concurrent init() goroutines (using a sync.RWMutex) and must not be read until all init() calls have completed (typically enforced by only reading after main() starts).
| Application | Beyond basic GetInstance() |
|---|---|
| Logger | Serialized concurrent writes (mutex or channel) |
| Config | RW lock for hot reload without pointer swap |
| DB Pool | Acquire + Release lifecycle, deadlock/starvation on exhaustion, double-release guard |
| Rate Limiter | Shared counter breaks under horizontal scaling |
| Service Registry | Concurrent registration safety, init-ordering hazards |
The interview insight: the Singleton pattern is easy to implement correctly for read-only shared state. It becomes hard the moment the shared instance is mutable, has limited capacity, or needs to coordinate lifecycle events like borrowing and returning resources.