DI Patterns in Go

Accept Interfaces, Return Structs

Use interfaces for function parameters to enable flexibility and testing, but return concrete structs to preserve extensibility.

The tangled dependency problem

You are building a service that processes user uploads. You write a struct that talks directly to a cloud storage provider. It works. Then your manager says the legal team wants to switch to a local disk storage for users in certain regions. You start copying files, renaming packages, and adding conditional checks everywhere. The code becomes a knot of tightly coupled imports and environment flags. You realize the problem is not the storage backend. The problem is that your business logic knows exactly what the storage backend looks like.

Go solves this with a simple rule that sounds like a mantra but actually changes how you structure programs: accept interfaces, return structs. It is the foundation of dependency injection in Go, and it keeps your code flexible without adding a framework.

What the rule actually means

An interface in Go is a contract. It lists the methods a type must implement. A struct is a concrete implementation. It holds data and provides the actual logic. The rule says your functions should ask for the contract, not the specific implementation. When you create something, you hand back the concrete thing, but you label it with the contract so the caller only sees what they need.

Think of a power outlet. The outlet is the interface. It defines voltage, frequency, and pin shape. Your laptop charger, your phone adapter, and your desk lamp are the structs. The wall socket does not care what device you plug in. It only cares that the device matches the contract. When you buy a new charger, you get a specific physical object. You do not buy a generic plug concept. You accept the outlet standard, you return the actual device.

In Go, this separation happens at compile time. The compiler checks that every struct you pass to a function satisfies the interface. If it does, the program runs. If it does not, the build fails. You get flexibility without runtime overhead. Go also uses implicit interface satisfaction. You do not write implements Storage anywhere. If a struct has the required methods, it satisfies the interface. This keeps interfaces small and focused on behavior rather than inheritance hierarchies.

Keep interfaces small. One or two methods is usually enough.

A minimal example

Here is the pattern in its simplest form.

// Storage defines the contract for saving data.
type Storage interface {
    Save(key string, data []byte) error
}

// S3Storage implements Storage for cloud buckets.
type S3Storage struct {
    bucket string
}

// NewS3Storage creates a configured S3 client.
func NewS3Storage(bucket string) Storage {
    // Return the interface type, not the concrete pointer.
    // This forces callers to stick to the agreed contract.
    return &S3Storage{bucket: bucket}
}

// SaveToCloud accepts the contract, not the concrete type.
func SaveToCloud(s Storage, key string, data []byte) error {
    // The caller only needs to know Save exists.
    // The compiler verifies s satisfies Storage at compile time.
    return s.Save(key, data)
}

Notice the return type of NewS3Storage. It returns Storage, not *S3Storage. The caller gets the interface. They can call Save. They cannot accidentally call a method that only exists on S3Storage but not on the contract. The function SaveToCloud accepts Storage. You can pass &S3Storage{}, a mock struct for tests, or a future DiskStorage without changing SaveToCloud.

Go interfaces are satisfied implicitly. You do not declare implementation relationships. If a struct has a Save(key string, data []byte) error method, it satisfies the interface. The compiler figures it out. This keeps interfaces decoupled from their implementations. You can define the interface in one package and implement it in another without circular imports.

Let the compiler catch missing methods. Do not hide coupling behind reflection.

How the compiler and runtime handle it

When you compile the program, the Go compiler builds a type table for every interface usage. It records the concrete type and a pointer to the method implementation. At runtime, calling s.Save() performs a single indirect function call. The overhead is negligible compared to network I/O or disk reads. The Go runtime optimizes these calls aggressively, and escape analysis often keeps small structs on the stack even when wrapped in interfaces.

If you pass a struct that misses a method, the compiler rejects it immediately. You will see an error like cannot use s3 (variable of type *S3Storage) as Storage value in argument: *S3Storage does not implement Storage (missing Save method). The message tells you exactly which method is missing. Fix the struct or adjust the interface. There is no guessing.

The implicit satisfaction also means interfaces can grow safely. If you add a Delete(key string) error method to Storage, every existing implementation breaks at compile time. That is a feature. It forces you to update all callers or create a new interface. Go prefers explicit breakage over silent runtime failures. You also get API stability. Returning an interface from a constructor means you can change the underlying struct fields or add internal methods without breaking callers. They only see the contract.

Wire dependencies in one place. Do not scatter constructors across packages.

Wiring it in a real application

Real programs need more than one dependency. You typically wire them together in a main package or a dedicated setup function. Here is how a small HTTP service looks when it follows the pattern.

// package main

import (
    "fmt"
    "net/http"
)

// UserStore defines how we fetch user data.
type UserStore interface {
    GetByID(id string) (string, error)
}

// PostgresStore talks to a real database.
type PostgresStore struct {
    dsn string
}

// GetByID satisfies UserStore.
func (p *PostgresStore) GetByID(id string) (string, error) {
    // Simulate DB query with a concrete implementation.
    // The handler never sees the database driver.
    return fmt.Sprintf("user_%s", id), nil
}

// NewPostgresStore returns the concrete type labeled as an interface.
func NewPostgresStore(dsn string) UserStore {
    // Returning UserStore hides PostgresStore from the caller.
    // This prevents accidental use of DB-specific methods.
    return &PostgresStore{dsn: dsn}
}

// Handler depends on the contract, not the database.
type Handler struct {
    store UserStore
}

// NewHandler wires the dependency.
func NewHandler(s UserStore) *Handler {
    // Store the interface in the struct field.
    // The handler remains testable without a real database.
    return &Handler{store: s}
}

// ServeHTTP handles the request.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Extract ID from URL (simplified for clarity).
    id := "123"
    name, err := h.store.GetByID(id)
    if err != nil {
        // Handle errors explicitly. Go prefers visible failure paths.
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    fmt.Fprint(w, name)
}

func main() {
    // Wire concrete implementations to interface-typed variables.
    db := NewPostgresStore("postgres://localhost/mydb")
    handler := NewHandler(db)

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

The Handler struct holds a UserStore interface. It does not know about PostgreSQL, SQLite, or an in-memory map. The main function is the only place that decides which concrete type to use. This makes testing trivial. You create a MockStore that implements UserStore, pass it to NewHandler, and verify the HTTP response without touching a database.

Go developers usually keep the wiring in main.go or a wire.go file. You do not need a dependency injection framework. The language's implicit interfaces and constructor functions handle it cleanly. If you find yourself writing a factory that returns interfaces but hides configuration, stop. Pass the configuration explicitly. Explicit dependencies are easier to trace than hidden ones. Go conventions also apply here. Functions that take a context should put it first: func (p *PostgresStore) GetByID(ctx context.Context, id string) (string, error). The receiver name should be short, like (p *PostgresStore), not (this *PostgresStore). Error handling stays explicit: if err != nil { return err }. The verbosity is intentional. It makes failure paths visible. Trust gofmt to handle indentation and spacing. Focus your energy on the interface boundaries.

Nil interfaces are not nil. Check pointers before wrapping them.

Common pitfalls and how to avoid them

The pattern is simple, but a few traps appear in production code.

Returning a concrete type instead of an interface breaks the contract. If NewPostgresStore returns *PostgresStore, the caller can call methods that are not part of UserStore. Later, when you swap to RedisStore, the caller code breaks because it relies on PostgreSQL-specific methods. Always return the interface from constructors. It forces the caller to stick to the agreed contract.

Accepting a concrete type in a function signature creates tight coupling. If you write func Process(s *PostgresStore), you can never test it without a database. Change the parameter to UserStore and the function becomes reusable. The compiler will complain with cannot use mock (variable of type *MockStore) as *PostgresStore value in argument if you try to pass a different type. That error is your friend.

Nil interfaces versus nil concrete types cause silent panics. An interface value holds two parts: a type pointer and a data pointer. If you assign a nil pointer to an interface, the interface itself is not nil. It holds the type information. Calling a method on it panics with runtime error: invalid memory address or nil pointer dereference. Check for nil pointers before assigning them to interfaces, or return a non-nil struct with a default state.

Go conventions matter here. Functions that take a context should put it first. The receiver name should be short. Error handling stays explicit. The verbosity is intentional. It makes failure paths visible. Trust gofmt to handle indentation and spacing. Focus your energy on the interface boundaries. Do not pass a *string. Strings are already cheap to pass by value. Use _ to discard values intentionally when you need to satisfy a multi-return signature but only care about one result.

Accept interfaces, return structs. Keep the boundary clear.

When to use interfaces and when to skip them

Interfaces are powerful, but they are not free. Every abstraction adds a layer of indirection. Use them deliberately.

Use an interface when you need to swap implementations for testing, feature flags, or environment differences. Use an interface when a package needs to depend on behavior without importing the concrete package, which prevents circular dependencies. Use a concrete struct when the implementation is unlikely to change and testing does not require a mock. Use a concrete struct when the performance overhead of an indirect call matters in a tight loop. Use plain sequential code when you do not need flexibility: the simplest thing that works is usually the right thing.

The Go standard library demonstrates this balance. io.Reader and io.Writer are interfaces because file systems, networks, and memory buffers all share the same contract. bytes.Buffer is returned as a struct because it is a specific, optimized implementation. You accept the reader, you return the buffer. The pattern scales from small utilities to large microservices. Stick to it and your codebase stays maintainable.

Where to go next