Practical guide
The singleton that turns a dependency into global state
A global recorder seems convenient until two tests need different configurations: the singleton hides the required API and shares state among consumers.
An audit package offers a global instance:
package audit
var defaultRecorder = NewRecorder(os.Stdout)
func Record(event Event) error {
return defaultRecorder.Record(event)
}Any module can register an event without receiving dependencies:
func (s *Service) Cancel(ctx context.Context, id BookingID) error {
if err := s.bookings.Cancel(ctx, id); err != nil {
return err
}
return audit.Record(BookingCancelled{ID: id})
}There is one instance and the call is short. The cost appears in what the Service signature no longer reveals: cancellation requires auditing, can fail because of it, and shares the same recorder with every consumer in the process.
The dependency exists even if the constructor does not show it#
The singleton does not remove the relationship. It creates an alternative route for obtaining the dependency.
service := NewService(bookings)This construction seems complete, but behavior also depends on when audit.defaultRecorder was initialized, who configured it, and which other modules use it.
To understand Cancel, you have to look for global names outside the module. The conversation is no longer contained by its API.
The test needs to modify the world#
To observe auditing, a global setter usually appears:
func SetDefault(recorder Recorder) {
defaultRecorder = recorder
}The test replaces the instance and promises to restore it:
func TestCancelRecordsEvent(t *testing.T) {
fake := &fakeRecorder{}
audit.SetDefault(fake)
t.Cleanup(func() {
audit.SetDefault(audit.NewRecorder(os.Stdout))
})
service := NewService(fakeBookings{})
_ = service.Cancel(context.Background(), "booking-42")
if fake.events != 1 {
t.Fatalf("expected one event, got %d", fake.events)
}
}The case no longer controls only its subject. It changes shared state across the process.
Two parallel tests can be overwritten. A test that forgets to restore contaminates the next. The result may depend on the order, on an entire suite, or on whether another package ran init.
The fragility does not come from the fake. It comes from replacing a local dependency by mutating a global singleton.
A single instance does not need global access#
The application can create a single recorder at its composition point and deliver it explicitly:
func main() {
recorder := audit.NewRecorder(os.Stdout)
bookings := postgres.NewBookings(db)
service := cancellation.NewService(bookings, recorder)
startHTTP(service)
}The module declares the capability it needs:
package cancellation
type Audit interface {
Record(audit.Event) error
}
type Service struct {
bookings Bookings
audit Audit
}
func NewService(bookings Bookings, audit Audit) *Service {
return &Service{bookings: bookings, audit: audit}
}A single instance can still exist in production. The difference is that its lifecycle is owned and the dependency crosses a visible API.
The test recovers autonomy#
Each test builds its own graph:
func TestCancelRecordsEvent(t *testing.T) {
fake := &fakeRecorder{}
service := NewService(fakeBookings{}, fake)
err := service.Cancel(context.Background(), "booking-42")
if err != nil {
t.Fatalf("cancel: %v", err)
}
if fake.events != 1 {
t.Fatalf("expected one event, got %d", fake.events)
}
}There is no restoration. Two cases can be executed in parallel with different recorders. A second instance of Service can use another policy without reconfiguring the first.
The constructor now tells the truth: the service is not complete without bookings and audit.
The singleton mixes three decisions#
Different decisions are usually hidden under the word singleton:
- quantity: we want an instance in this process;
- life cycle: must live from startup to shutdown;
- access: any code can obtain it globally.
The first two can be valid without the third. A connection pool, metrics registry, or configuration can have a shared instance governed by composition. They do not need to expose a Default() to every package.
Separating the decisions allows you to change the quantity in tests, run two applications in the same process or migrate a dependency without editing hidden consumers.
sync.Once resolves initialization, not layout#
This variant avoids creating the instance twice:
var (
once sync.Once
recorder *Recorder
)
func Default() *Recorder {
once.Do(func() {
recorder = NewRecorder(os.Stdout)
})
return recorder
}sync.Once can make concurrent initialization safe. It does not make the dependency visible, separate state between tests, or allow two legitimate configurations. It solves a property of the mechanism, not the contract between modules.
When a global is harmless#
A constant or immutable value without shared identity does not introduce the same risk:
var ErrNotFound = errors.New("not found")Nobody reconfigures it and observing it does not change other consumers. The problem appears with services, caches, clients or registries whose state and configuration change the behavior.
The useful question is not "is there a package variable?" but "can a consumer change something that silently affects others?"
Sharing an instance may be a composition decision. Sharing a global door to reach it turns that decision into coupling for everyone.