How to Avoid DI Frameworks and Use Plain Go

Avoid Go DI frameworks by using constructor functions and interfaces to manually inject dependencies into your structs and functions.

The manual wire is the feature

You join a Go project expecting to find a configuration file that wires everything together. You look for the @Inject annotation, the dependency graph generator, or the reflection-based container. You find none. Instead, you see a main.go that calls NewServer, which calls NewDB, which calls NewLogger. The code feels manual, almost primitive. You wonder if the team forgot to set up the framework. They didn't. They chose plain Go.

In many ecosystems, dependency injection frameworks hide the construction of objects behind annotations and runtime magic. The framework scans your code, builds a graph, and instantiates objects for you. This abstraction costs you compile-time safety and runtime performance. It also makes the flow of data opaque. When something breaks, you debug the framework's configuration rather than your code.

Go takes a different path. You build the dependency graph yourself, explicitly, at compile time. There is no magic. If a service needs a database, you pass the database to the service. The compiler checks the types. If you pass the wrong thing, the build fails. This explicit wiring makes the flow of data obvious and keeps the codebase free of hidden behavior. You trade a few lines of boilerplate for total visibility and zero runtime surprises.

Trust the compiler. Wire it yourself.

Dependency injection in plain words

Dependency injection is just passing what a function or struct needs to do its job, rather than letting it create the dependency itself. Think of it like wiring a house. A smart home framework is like a system where you tell a voice assistant "Light the kitchen," and it figures out the switch, the wire, and the bulb. If the light doesn't work, you check the cloud service, the hub, and the bulb.

Plain Go is you running the wire from the switch to the bulb yourself. You see every connection. If the light doesn't work, you check the wire you ran. The dependency graph is a chain of function calls. main creates the dependencies and passes them to the components that need them. This pattern is often called the "composition root." The root is usually main or a top-level package where the application starts. All dependencies flow downward from there.

This approach relies heavily on interfaces. An interface defines a set of methods. Any type that implements those methods satisfies the interface. Go interfaces are implicit. You don't declare that a type implements an interface. You just write the methods. The compiler checks the method set. This means you can swap implementations without changing the consumer. You can pass a real database in production and a mock database in tests. The consumer doesn't care. It only cares about the interface.

Interfaces define behavior, not structure. Define them close to the consumer, and keep them small.

Minimal example

Here's the simplest dependency injection pattern in Go: define an interface, implement it, and pass it to a constructor.

package main

// Logger defines the behavior required for logging.
// Interfaces are small and focused on capability.
type Logger interface {
	Log(msg string)
}

// StdLogger implements Logger using standard output.
// Concrete types are unexported if they are only used locally.
type stdLogger struct{}

// Log prints the message to stdout.
// Receiver is lowercase to match the type name convention.
func (s *stdLogger) Log(msg string) {
	println(msg)
}

// Service depends on a Logger.
// The field is unexported because callers don't need direct access.
type service struct {
	logger Logger
}

// NewService creates a new service with the provided logger.
// Constructor functions are the standard way to inject dependencies.
func NewService(logger Logger) *service {
	return &service{logger: logger}
}

// Run demonstrates the service using its dependency.
func (s *service) Run() {
	s.logger.Log("Service started")
}

func main() {
	// Create the concrete dependency.
	logger := &stdLogger{}

	// Inject the dependency into the service.
	// The compiler ensures logger satisfies the Logger interface.
	svc := NewService(logger)

	svc.Run()
}

When you run this, the compiler checks that *stdLogger implements Logger. It does this by verifying the method set. *stdLogger has a Log(string) method, so it satisfies the interface. No registration required. At runtime, main creates the logger, passes it to NewService, and the service holds a reference. The dependency graph is a simple chain: main -> service -> logger. You can trace every arrow. If you change the logger to a file logger, you only change the line in main. The service doesn't know or care. This is the power of interfaces. The service depends on a behavior, not an implementation.

Notice the receiver name (s *stdLogger). The convention is to use one or two letters matching the type, not this or self. This keeps method signatures clean and consistent across the standard library and community code.

Realistic example

Real applications have more dependencies. A HTTP handler might need a database, a logger, and a context for cancellation. Here's how you wire a handler with multiple dependencies using a struct and a constructor.

package main

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

// UserStore defines how to retrieve user data.
// Interfaces are named by the capability they provide.
type UserStore interface {
	GetUser(ctx context.Context, id string) (User, error)
}

// User represents a domain entity.
type User struct {
	ID   string
	Name string
}

// Handler holds dependencies for request handling.
// Grouping dependencies in a struct keeps the handler signature clean.
type Handler struct {
	store  UserStore
	logger Logger
}

// NewHandler creates a handler with all required dependencies.
// Constructor validates dependencies are non-nil if needed.
func NewHandler(store UserStore, logger Logger) *Handler {
	return &Handler{
		store:  store,
		logger: logger,
	}
}

The handler groups its dependencies in a struct. This avoids passing the same dependencies to every method. The constructor NewHandler takes the dependencies and returns a configured handler. This is the composition point for the handler.

// ServeHTTP handles the HTTP request.
// It uses the injected store and logger.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	h.logger.Log("Handling request")

	user, err := h.store.GetUser(ctx, "123")
	if err != nil {
		h.logger.Log(fmt.Sprintf("Error: %v", err))
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}

	fmt.Fprintf(w, "User: %s", user.Name)
}

// DBUserStore implements UserStore using a mock database.
type dbUserStore struct{}

// GetUser retrieves a user from the database.
// It respects context cancellation for timeouts.
func (d *dbUserStore) GetUser(ctx context.Context, id string) (User, error) {
	select {
	case <-ctx.Done():
		return User{}, ctx.Err()
	default:
		return User{ID: id, Name: "Alice"}, nil
	}
}

The handler uses the injected store and logger. Notice ctx is the first parameter in GetUser. This is a hard convention in Go. Functions that perform I/O or long-running work take a context.Context as the first argument, named ctx. It allows cancellation and deadlines to propagate through the call chain. The handler extracts the context from the request and passes it down. If the client cancels the request, the context signals the store to stop.

func main() {
	// Wire dependencies in main.
	// This is the composition root where the graph is built.
	store := &dbUserStore{}
	logger := &stdLogger{}
	handler := NewHandler(store, logger)

	http.ListenAndServe(":8080", handler)
}

The main function is the composition root. It creates the concrete dependencies and wires them together. The graph is explicit. You can see exactly what the handler depends on. If you add a new dependency, you add it to the handler struct and the constructor. The compiler forces you to update every call site. This prevents accidental omissions.

The error handling boilerplate if err != nil is verbose by design. The community accepts it because it makes the unhappy path visible. You can't hide errors behind exceptions or silent failures. Every error must be handled or returned. This makes the code robust and predictable.

Pitfalls and compiler errors

The biggest pitfall is circular dependencies. If Service A needs Service B, and Service B needs Service A, you can't construct either. The compiler won't catch this directly; it manifests as a runtime panic or a build error if you try to initialize them in the wrong order. If you structure your code so that dependencies flow in one direction, usually from infrastructure to domain, you avoid cycles. Domain logic should not depend on infrastructure. Infrastructure depends on domain interfaces.

If you try to pass a type that doesn't implement the interface, the compiler rejects it. You get cannot use store (variable of type *dbStore) as UserStore value in argument: *dbStore does not implement UserStore (missing GetUser method). This error tells you exactly which method is missing. It's better than a runtime panic.

Another pitfall is interface pollution. Don't create an interface for every type. Go interfaces are implicit. You don't need to define an interface if only one type implements it. Define the interface where it is used, not where it is implemented. This keeps the interface small and focused on the consumer's needs. If you define an interface too early, you might add methods that the consumer doesn't need, making it harder to mock in tests.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If you inject a channel or a context, ensure the consumer respects cancellation. The worst goroutine bug is the one that never logs.

Interfaces are cheap. Frameworks are not.

When to use plain Go versus helpers

Use plain Go constructors when you want explicit control over dependency creation and compile-time safety. Use a lightweight wiring helper like fx or wire when your application has dozens of dependencies and manual wiring becomes repetitive boilerplate. Use function parameters for simple dependencies that are only needed in a single scope. Use a struct to group dependencies when a component needs multiple collaborators that are used across several methods. Use interfaces for dependencies that you want to mock in tests or swap with different implementations. Use concrete types for dependencies that have only one implementation and are part of the core infrastructure.

Accept interfaces, return structs. This mantra keeps your public API stable and your internal implementation flexible.

Where to go next

Learn how to structure your packages to keep dependencies flowing the right way in How to Separate Business Logic from Infrastructure in Go.

Master error handling patterns that work well with injected dependencies in errors.Is vs errors.As.

See how configuration fits into the dependency graph in How to Use koanf as a Viper Alternative.