Dependency Injection in Go Is Just Passing In What You Need
No framework, no container, no reflection scanning your struct fields at startup: Go's version of dependency injection is a constructor argument, and interfaces are what make that argument swappable.
Every Go codebase past a certain size ends up with the same shape: a handler that calls a service, a service that calls a store, and a store that talks to a database. Wire those three together the wrong way and you get a service that can’t be tested without a live Postgres connection, and a handler that can’t be reused with a different backend. Dependency injection is just the name for wiring it the other way: pass each layer what it needs instead of letting it go find it.
A Struct That Builds Its Own Dependencies Is Hard to Change
Here’s the version that feels natural to write first: a service that opens its own database connection inside its constructor.
type User struct { ID string Name string}
type UserService struct { db *sql.DB}
func NewUserService() *UserService { db, err := sql.Open("postgres", os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) } return &UserService{db: db}}This compiles and works, but UserService now owns two jobs: the business logic, and deciding how to connect to Postgres. Swap databases, add a second service that needs the same connection, or write a test that shouldn’t touch a real database, and you’re stuck, because the connection only exists inside this one constructor.
Dependency Injection Is Just Passing That Dependency In Instead
The fix isn’t a library. It’s moving the *sql.DB out of the constructor and into an argument:
type UserService struct { db *sql.DB}
func NewUserService(db *sql.DB) *UserService { return &UserService{db: db}}UserService no longer knows or cares how the connection was created, it just uses whatever it was handed. That’s the entire mechanism behind dependency injection in Go: a constructor argument instead of a hardcoded value. There’s no annotation, no container, no reflection scanning your struct fields at startup the way Spring or NestJS do it. You pass the thing in, by hand, like any other argument.
Interfaces Are What Make the Injected Value Swappable
Passing in a concrete *sql.DB is already better, but it still locks UserService to Postgres specifically. The next step is depending on an interface instead of a concrete type:
type UserStore interface { GetUser(ctx context.Context, id string) (*User, error) CreateUser(ctx context.Context, u *User) error}
type UserService struct { store UserStore}
func NewUserService(store UserStore) *UserService { return &UserService{store: store}}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) { return s.store.GetUser(ctx, id)}Now UserService only knows about two methods, not a database driver. A Postgres-backed store satisfies UserStore:
type PostgresUserStore struct { db *sql.DB}
func NewPostgresUserStore(db *sql.DB) *PostgresUserStore { return &PostgresUserStore{db: db}}
func (s *PostgresUserStore) GetUser(ctx context.Context, id string) (*User, error) { var u User err := s.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name) return &u, err}
func (s *PostgresUserStore) CreateUser(ctx context.Context, u *User) error { _, err := s.db.ExecContext(ctx, "INSERT INTO users (id, name) VALUES ($1, $2)", u.ID, u.Name) return err}and so does anything else with the same two methods. UserService doesn’t get to tell the difference, which is exactly the point.
Wiring Handlers, Services, and Stores Happens Once, in main
Handlers depend on services the same way services depend on stores: through the constructor.
type UserHandler struct { service *UserService}
func NewUserHandler(service *UserService) *UserHandler { return &UserHandler{service: service}}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { user, err := h.service.GetUser(r.Context(), r.PathValue("id")) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } json.NewEncoder(w).Encode(user)}Somewhere has to actually build all three layers and hand them to each other, and that’s main:
func main() { db, err := sql.Open("postgres", os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) }
store := NewPostgresUserStore(db) service := NewUserService(store) handler := NewUserHandler(service)
mux := http.NewServeMux() mux.HandleFunc("GET /users/{id}", handler.GetUser)
log.Fatal(http.ListenAndServe(":8080", mux))}That’s the whole dependency graph, built by hand: db feeds the store, the store feeds the service, the service feeds the handler. Nothing here is injected automatically, I’m typing every line of it. For a project this size that’s a feature, not a limitation: I can read main top to bottom and see exactly how the app is assembled instead of trusting a container to have gotten it right. Once main gets long enough that this wiring turns tedious, tools like google/wire generate the same code from a set of provider functions, but it’s still generating plain constructor calls, not adding runtime reflection.
Why This Pattern Is Everywhere: Tests Stop Needing a Real Database
This is the actual reason DI shows up in nearly every non-trivial Go service: it turns UserStore from a hardcoded dependency into a parameter, and a parameter can be anything that satisfies the interface, including a fake built for a test.
type fakeUserStore struct { users map[string]*User}
func (f *fakeUserStore) GetUser(ctx context.Context, id string) (*User, error) { u, ok := f.users[id] if !ok { return nil, errors.New("user not found") } return u, nil}
func (f *fakeUserStore) CreateUser(ctx context.Context, u *User) error { f.users[u.ID] = u return nil}
func TestUserService_GetUser(t *testing.T) { store := &fakeUserStore{users: map[string]*User{"1": {ID: "1", Name: "Bob"}}} service := NewUserService(store)
user, err := service.GetUser(context.Background(), "1") if err != nil { t.Fatal(err) } if user.Name != "Bob" { t.Errorf("got %q, want Bob", user.Name) }}No test database to start, no fixtures to clean up between runs. NewUserService doesn’t know or care whether it received *PostgresUserStore or *fakeUserStore, and that’s the whole design paying for itself.
One More Example, With File Storage Instead of a Database
Everything above uses a database because that’s where DI usually bites first, but none of it is about databases. Anything with more than one plausible implementation gets the same treatment. File storage is the clearest second case: local disk while I’m developing, S3 in production.
type Storage interface { Save(key string, data []byte) error Get(key string) ([]byte, error) Delete(key string) error}
type LocalStorage struct { BasePath string}
func (l LocalStorage) Save(key string, data []byte) error { fmt.Printf("Writing %s to disk at %s/%s\n", key, l.BasePath, key) return nil}
func (l LocalStorage) Get(key string) ([]byte, error) { fmt.Printf("Reading %s from disk\n", key) return []byte("dummy data"), nil}
func (l LocalStorage) Delete(key string) error { fmt.Printf("Deleting %s from disk\n", key) return nil}
type S3Storage struct { Bucket string}
func (s S3Storage) Save(key string, data []byte) error { fmt.Printf("Uploading %s to s3://%s/%s\n", key, s.Bucket, key) return nil}
func (s S3Storage) Get(key string) ([]byte, error) { fmt.Printf("Fetching %s from s3://%s\n", key, s.Bucket) return []byte("dummy data"), nil}
func (s S3Storage) Delete(key string) error { fmt.Printf("Deleting %s from s3://%s\n", key, s.Bucket) return nil}Both implementations are stubs that print what they would do, but the shape is the part worth looking at: two unrelated types, the same three methods, neither one aware the other exists. The consumer depends on the interface and nothing else.
type AvatarService struct { Store Storage}
func (a AvatarService) UploadAvatar(userID string, data []byte) error { return a.Store.Save("avatars/"+userID, data)}
func main() { svc := AvatarService{Store: S3Storage{Bucket: "my-app-uploads"}} if err := svc.UploadAvatar("user-123", []byte("fake image bytes")); err != nil { log.Fatal(err) }}AvatarService has no constructor here, and it doesn’t need one: the injection point is the Store field, and main fills it. Switching to LocalStorage{BasePath: "./uploads"} for local development is a one-line change in main, and UploadAvatar never finds out.
Takeaway
Dependency injection in Go isn’t a framework feature you opt into. It’s a habit: constructors take interfaces instead of reaching for globals or building their own dependencies, and the wiring happens once, by hand, in main. The payoff isn’t really in production, where the real store gets built either way, it’s in every test file that gets to skip the database entirely.