Skip to content
gohacks.dev
Web

Dependency Injection in Go Handlers Without a Framework

Give HTTP handlers their database, logger and clients through struct fields or closures, wire everything in main, test with fakes and avoid globals.

14 min read
Summarize in AI
Flat illustration of a request passing through concentric middleware layers to a handler.

A handler that needs a database has to get it from somewhere. In a tutorial it comes from a package-level variable, because that is the shortest way to make the example run. In a real service that choice shows up later as tests that cannot run in parallel, a fake that has to be installed and removed around every test, and a function that works when called from main and panics with a nil pointer when called from anywhere else.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Dependency injection sounds like a framework term, and in Go it is nothing of the sort. It is passing things as arguments, and the only decisions are which things, how, and where the wiring happens. This article covers the two styles that work for handlers, the interface rules that keep them testable, how to wire a whole service by hand in main, and the habits that look like injection and are not. It belongs to the web cluster that ends in a production HTTP server, pairs with route organization, and the testing it enables is the subject of testing handlers. Everything ran on Go 1.25.6 with the standard library only.

The problem in one picture

Here is the version most of us wrote first. The handler reaches for a package-level db, and a test that wants a different store has to overwrite the variable.

globals.goGo
package main

import (
	"fmt"
	"net/http"
)

type Store interface{ Name(id int) (string, bool) }

type realStore struct{}

func (realStore) Name(id int) (string, bool) { return "real-user", id == 1 }

// store is a package-level dependency. Every handler reads it, every test can change it.
var store Store = realStore{}

func getUser(w http.ResponseWriter, r *http.Request) {
	name, ok := store.Name(1)
	if !ok {
		http.NotFound(w, r)
		return
	}
	fmt.Fprint(w, name)
}
globals_test.goGo
package main

import (
	"net/http/httptest"
	"testing"
)

type fakeStore struct{}

func (fakeStore) Name(id int) (string, bool) { return "fake-user", true }

func TestWithAFake(t *testing.T) {
	store = fakeStore{} // forgot to restore it
	rec := httptest.NewRecorder()
	getUser(rec, httptest.NewRequest("GET", "/", nil))
	if rec.Body.String() != "fake-user" {
		t.Errorf("got %q", rec.Body.String())
	}
}

func TestWithTheRealThing(t *testing.T) {
	rec := httptest.NewRecorder()
	getUser(rec, httptest.NewRequest("GET", "/", nil))
	if rec.Body.String() != "real-user" {
		t.Errorf("got %q, want real-user", rec.Body.String())
	}
}
Text
=== RUN   TestWithAFake
--- PASS: TestWithAFake (0.00s)
=== RUN   TestWithTheRealThing
    globals_test.go:25: got "fake-user", want real-user
--- FAIL: TestWithTheRealThing (0.00s)
FAIL
FAIL	example.test/lab	0.348s
FAIL

The second test fails, even though nothing in it is wrong: the first one left the fake installed. Go runs tests in a file in source order, so the failure is reproducible here; with t.Parallel() or a reordering flag such as -shuffle=on it becomes intermittent, which is far worse. The cure is for tests to be able to build an isolated handler with its own dependencies, which requires the dependency to be an input.

Style one: a struct with methods

Put the dependencies in a struct and make the handlers methods on it.

server.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strconv"
)

type User struct {
	ID   int
	Name string
}

var ErrNotFound = errors.New("user not found")

// UserStore is defined here, where it is used, and lists only what these handlers call.
type UserStore interface {
	Find(ctx context.Context, id int) (User, error)
}

type Server struct {
	Users UserStore
	Log   func(format string, args ...any)
}

func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "bad id", http.StatusBadRequest)
		return
	}
	u, err := s.Users.Find(r.Context(), id)
	switch {
	case errors.Is(err, ErrNotFound):
		http.NotFound(w, r)
	case err != nil:
		s.Log("find user %d: %v", id, err)
		http.Error(w, "internal error", http.StatusInternalServerError)
	default:
		fmt.Fprintf(w, "%d:%s", u.ID, u.Name)
	}
}

func (s *Server) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", s.GetUser)
	return mux
}

This is the style I use by default. The struct is the list of the service's collaborators, in one place, with names. Adding a dependency is adding a field, and a reader sees everything the handlers can touch. Methods share the dependencies without repeating them, which matters when the struct has a dozen handlers. The cost is that the struct can drift into a grab bag where each handler uses two of ten fields. When that happens, split by feature, with one struct per area (UserHandlers, BillingHandlers), as in route organization.

Style two: a constructor that returns the handler

The alternative is a function that takes exactly what one handler needs and returns the http.HandlerFunc as a closure.

closure.goGo
package main

import (
	"fmt"
	"net/http"
	"strconv"
)

func GetUserHandler(users UserStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, _ := strconv.Atoi(r.PathValue("id"))
		u, err := users.Find(r.Context(), id)
		if err != nil {
			http.NotFound(w, r)
			return
		}
		fmt.Fprintf(w, "%d:%s", u.ID, u.Name)
	}
}

Each handler declares its dependencies in its signature, so you can see at a glance that this one needs a UserStore and nothing else. It is the better fit for small services and for handlers that need unusual setup, such as a compiled template or a regular expression built once at construction time and captured by the closure. The weakness is verbosity at scale: with thirty handlers, thirty constructors, each repeating the logger and the store in its argument list.

I mix them without guilt: the struct for the bulk of the handlers, and a constructor for the odd one that has its own expensive setup. Both are the same idea. The test for either is identical.

Interfaces where they are consumed

UserStore above has one method, Find, and it lives in the same package as the handlers. Two rules sit behind that choice.

The interface belongs to the consumer. The package that implements the store, with its SQL and its connection pool, should not export a UserStoreInterface for others to depend on. Each consumer declares the slice of behavior it needs, and Go's implicit interface satisfaction means the real store matches without knowing it. This is the idea behind "accept interfaces, return structs" from the article on that proverb.

And the interface is small. The handlers need Find; they do not need the twelve other methods the store has. A fake for a one-method interface is five lines, and a fake for a fifteen-method interface is a chore that people avoid, which is how a codebase ends up with tests that hit a real database or no tests at all.

Testing with fakes

server_test.goGo
package main

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

type fakeUsers struct {
	users map[int]User
	err   error
}

func (f fakeUsers) Find(_ context.Context, id int) (User, error) {
	if f.err != nil {
		return User{}, f.err
	}
	u, ok := f.users[id]
	if !ok {
		return User{}, ErrNotFound
	}
	return u, nil
}

func TestServerAndClosureBehaveTheSame(t *testing.T) {
	users := fakeUsers{users: map[int]User{1: {1, "ann"}}}
	var logged []string
	srv := &Server{Users: users, Log: func(f string, a ...any) { logged = append(logged, f) }}

	handlers := map[string]http.Handler{
		"struct":  srv.Routes(),
		"closure": routeFor(GetUserHandler(users)),
	}
	for name, h := range handlers {
		t.Run(name, func(t *testing.T) {
			for path, want := range map[string]int{"/users/1": 200, "/users/9": 404} {
				rec := httptest.NewRecorder()
				h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
				if rec.Code != want {
					t.Errorf("%s: status %d, want %d", path, rec.Code, want)
				}
			}
		})
	}
}

func TestStoreFailureIsLoggedAndHidden(t *testing.T) {
	var logged []string
	srv := &Server{
		Users: fakeUsers{err: errors.New("connection refused")},
		Log:   func(f string, a ...any) { logged = append(logged, f) },
	}
	rec := httptest.NewRecorder()
	srv.Routes().ServeHTTP(rec, httptest.NewRequest("GET", "/users/1", nil))
	if rec.Code != 500 || strings.Contains(rec.Body.String(), "refused") {
		t.Errorf("status %d body %q: the internal error must not reach the client", rec.Code, rec.Body)
	}
	if len(logged) != 1 {
		t.Errorf("logged %d times, want 1", len(logged))
	}
}

func routeFor(h http.HandlerFunc) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", h)
	return mux
}
Text
=== RUN   TestServerAndClosureBehaveTheSame
=== RUN   TestServerAndClosureBehaveTheSame/struct
=== RUN   TestServerAndClosureBehaveTheSame/closure
--- PASS: TestServerAndClosureBehaveTheSame (0.00s)
    --- PASS: TestServerAndClosureBehaveTheSame/struct (0.00s)
    --- PASS: TestServerAndClosureBehaveTheSame/closure (0.00s)
=== RUN   TestStoreFailureIsLoggedAndHidden
--- PASS: TestStoreFailureIsLoggedAndHidden (0.00s)
PASS
ok  	example.test/lab	1.262s

Every test builds its own handler with its own fake, so tests are independent and can run in parallel without a lock. The failure test uses the same pattern to reach a branch that is almost impossible to trigger with a real database: the store returning an error. And the table runs both styles against the same expectations, which is what "equivalent" means in practice.

The composition root

All the wiring happens in one place, main, which builds the graph by hand from the outside in. Nothing else constructs a database connection or knows which implementation is real.

main.goGo
package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
)

// db stands in for a real connection pool; the prints show the order of creation and cleanup.
type db struct{ name string }

func openDB(name string) *db {
	fmt.Println("open  ", name)
	return &db{name: name}
}

func (d *db) Close() { fmt.Println("close ", d.name) }

func (d *db) Find(_ context.Context, id int) (User, error) {
	if id == 1 {
		return User{ID: 1, Name: "ann"}, nil
	}
	return User{}, ErrNotFound
}

func main() {
	// 1. Leaves first: things with no dependencies.
	logf := func(f string, a ...any) { fmt.Printf("log: "+f+"\n", a...) }
	database := openDB("postgres")
	defer database.Close() // closed last, because it was opened first

	cache := openDB("redis")
	defer cache.Close()

	// 2. The service: everything it needs is a constructor argument or a field.
	srv := &Server{Users: database, Log: logf}

	// 3. The HTTP layer, which only knows the Server.
	httpSrv := httptest.NewServer(srv.Routes())
	defer func() {
		fmt.Println("stop   http server")
		httpSrv.Close() // stopped first, so no request arrives after a dependency is closed
	}()

	res, _ := http.Get(httpSrv.URL + "/users/1")
	fmt.Println("served GET /users/1 ->", res.Status)
	res.Body.Close()
	fmt.Println("shutting down")
}
Text
open   postgres
open   redis
served GET /users/1 -> 200 OK
shutting down
stop   http server
close  redis
close  postgres

The output shows the discipline in the order of lines: dependencies open from the bottom up (database, then cache), and defer closes them in reverse, with the HTTP server stopped before either, so no request is in flight when a connection it needs disappears. If your resources need more than defer, such as a signal-driven shutdown with a drain period, that is graceful shutdown, and the same reverse-order rule applies.

Wiring by hand looks like it will get unmanageable, and in practice it rarely does. A service with twenty components has a main of about sixty lines, readable top to bottom, with a compile error when a constructor changes and nothing resolved at runtime. Compare that with a reflection-based container, where the same mistake is a panic at startup, or a missing binding found in production. Tools exist (google/wire generates this code at build time, uber-go/fx and dig resolve it at runtime), and for very large graphs wire's generated code is a fair trade. I have not needed any of them below a few dozen components.

A service layer between handlers and stores

As soon as a request involves a rule (a user may not be deleted while they own an open invoice), the logic should not live in the handler or in the store. A service type sits between them, takes its own small interfaces, and returns domain errors that the handler translates to status codes. The handler depends on a UserService interface with the two methods it calls, the service depends on a UserStore and an Invoices interface, and each layer is tested alone with fakes of the layer beneath it.

Text
handler (HTTP: decode, call, encode)
   |  UserService  (interface, defined in the handler's package)
service (rules, transactions, returns domain errors)
   |  UserStore, Invoices (interfaces, defined in the service's package)
store (SQL)

The arrows point downward and each interface is declared by the layer above it, so no package imports one that imports it back. That is also how you avoid import cycles, which are the usual first sign that a codebase has put an interface in the wrong place. If two packages need each other's types, the shared concept belongs in a third, smaller package of plain types.

Constructors, defaults and cleanup

A constructor should refuse an unusable dependency instead of letting a nil pointer surface at the first request. NewServer(users UserStore, log *slog.Logger) (*Server, error) can return an error when users is nil, and default log to slog.Default() when it is, which keeps tests short without a global. For three or four required arguments, plain parameters read best. When there are many optional ones, switch to functional options, NewServer(users, WithLogger(l), WithTimeout(5*time.Second)), covered in the functional options article.

Anything that owns a resource needs a way to release it. Give it a Close() error method, return the cleanup from the constructor or register it in main, and close in reverse order of creation, as the wiring program does. When several things close at shutdown, errors.Join collects their failures into one error so the first does not hide the second. The temptation to put cleanup in a finalizer or to rely on process exit is worth resisting: tests that create and drop resources thousands of times will otherwise leak them.

Testing the wiring itself

The composition root is code, and it can break: a field left unset, two arguments in the wrong order, a dependency created but never passed. Handler tests will not notice. A single smoke test that builds the whole application with fakes at the edges (a stub for the database, a recorder for outgoing mail) and sends one request through each major route catches the wiring mistakes cheaply. Make main a thin function that parses configuration and calls run(ctx, cfg) error, and test run with a context you cancel, as described in starting and stopping goroutines. Anything that cannot be exercised that way, because it lives directly in main, is code you have decided not to test.

Habits that look like injection and are not

init() that opens connections. It runs before main, cannot return an error, cannot be given a config, and makes every test that imports the package open a real connection. Construct explicitly.

Passing the whole app everywhere. A *App with forty fields passed to every function is a global in a trench coat: each function can reach everything, so a reader cannot tell what a function depends on. Pass the two things it uses.

Dependencies in context.Value. ctx.Value("db") is untyped, invisible in the signature, and fails at runtime when the middleware that set it did not run. Context values are for request-scoped data that cannot be a parameter, such as a trace ID or the authenticated user; keep services out of it, as the context article explains.

One interface per struct, in the implementing package. It is a Java habit. The interface with fifteen methods next to the struct gives every consumer an interface that is too large to fake.

Mocks generated for everything. A hand-written fake with a map is often shorter than the mock setup, and its failures read as ordinary Go. Generated mocks are fine at a boundary you cannot easily fake, such as a payment SDK.

Configuration is a dependency too

The same rule covers configuration. Read environment variables and files once in main, build a typed Config, and pass the pieces to the constructors that need them (NewServer(cfg.Timeout), not os.Getenv inside a handler). A function that reads the environment itself is a function you cannot test without changing the process environment, and the reader cannot tell it has a hidden input. See configuration for web apps for the parsing and validation side.

How this scales past one service

The pattern does not change when the company has many services; only the repetition does. Teams with several services often extract the pieces everyone writes the same way, a logger constructor, an HTTP server builder with the standard timeouts, a graceful shutdown helper, into a small internal module, and keep the wiring in each service's main. That module should export constructors and options, not a global registry. The moment shared code asks for dependencies by name at runtime, it has become a service locator, and every service that uses it has inherited an invisible coupling.

One more habit pays off at any size: make the wiring diff small. When a pull request adds a dependency, the main change should be one line to build it and one to pass it, and the handler change should be one field. If adding a collaborator forces edits in six files, the structure is carrying more indirection than it earns, and the right response is to remove a layer, not to add a tool that hides it.

A short checklist

  • Every handler's dependencies are visible as struct fields or constructor arguments.
  • Interfaces live with their consumers and have the methods the consumer calls, usually one to three.
  • main builds the graph, and nothing else creates a connection, client or logger.
  • Close resources in reverse order; stop accepting requests before closing what requests use.
  • Tests construct their own handler with fakes and share no package-level state.
  • No dependency travels in context.Value, a global or an init.

The official guidance is short and consistent: the Go team's code review comments say where interfaces belong, and the net/http documentation shows that a handler is whatever has a ServeHTTP method, which is what lets a struct with fields be one.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.