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

  1. identify the global(s) (logger, db, cache, config).
  2. create a small interface if needed (owned by the consumer package).
  3. add a constructor that accepts the dependency.
  4. replace calls to the singleton with fields on your struct.
  5. wire dependencies in main() or a builder function.
  6. 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

Discussion

Share your thoughts and join the conversation

Loading comments...

Join the Discussion

Please log in to share your thoughts and engage with the community.