How to Implement Dependency Injection Manually in Go

Implement manual dependency injection in Go by defining interfaces and passing concrete implementations through constructor functions.

The problem with hardcoded dependencies

You are building a user service. It needs to fetch users from a database. You write a function that opens a connection to PostgreSQL, runs a query, and returns the result. It works. Then you want to write a test. You do not want to spin up a real database for every test run. You also realize the product manager might ask for a Redis cache layer next month. If your code is glued to PostgreSQL, you are rewriting everything.

Dependency injection solves this by letting you hand the service whatever data source it needs, without changing the service code. You define a contract for the data source. The service depends on the contract. You pass the concrete implementation when you create the service. This keeps your code flexible, testable, and easy to refactor.

Dependency injection in plain words

Think of dependency injection like a power tool with a detachable battery. The drill does not care if the battery is brand new, recycled, or a mock battery for a demo. As long as the battery fits the slot and provides power, the drill works. In Go, the slot is an interface. The battery is the concrete implementation. You build the drill once. You swap batteries at runtime.

The core idea is simple. Do not create your dependencies inside your functions. Pass them in from the outside. This decouples your business logic from the infrastructure. You can test the logic with fake dependencies. You can swap the database without touching the service. You can run the same service in production with a real database and in tests with an in-memory mock.

Go does not have a built-in dependency injection framework. The standard library does not provide one. The community generally prefers manual injection. Go values explicit code. When you read a function signature, you see exactly what it needs. There is no hidden magic pulling dependencies from a global container. This makes the code easier to understand and refactor. You can trace dependencies by following the constructor calls.

Interfaces are contracts. Keep them small.

Minimal example

Here is the simplest dependency injection pattern. You define an interface for the dependency. You create a struct that holds the dependency. You write a constructor that accepts the dependency. You pass the concrete implementation when you create the struct.

// Repository defines the dependency contract.
// The service depends on this interface, not a concrete type.
type Repository interface {
	Get(id string) (*User, error)
}

// User is the domain entity.
type User struct {
	ID   string
	Name string
}

// UserService holds the repository dependency.
// The field is private to enforce encapsulation.
type UserService struct {
	repo Repository
}

// NewUserService creates the service.
// The caller passes the concrete implementation here.
func NewUserService(repo Repository) *UserService {
	return &UserService{repo: repo}
}

// GetUser delegates work to the injected repository.
// The service logic remains independent of the data source.
func (s *UserService) GetUser(id string) (*User, error) {
	return s.repo.Get(id)
}

Here is how you use the service with a mock implementation. The mock satisfies the interface because it has the same methods. The compiler checks this automatically.

// MockRepo implements Repository for testing.
// This allows swapping the dependency without changing the service.
type MockRepo struct{}

func (m *MockRepo) Get(id string) (*User, error) {
	return &User{ID: id, Name: "Alice"}, nil
}

func main() {
	// Create the mock dependency.
	repo := &MockRepo{}

	// Inject the dependency into the service.
	svc := NewUserService(repo)

	// Use the service.
	user, err := svc.GetUser("123")
	if err != nil {
		panic(err)
	}
	println(user.Name)
}

The compiler enforces the contract. Trust it.

How it works at compile and runtime

At compile time, the compiler checks that MockRepo satisfies Repository. It does not care that MockRepo is a mock. It only checks the method set. If MockRepo misses a method, you get a compile error. The error looks like cannot use mock (type *MockRepo) as Repository value in argument: *MockRepo does not implement Repository (missing Get method). This is good. It prevents runtime surprises.

Go uses structural typing for interfaces. You do not write type MockRepo struct implements Repository. You just write the methods. If the methods match, the type implements the interface. This reduces coupling. You can add an interface to an existing type without modifying the type. This is powerful for legacy code or third-party packages. You can define an interface that matches a third-party struct and use it in your code without importing the third-party package directly.

At runtime, NewUserService stores the pointer to MockRepo in the repo field. When GetUser calls s.repo.Get, dynamic dispatch happens. The interface value holds a pointer to the concrete type and a pointer to the method table. The call goes to MockRepo.Get. This is how Go implements interfaces. The overhead is small. The flexibility is high.

Interfaces are satisfied implicitly. No declarations needed.

Realistic example with context and HTTP

Real code has more moving parts. You have an HTTP handler. It needs a service. The service needs a repo. You wire them up in main. You also pass context.Context to support cancellation and deadlines.

Here is the updated service and repository interface. The context is always the first parameter by convention. This allows the HTTP request cancellation to propagate through the call chain.

// Repository interface includes context.
// Context is always the first parameter by convention.
type Repository interface {
	Get(ctx context.Context, id string) (*User, error)
}

// UserService passes context through.
// This propagates cancellation and deadlines.
type UserService struct {
	repo Repository
}

// GetUser delegates to the repository with context.
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.Get(ctx, id)
}

Here is the HTTP handler. It depends on the service. It extracts the ID from the request and calls the service. The receiver name h matches Handler. This is the standard convention for receiver names: one or two letters matching the type.

// Handler depends on the service.
// It bridges HTTP requests to business logic.
type Handler struct {
	svc *UserService
}

// NewHandler injects the service.
func NewHandler(svc *UserService) *Handler {
	return &Handler{svc: svc}
}

// ServeHTTP handles the request.
// It passes the request context to the service.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get("id")
	user, err := h.svc.GetUser(r.Context(), id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	fmt.Fprintf(w, "User: %s", user.Name)
}

Here is the wiring logic in main. This is the composition root. You create the concrete dependencies, inject them layer by layer, and start the server. The wiring logic stays in one place. The rest of the code focuses on business logic.

func main() {
	// Create concrete dependencies.
	db, err := sql.Open("postgres", "dsn")
	if err != nil {
		panic(err)
	}
	repo := &PostgresRepo{db: db}

	// Inject dependencies layer by layer.
	svc := NewUserService(repo)
	handler := NewHandler(svc)

	// Start the server.
	http.ListenAndServe(":8080", handler)
}

Wire dependencies in main. Keep business logic pure.

Pitfalls and compiler errors

Big interfaces are a common mistake. If you define a Repository with twenty methods, your mock becomes huge. Every test has to implement all twenty methods, even if it only uses one. This violates the interface segregation principle. Split the interface. Create UserRepository and OrderRepository. Or create smaller interfaces like UserGetter and UserSaver. Small interfaces are easier to test and reuse.

Circular dependencies break the graph. Service A needs Service B. Service B needs Service A. You cannot wire this up manually. Refactor the code. Extract the shared dependency into a third service. Or merge the services. Circular dependencies usually indicate a design problem.

Returning interfaces is another pitfall. The Go community follows the mantra "accept interfaces, return structs." Do not return an interface from a constructor. Return the concrete struct. The caller decides if they want to treat it as an interface. This gives the caller more flexibility. If you return an interface, you lock the caller into that interface. If you return a struct, the caller can use the struct directly or assign it to an interface.

If you forget to capture a loop variable in a closure, the compiler rejects the program with loop variable i captured by func literal. This is a hard error in Go 1.22+. Dependency injection does not use closures for wiring, but it is worth noting. Manual injection uses constructors and fields. This is safer and easier to debug.

Do not let interfaces bloat. Split them early.

When to use manual dependency injection

Use manual dependency injection when you want full control over your dependency graph and keep your codebase free of external frameworks. Go's standard library and idiomatic style support this pattern naturally. You get testability, flexibility, and explicit code without adding complexity.

Use a dependency injection container when your application has a complex web of dependencies that makes manual wiring tedious and error-prone. Containers can automate the wiring process. They can also handle lifecycle management and scoping. However, they add a layer of indirection. You lose the ability to trace dependencies by reading the code. Reach for a container only when the pain of manual wiring is real.

Use global variables for configuration in small scripts where testability is not a concern. Global variables are simple. They are also hard to test and reason about. Avoid them in larger applications.

Use functional options when you need to pass dependencies along with many optional configuration values to a constructor. Functional options let you configure a struct with a clean API. You can combine dependencies and options in the same constructor. This is a common pattern in Go libraries.

Manual wiring is Go's default. Reach for frameworks only when the pain is real.

Where to go next