How to Use dig for Reflection-Based DI in Go

Use the github.com/uber-go/dig library for reflection-based DI in Go, as the dig command is for DNS lookups.

How to Use dig for Reflection-Based DI in Go

You are building a Go service. The main function is swelling with initialization code. You need a database connection, a cache client, a logger, and three layers of business logic that all depend on each other. You remember how other languages solve this with dependency injection containers, so you search for a Go package called dig. The first result is a DNS lookup utility. The second is github.com/uber-go/dig, a reflection-based DI container. You install the Uber package, but the documentation feels like it assumes you already know how dependency graphs work. You want to understand what the container is actually doing under the hood, why Go developers debate its use, and how to wire your application without losing control.

Dependency injection is just passing dependencies to the code that needs them instead of letting that code create them itself. A DI container automates the wiring. You tell the container how to build each piece. You tell it what you want to run. The container uses reflection to inspect function signatures, matches return values to required parameters, builds the missing pieces, and calls your function.

dig from Uber does exactly this. It does not generate code. It does not require a build step. It works at runtime by reading your function signatures and constructing a directed acyclic graph of dependencies. The container keeps a registry of provider functions. When you ask it to invoke a target function, it traces the graph, calls the providers in the correct order, and passes the results along.

Go favors explicit initialization. Many teams prefer writing the wiring by hand because it compiles to static calls and fails fast. dig trades compile-time guarantees for flexibility. You can swap implementations, mock dependencies for tests, and restructure the graph without touching the call sites. The tradeoff is runtime reflection and error messages that can feel cryptic until you know how to read them.

Containers remove boilerplate. They also hide the initialization order. Know what you are trading.

Minimal example

Here is the smallest working container. It registers a factory function, asks for a dependency, and prints the result.

package main

import (
	"fmt"
	"github.com/uber-go/dig"
)

// Service defines the behavior we want to use.
type Service interface {
	Do() string
}

// serviceImpl satisfies Service.
type serviceImpl struct{}

// Do returns a simple greeting.
func (s *serviceImpl) Do() string {
	return "Hello from dig"
}

func main() {
	// Create an empty container with no registered providers.
	c := dig.New()

	// Register a factory that returns a concrete type.
	// dig caches the result so this function runs only once per scope.
	c.Provide(func() *serviceImpl { return &serviceImpl{} })

	// Ask the container to call this function with dependencies injected.
	// dig matches the parameter type against registered providers.
	c.Invoke(func(s Service) {
		fmt.Println(s.Do())
	})
}

Keep providers small. One responsibility per factory function makes the graph readable.

How resolution works

When the program runs, dig.New() allocates a container with an empty registry. c.Provide stores the anonymous function. The container uses reflection to read the return type: *serviceImpl. It notes that this provider can satisfy any request for *serviceImpl or any interface it implements, like Service.

c.Invoke triggers the resolution. The container reads the parameter type of the passed function: Service. It searches the registry for a provider that returns a compatible type. It finds the *serviceImpl factory. Since serviceImpl implements Service, the match succeeds. The container calls the provider, gets a *serviceImpl value, and passes it to the invoke function. The function runs and prints the greeting.

If you remove the Provide call, the container cannot find a matching provider. It panics with a resolution error that lists the missing type and the call stack of providers it tried to build. The error message looks like dig: missing dependency for type "main".Service in func literal. The container traces the graph and stops at the first gap.

Go convention dictates that public names start with a capital letter and private names start lowercase. The container respects this. It only injects types that are visible to the calling package. If you register a provider returning a lowercase type, the container stores it, but external packages cannot request it. This matches Go's visibility rules without extra configuration. The receiver name is usually one or two letters matching the type. Write (s *serviceImpl) Do() instead of (this *serviceImpl) Do(). It keeps the code tight and matches the standard library style.

Reflection reads signatures at runtime. Type mismatches surface during execution, not compilation. Test the wiring early.

Realistic wiring chain

Real applications rarely have single dependencies. They have chains. A configuration struct feeds a database connection, which feeds a repository, which feeds a service. Here is how you wire a three-step chain without manual nesting.

package main

import (
	"fmt"
	"github.com/uber-go/dig"
)

// Config holds application settings.
type Config struct {
	DSN string
}

// DBConn represents a database connection.
type DBConn struct{}

// Repo handles data access.
type Repo struct{}

// Service handles business logic.
type Service struct{}

// NewConfig reads settings from environment or files.
func NewConfig() *Config {
	return &Config{DSN: "postgres://localhost/mydb"}
}

// NewDBConn opens a connection using the config.
func NewDBConn(cfg *Config) *DBConn {
	_ = cfg.DSN
	return &DBConn{}
}

// NewRepo creates a repository backed by the database.
func NewRepo(db *DBConn) *Repo {
	return &Repo{}
}

// NewService builds the business layer.
func NewService(r *Repo) *Service {
	return &Service{}
}

func main() {
	c := dig.New()

	// Register each factory independently.
	// dig builds the dependency graph lazily on first invoke.
	c.Provide(NewConfig)
	c.Provide(NewDBConn)
	c.Provide(NewRepo)
	c.Provide(NewService)

	// Invoke the final layer. dig resolves the chain automatically.
	// It calls providers in reverse dependency order.
	c.Invoke(func(s *Service) {
		fmt.Println("Service initialized successfully")
	})
}

The container does not care about the order of Provide calls. It builds the graph lazily. When Invoke asks for *Service, it sees NewService needs *Repo. It checks for *Repo, finds NewRepo, which needs *DBConn. It traces down to NewConfig, which needs nothing. The container calls them in reverse order: config, connection, repo, service. Each result is cached in the container so the same provider runs only once per scope.

Go convention dictates that you accept interfaces and return structs. The container follows this naturally. Register providers that return concrete structs. Invoke functions that accept interfaces. The container handles the type assertion behind the scenes. Do not pass pointers to strings or integers unless you need mutation. Values are cheap to copy. Keep the graph clean. When a provider needs a request context, always place context.Context as the first parameter, conventionally named ctx. The container will inject it automatically if you provide a context provider, or you can pass it explicitly during invocation.

Trust the graph. If the order feels wrong, your provider signatures are lying. Fix the contract, not the container.

Pitfalls and debugging

Reflection brings overhead and opacity. The container resolves types at runtime, so a typo in a provider signature does not fail at compile time. It fails when Invoke runs. You get a panic with dig: missing dependency for type "main".Repo in func literal. The error points to the missing type, but it does not tell you which provider failed to return it. You have to trace the graph yourself.

Circular dependencies break the container. If A needs B and B needs A, the graph has a cycle. dig detects this during resolution and panics with dig: dependency cycle found. You must restructure the code, usually by extracting a shared interface or introducing a third component.

Error handling requires discipline. Provider functions can return errors. dig supports error-returning providers, but you must register them correctly. If a provider returns an error, the container stops resolution and returns the error to the caller. You must check the return value of Invoke. If you ignore it, the program continues with uninitialized dependencies. The compiler will not catch this. You have to write the check yourself.

// NewDBConn returns an error if the connection fails.
func NewDBConn(cfg *Config) (*DBConn, error) {
	if cfg.DSN == "" {
		return nil, fmt.Errorf("empty DSN")
	}
	return &DBConn{}, nil
}

func main() {
	c := dig.New()
	c.Provide(NewConfig)
	c.Provide(NewDBConn)

	// Invoke returns an error if any provider fails.
	// Always capture and handle the error before proceeding.
	err := c.Invoke(func(db *DBConn) {
		_ = db
	})
	if err != nil {
		fmt.Println("failed to wire dependencies:", err)
		return
	}
}

The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it makes failure paths visible. Do not swallow the error from Invoke. Treat it like a fatal initialization failure. Run gofmt on your wiring code. The container does not care about indentation, but your teammates will. Let the tool decide formatting so you can focus on the graph structure.

Scopes solve the singleton problem. The default container caches every provider result. That works for application-level dependencies like database connections. It breaks for per-request state like HTTP context or user sessions. dig supports child scopes. You create a parent container with long-lived providers. You create a child scope for each request. The child inherits the parent's providers but caches its own results separately. When the request finishes, you drop the child scope and reclaim the memory.

// Create a child scope for isolated request state.
scope := c.Scope()

// Register request-specific providers in the child.
scope.Provide(func() *RequestContext { return &RequestContext{} })

// Invoke within the scope.
scope.Invoke(func(rc *RequestContext) {
	_ = rc
})

// Drop the scope when the request completes.
scope.Close()

Goroutine leaks happen when a background task waits on a channel that never closes. The same principle applies to scopes. If you forget to close a scope, the cached values stay in memory. Always pair scope creation with cleanup. Use defer scope.Close() in request handlers.

The worst DI bug is the one that silently uses stale state. Close scopes. Check errors. Verify the graph.

When to use dig versus alternatives

Use manual wiring when your application has fewer than five dependencies and the initialization order is obvious. Write the main function explicitly. The compiler verifies every type, and the code reads like a checklist.

Use dig when you have a complex dependency graph with many cross-cutting concerns, and you want to swap implementations without rewriting call sites. The container handles the ordering and caching automatically.

Use code generation tools like wire when you want the flexibility of a container but need compile-time guarantees. The generator inspects your providers and outputs a static Go file that performs the same wiring without reflection.

Use a simple struct-based injector when you want zero external dependencies and full control over the initialization sequence. Define a single App struct, pass it around, and initialize fields in main.

Pick the tool that matches your graph complexity. Simple apps do not need containers. Complex apps need discipline.

Where to go next