The problem with hidden dependencies
You write a function that fetches a user profile. It queries the database, formats the response, and returns it. The feature works perfectly on your machine. You write a test. The test hits your local PostgreSQL instance. It fails because the test database is empty. You add a setup script to seed data. The test passes. A week later, you need to verify the error path when the database is unreachable. You have to stop your database server, run the test, and restart it. Your test suite now depends on infrastructure state. The code is tightly coupled to the database driver, and testing it requires orchestrating external systems.
This is the dependency trap. Functions that reach out to databases, file systems, or HTTP endpoints pull those external systems into their execution path. When the dependency is hardcoded inside the function, you cannot swap it out. You cannot isolate the logic. You cannot run tests in parallel without fighting for shared resources. The solution is not to avoid external calls. The solution is to make those calls replaceable.
What dependency injection actually means
Dependency injection is a straightforward idea: pass what a function needs instead of letting it grab what it wants. The name sounds heavy because it comes from enterprise Java frameworks that required XML configuration files and reflection magic. Go strips all that away. In Go, dependency injection is just passing an interface as a parameter.
Think of a power tool. If the motor is hardwired into the plastic shell, you cannot replace a worn battery. You cannot test the trigger mechanism without plugging it into the wall. If the tool uses a standard battery slot, you can insert a fully charged pack for production, a dummy pack for a demonstration, or a diagnostic pack that logs every voltage draw. The tool does not care what sits in the slot. It only cares that the slot provides power.
Go makes this pattern frictionless because interfaces are implicit. You do not write implements Repository anywhere. You define the interface, you write a struct that matches the method signatures, and the compiler connects the dots. This implicit contract keeps your code focused on behavior rather than type declarations. You define the shape of the dependency, and any type that fits the shape will work.
The minimal pattern
Here is the simplest way to decouple a service from its data source. The service defines what it needs. The caller decides what to provide.
// Repository defines the data access contract.
// It stays small because the service only needs one operation.
type Repository interface {
Get(id string) (*User, error)
}
// UserService holds the dependency as a field.
// The field type is the interface, not a concrete struct.
type UserService struct {
repo Repository
}
// NewUserService creates the service and validates the dependency.
// Constructor injection ensures the service is never in an invalid state.
func NewUserService(repo Repository) *UserService {
return &UserService{repo: repo}
}
// GetUser delegates the data fetch to the injected repository.
// The service logic remains pure and testable.
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.Get(id)
}
The UserService struct contains a repo field typed as Repository. It does not know whether repo is a PostgreSQL client, a Redis wrapper, or an in-memory map. It only knows that calling repo.Get(id) will return a *User and an error. When you call NewUserService, you pass whatever concrete type satisfies the interface. The compiler verifies the match at compile time. If the concrete type is missing a method, the program refuses to build.
This separation gives you two distinct execution paths. The production path wires a real database client. The test path wires a mock or a fake. The service code stays identical in both cases.
Walking through the type system
Go's type system treats interfaces as behavioral contracts. A type satisfies an interface if it implements every method in the interface set. There is no registration step. There is no inheritance. The match is purely structural.
When you pass a concrete *PostgresRepo to NewUserService, the compiler checks the method signatures. *PostgresRepo must have a Get(id string) (*User, error) method. If it does, the assignment succeeds. The repo field inside UserService now holds an interface value. An interface value in Go is a pair: a pointer to type information and a pointer to the underlying data. The type information tells the runtime how to dispatch method calls. The data pointer points to your actual *PostgresRepo instance.
When GetUser calls s.repo.Get(id), the runtime uses the type information to jump to the correct implementation. This indirection costs a few nanoseconds per call. In almost every application, that cost is invisible. The tradeoff buys you testability, modularity, and the ability to swap implementations without touching the service code.
Convention aside: receiver names in Go are usually one or two letters matching the type. You will see (s *UserService) or (u *User), never (this *UserService) or (self *UserService). Keep it short. The language is designed for it.
Wiring it up in production
Real applications rarely have a single dependency. You usually chain several components together. A service might need a database repository, a cache client, and a logger. You wire them together in one place, typically in main.go or a dedicated setup package.
// WireProduction builds the real dependency graph.
// It creates concrete instances and passes them upward.
func WireProduction() *UserService {
db := connectToDatabase() // returns a configured database client
cache := connectToRedis() // returns a cache client with timeouts
repo := NewPostgresRepo(db, cache)
return NewUserService(repo)
}
The wiring function is where infrastructure lives. It handles connection strings, retries, and timeouts. The business logic never touches os.Getenv or net/http. This keeps your core packages portable. You can move UserService to a different project, and it will compile as long as you provide a Repository.
Convention aside: Go developers follow the rule "accept interfaces, return structs." Your constructor accepts the Repository interface, but it returns a concrete *UserService. This keeps the public API stable while allowing internal implementations to change without breaking callers.
Swapping implementations in tests
The real payoff appears when you write tests. You no longer need a running database. You create a lightweight struct that implements Repository and returns predetermined values.
// FakeRepo implements Repository for deterministic testing.
// It stores data in a map so tests can verify writes.
type FakeRepo struct {
users map[string]*User
}
// Get retrieves a user from the in-memory map.
// It returns ErrNotFound when the key does not exist.
func (f *FakeRepo) Get(id string) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
// NewFakeRepo initializes the fake with optional seed data.
// Tests can prepopulate it to simulate database state.
func NewFakeRepo(seed map[string]*User) *FakeRepo {
return &FakeRepo{users: seed}
}
In your test file, you instantiate the fake, pass it to the constructor, and verify the service behavior. The test runs in milliseconds. It does not depend on network latency or database locks. You can run fifty tests in parallel without resource contention.
func TestUserService_GetUser(t *testing.T) {
seed := map[string]*User{"1": {ID: "1", Name: "Alice"}}
repo := NewFakeRepo(seed)
svc := NewUserService(repo)
user, err := svc.GetUser("1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
}
The test focuses entirely on the service logic. It verifies that the service correctly delegates to the repository and returns the result. If you need to test error handling, you create a second fake that always returns an error. You swap implementations by changing one line in the test setup. The service code never changes.
When things go wrong
The pattern breaks when you ignore the contract or overuse it. The most common mistake is interface explosion. You define a separate interface for every single struct in your codebase. You end up with UserServiceInterface, RepositoryInterface, and CacheInterface scattered across files. This adds indirection without value. Define an interface only when you actually need to swap implementations. If a struct is only used in one place, pass the concrete type.
Another trap is passing nil dependencies. If you forget to wire a dependency, the service will panic when it tries to call a method on a nil interface. The compiler cannot catch this at build time. You must validate dependencies in the constructor.
func NewUserService(repo Repository) *UserService {
if repo == nil {
panic("UserService: repo cannot be nil")
}
return &UserService{repo: repo}
}
Runtime panics from nil dereferences are easy to spot during testing. Silent failures are worse. If your mock returns nil, nil instead of nil, ErrNotFound, your service might proceed with an empty user object and corrupt downstream state. Always assert that your mocks return the exact error values your production code expects.
The compiler will also stop you if you try to pass the wrong type. If you accidentally pass a *sql.DB directly to NewUserService, the build fails with cannot use db (type *sql.DB) as Repository value in argument: *sql.DB does not implement Repository (missing Get method). This error is a feature. It forces you to write an adapter or fix the interface definition before the code ships.
Convention aside: error handling in Go is verbose by design. You will see if err != nil { return err } repeatedly. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors. Do not wrap them unless you add context. Let the caller decide how to handle failures.
Choosing your injection style
Go gives you flexibility in how you hand dependencies to your code. Pick the approach that matches the lifecycle and requirements of the dependency.
Use constructor injection when the dependency is required for the object to function. The service cannot operate without it, so the constructor enforces the requirement upfront.
Use method injection when the dependency is optional or varies per call. Pass the dependency as a parameter to the specific function that needs it, keeping the struct fields clean.
Use a wiring function or setup package when your application has more than five interconnected services. Centralizing the graph prevents circular imports and makes the startup sequence explicit.
Use direct imports for standard library packages or third-party libraries you do not plan to mock. Adding an interface around fmt or encoding/json creates unnecessary abstraction layers.
Dependency injection is not a framework. It is a boundary. Draw the boundary around your business logic, pass the external world through the door, and keep the internals clean.