go dependency injection: techniques for clean and testable code

what is dependency injection and why should you care?

if you're writing go code that's more than a simple script, you've likely encountered a common problem: your functions and structs become tightly coupled to their dependencies. this makes your code hard to test, difficult to maintain, and brittle to change. dependency injection (di) is a design pattern that solves this by decoupling the creation of a dependency from its usage. in simple terms, instead of a struct creating its own database connection or logger, you inject (pass) those dependencies into it from the outside.

for a full stack or devops engineer, adopting di in your go services leads to cleaner architecture, more reliable unit tests with mocks, and code that's easier to reason about. it's a cornerstone technique for writing production-grade, coding best practices.

the manual approach: constructor injection

the simplest and most idiomatic form of di in go is constructor injection. you define a struct that holds its dependencies as fields, and provide a constructor function that accepts those dependencies as arguments.

let's see a classic example without di (the "bad" way):

type userservicebad struct {
    db *sql.db
}

func newuserservicebad() *userservicebad {
    // the service creates its own dependency.
    // this is hard to test and inflexible.
    db, _ := sql.open("postgres", "connection_string")
    return &userservicebad{db: db}
}

func (s *userservicebad) getuser(id int) (user, error) {
    // ... use s.db to query
}

now, let's refactor it using di:

// define an interface for our dependency. this is key for testability.
type userrepository interface {
    getuserbyid(id int) (user, error)
}

type postgresrepository struct {
    db *sql.db
}

func (r *postgresrepository) getuserbyid(id int) (user, error) {
    // ... actual database query
}

// our service now depends on the abstraction (interface), not the concrete implementation.
type userservice struct {
    repo userrepository
}

// constructor injection: dependencies are passed in.
func newuserservice(repo userrepository) *userservice {
    return &userservice{repo: repo}
}

func (s *userservice) getuser(id int) (user, error) {
    return s.repo.getuserbyid(id)
}

why is this better? in your tests, you can easily pass a mock repository that implements the userrepository interface, without needing a real database. this makes your unit tests fast, isolated, and reliable.

managing complex dependency graphs: frameworks

for small projects, manual di is perfect. but as your application grows—with dozens of services, configurations, and shared resources—manually wiring dependencies (creating the "dependency graph") becomes tedious and error-prone. this is where di frameworks come in.

they automate the process of constructing your object graph, managing lifetimes (like singletons), and ensuring all dependencies are satisfied. two popular choices in the go ecosystem are google wire and uber's dig/fx.

1. google wire: compile-time dependency injection

wire is a code-generation tool. you write "provider" functions that create your dependencies, and wire generates the inject function that wires everything together at compile time.

pros: no runtime reflection, type-safe, catches errors at compile time, minimal runtime overhead. cons: requires a separate code generation step (go generate).

example workflow:

  • you define your structs and provider functions (e.g., newuserservice, newpostgresrepo).
  • you create a wire.go file with wire.build(...) directives listing your final object and its dependencies.
  • run go generate, which creates a wire_gen.go file with a initializeuserservice() function that constructs the entire graph.
// +build wireinject
package main

import (
    "github.com/google/wire"
)

// this function is generated by wire. you call it to get a fully-wired userservice.
func initializeuserservice(config config) (*userservice, error) {
    wire.build(
        newconfig,          // provider for config (maybe reads env vars)
        newpostgresdb,      // provider for *sql.db
        newpostgresrepository, // provider that depends on *sql.db
        newuserservice,     // provider that depends on userrepository
    )
    return &userservice{}, nil
}

2. uber fx: runtime dependency injection

fx is a runtime di framework built on reflection. you define your application as a set of modules that provide and consume dependencies. fx then builds and runs your application's object graph.

pros: extremely flexible, powerful lifecycle hooks (onstart, onstop), great for complex applications with plugins. cons: runtime errors if the graph is misconfigured, slight reflection overhead.

example structure:

func main() {
    app := fx.new(
        // provide a config struct (a singleton)
        fx.provide(newconfig),
        // provide a database connection. fx will ensure it's created only once.
        fx.provide(newpostgresdb),
        // provide a repository that *depends on* the db.
        fx.provide(newpostgresrepository),
        // provide the final service.
        fx.invoke(newuserservice),
        // hook to start a server after all dependencies are ready.
        fx.invoke(func(s *userservice) { s.startserver() }),
    )
    app.run()
}

notice the separation: fx.provide registers constructors, fx.invoke calls functions (often for side-effects like starting a server) with all their dependencies filled.

comparison at a glance

featuremanual digoogle wireuber fx
type safetyfull (compile-time)full (compile-time, generated code)runtime (can fail at startup)
boilerplatehighlow (after generation)very low
reflection overheadnonenoneminimal
learning curvebasic (go interfaces)medium (code-gen patterns)steeper (application lifecycle)
best forsmall projects, librariesmedium/large apps, performance-sensitivelarge, complex microservices/plugins

best practices for testable go code

regardless of the technique you choose, follow these rules:

  • depend on abstractions (interfaces): your structs should depend on interfaces, not concrete implementations. this is the "d" in the solid principles and is fundamental for mocking.
  • keep constructors simple: constructors (newx) should do minimal work—just assign fields. complex setup logic can go in an init or start method, which is easier to test.
  • pass dependencies explicitly: the dependency chain should be visible in the function signatures. avoid using global variables (var db *sql.db) or init functions to set dependencies.
  • scope dependencies appropriately: is a dependency needed per-request (like a request-scoped logger) or for the entire application lifetime (like a database pool)? frameworks like fx help manage this with fx.option and lifecycle annotations.

conclusion: choose the right tool for your journey

start with manual constructor injection. it's the go way and teaches you the core concepts. when wiring becomes a chore, evaluate google wire for its simplicity and safety, or uber fx if you need its powerful runtime features. the ultimate goal is the same: clean, modular, and testable code. by mastering di, you move from just coding to architecting resilient go applications that scale in complexity and team size—a critical skill for any devops or full stack engineer.

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.