stop abusing singletons: a practical guide to dependency injection in golang (with real-world patterns and pitfalls)
why singletons keep biting go projects
singletons look attractive: a single global instance, easy access, and less wiring. but in real-world go projects—whether devops tools, full-stack services, or cli utilities—overusing singletons quickly leads to brittle code and hidden coupling. when everything depends on a global, testing becomes painful, concurrency hazards creep in, and your codebase resists change.
- hidden dependencies: functions and packages pull in globals behind your back.
- hard to test: you need global resets or side-effect-heavy setup across tests.
- concurrency risks: shared mutable state across goroutines can race without careful locking.
- poor extensibility: replacing an implementation (e.g., a logger, db, or cache) requires invasive refactors.
dependency injection (di) in go: the practical way
dependency injection means explicitly passing dependencies where they’re needed rather than pulling them from package-level state. go doesn’t need a heavy di container: simple constructors and interfaces do 90% of the job cleanly.
- constructor functions (newx) create values with their dependencies.
- interfaces enable swapping implementations in tests or different environments.
- wire at the edges: compose your graph in
main()or a small bootstrap function.
singleton smell vs di example
singleton logger (smelly)
package logx
import (
"log"
"os"
"sync"
)
var (
once sync.once
logger *log.logger
)
func logger() *log.logger {
once.do(func() {
logger = log.new(os.stdout, "[app] ", log.lstdflags|log.lshortfile)
})
return logger
}
// usage across the codebase:
package user
import "myapp/logx"
func createuser(name string) error {
logx.logger().printf("creating user: %s", name)
// ...
return nil
}
problems: the user package silently depends on a global. tests can’t easily capture output or replace the logger. parallel tests might interleave logs unpredictably.
constructor injection (clean)
package user
import "log"
type service struct {
log *log.logger
// other deps, e.g., repo repo
}
func newservice(logger *log.logger /*, repo repo */) *service {
return &service{log: logger}
}
func (s *service) createuser(name string) error {
s.log.printf("creating user: %s", name)
// ...
return nil
}
// main.go: wire dependencies once
package main
import (
"log"
"os"
"myapp/user"
)
func main() {
logger := log.new(os.stdout, "[app] ", log.lstdflags|log.lshortfile)
svc := user.newservice(logger)
// run http server using svc...
}
now tests can pass a fake logger or buffer, and production wiring is obvious and explicit.
real-world patterns
1) interface + constructor for infrastructure (db/cache/queue)
type clock interface {
now() time.time
}
type realclock struct{}
func (realclock) now() time.time { return time.now() }
// example: a token service that depends on clock (easy to test)
type tokenservice struct {
clock clock
}
func newtokenservice(c clock) *tokenservice { return &tokenservice{clock: c} }
func (s *tokenservice) expired(t time.time) bool {
return s.clock.now().after(t)
}
why it helps: time-based logic becomes testable with a fake clock.
2) functional options for configurable constructors
type httpclient struct {
timeout time.duration
retries int
}
type option func(*httpclient)
func withtimeout(d time.duration) option {
return func(c *httpclient) { c.timeout = d }
}
func withretries(n int) option {
return func(c *httpclient) { c.retries = n }
}
func newhttpclient(opts ...option) *httpclient {
c := &httpclient{
timeout: 5 * time.second,
retries: 2,
}
for _, opt := range opts {
opt(c)
}
return c
}
why it helps: avoids massive constructors. keeps di flexible without global state.
3) provider/builder in main() for wiring
type app struct {
usersvc *user.service
// other services/handlers...
}
func buildapp() (*app, error) {
logger := log.new(os.stdout, "[app] ", log.lstdflags)
// db := connectdb(cfg) // e.g., returns *sql.db
usersvc := user.newservice(logger /*, repo */)
return &app{usersvc: usersvc}, nil
}
func main() {
app, err := buildapp()
if err != nil { log.fatal(err) }
// start http server and inject app.usersvc into handlers
}
why it helps: clear composition root. testing buildapp or building a buildtestapp becomes straightforward.
4) context for request-scoped values (not globals)
use context.context to pass request-scoped data (trace ids, user ids) down call chains. do not hide them behind singletons.
func handler(svc *user.service) http.handlerfunc {
return func(w http.responsewriter, r *http.request) {
traceid := r.header.get("x-trace-id")
ctx := context.withvalue(r.context(), "traceid", traceid) // better: typed key
if err := svc.handle(ctx, w, r); err != nil { /* ... */ }
}
}
testing without singletons
table-driven unit tests
func testtokenservice_expired(t *testing.t) {
fake := fakeclock{t: time.date(2024, 12, 1, 0, 0, 0, 0, time.utc)}
svc := newtokenservice(fake)
cases := []struct {
name string
in time.time
want bool
}{
{"before", time.date(2024, 11, 1, 0, 0, 0, 0, time.utc), false},
{"after", time.date(2024, 12, 2, 0, 0, 0, 0, time.utc), true},
}
for _, tc := range cases {
t.run(tc.name, func(t *testing.t) {
got := svc.expired(tc.in)
if got != tc.want {
t.fatalf("got %v, want %v", got, tc.want)
}
})
}
}
type fakeclock struct{ t time.time }
func (f fakeclock) now() time.time { return f.t }
http handler injection
type userhandler struct {
svc *user.service
}
func (h *userhandler) servehttp(w http.responsewriter, r *http.request) {
// use h.svc, not a global
}
func testuserhandler(t *testing.t) {
logger := log.new(io.discard, "", 0) // no noisy logs
svc := user.newservice(logger)
h := &userhandler{svc: svc}
req := httptest.newrequest(http.methodget, "/users", nil)
w := httptest.newrecorder()
h.servehttp(w, req)
if w.code != http.statusok {
t.fatalf("got %d, want %d", w.code, http.statusok)
}
}
common pitfalls (and how to avoid them)
- god constructors: when a constructor takes 15 deps, it signals poor boundaries. split services by business capability.
- leaky interfaces: keep them small and domain-focused. prefer behavior-driven interfaces owned by the consumer package.
- accidental globals: package-level vars for caches, configs, or http clients reintroduce hidden state. declare them in main and inject.
- context abuse: don’t stuff services into
context.context. only request-scoped values belong there. - overengineering di: go doesn’t need a heavyweight container. start with constructor injection and interfaces.
when a singleton might be acceptable
sometimes process-wide resources can be “effectively singletons,” but still avoid global access:
- read-only config: load once in main, pass to constructors.
- metrics registry: wrap in an interface and inject; export default collectors only at the edges.
- logger: one instance is fine—just inject it, don’t import a global.
lightweight di helpers
if your composition graph grows, consider:
- fx (uber-go/fx): lifecycle-aware app framework, good for services with start/stop hooks.
- google/wire: compile-time dependency wiring with code generation.
- dig (uber-go/dig): reflection-based container—use sparingly; constructor injection is often clearer.
start simple. add tools only when wiring in main becomes unmanageable.
from singleton to di: a refactoring checklist
- identify the global(s) (logger, db, cache, config).
- create a small interface if needed (owned by the consumer package).
- add a constructor that accepts the dependency.
- replace calls to the singleton with fields on your struct.
- wire dependencies in
main()or a builder function. - write tests that inject fakes or in-memory implementations.
performance and concurrency notes
- allocation concerns: di does not mean new allocations per request. services are usually long-lived; per-request values are kept in locals.
- goroutines: inject thread-safe dependencies (db pools, http clients). avoid sharing mutable maps; wrap with channels or sync primitives if needed.
- memory: prefer interfaces with pointer receivers to avoid copying large structs.
seo, full-stack, and devops angle
for full-stack teams, di makes it easy to switch infrastructure between environments: in-memory stores for local dev, real databases in staging, and managed services in prod. for seo-driven web apps, you can inject different caching layers or renderers to a/b test without rewriting handlers. in devops pipelines, di allows your cli tools and microservices to run with mocks in ci, real dependencies in canaries, and feature flags toggled via injected config providers.
end-to-end example: http api with di
type repo interface {
finduser(ctx context.context, id string) (user, error)
}
type sqlrepo struct { db *sql.db }
func (r *sqlrepo) finduser(ctx context.context, id string) (user, error) { /* ... */ return user{}, nil }
type userservice struct { repo repo; log *log.logger }
func newuserservice(r repo, l *log.logger) *userservice { return &userservice{repo: r, log: l} }
type userhandler struct { svc *userservice }
func newuserhandler(s *userservice) *userhandler { return &userhandler{svc: s} }
func (h *userhandler) servehttp(w http.responsewriter, r *http.request) {
ctx := r.context()
id := r.url.query().get("id")
u, err := h.svc.repo.finduser(ctx, id)
if err != nil { http.error(w, "not found", http.statusnotfound); return }
h.svc.log.printf("served user %s", id)
json.newencoder(w).encode(u)
}
func buildapp(dsn string) (http.handler, error) {
db, err := sql.open("postgres", dsn)
if err != nil { return nil, err }
logger := log.new(os.stdout, "[api] ", log.lstdflags)
repo := &sqlrepo{db: db}
svc := newuserservice(repo, logger)
return newuserhandler(svc), nil
}
func main() {
h, err := buildapp(os.getenv("dsn"))
if err != nil { log.fatal(err) }
http.listenandserve(":8080", h)
}
key takeaway: dependencies flow inward from main to handlers/services, never the other way around.
quick reference: do’s and don’ts
- do pass dependencies explicitly via constructors.
- do keep interfaces small and consumer-owned.
- do wire in main; keep packages free of globals.
- don’t hide behind singletons or package-level vars.
- don’t overuse context for non-request-scoped services.
- don’t introduce a heavy di container unless warranted.
conclusion
abandoning singletons in favor of straightforward dependency injection makes your go code cleaner, more testable, and easier to evolve. start small: define constructors, pass dependencies, and compose in main. your future self—and your team—will thank you.
Comments
Share your thoughts and join the conversation
Loading comments...
Please log in to share your thoughts and engage with the community.