How to Use Constructor Injection in Go

Go requires manual constructor injection by passing dependencies as arguments to a custom constructor function.

The missing dependency problem

You write a Go package that needs to talk to a database. In your previous language, you slap an annotation on the struct, run a dependency injection framework, and the client appears. In Go, the compiler stares back at you with an empty struct and a missing field. There is no hidden magic. If a struct needs something to function, you have to hand it that thing explicitly.

Constructor injection in plain words

Constructor injection in Go is just a regular function that accepts dependencies as parameters and returns a fully initialized struct. The community calls these functions constructors by convention, even though Go has no constructor keyword. You define a function, usually named NewTypeName, pass in the required pieces, wire them into the struct fields, and return a pointer to the result. The pattern keeps dependencies visible, testable, and easy to trace. Public names start with a capital letter, so NewService is exported and callable from other packages. Private helper functions stay lowercase. This capitalization rule replaces access modifiers entirely.

Minimal example

Here is the simplest form. A service needs an HTTP client to make requests. The constructor takes the client, stores it, and hands back the service.

package main

import "net/http"

// Service handles outbound API calls.
type Service struct {
    client *http.Client
}

// NewService creates a ready-to-use Service with the provided HTTP client.
func NewService(client *http.Client) *Service {
    // Return a pointer so callers share the same underlying struct.
    // Avoids copying the entire struct on every assignment.
    return &Service{
        client: client,
    }
}

func main() {
    // Instantiate the dependency first.
    // Heap allocation happens here with default timeouts.
    client := &http.Client{}
    // Pass it into the constructor.
    // The compiler verifies the type matches exactly.
    svc := NewService(client)
}

Walk through what happens

When the program runs, &http.Client{} allocates memory on the heap and initializes the default transport and timeout values. The NewService call receives that pointer, wraps it inside a new Service struct, and returns the address of that struct. The caller now holds a reference to both objects. If you pass the same client to multiple services, they all share the underlying connection pool. The compiler enforces that every field mentioned in the struct literal gets a value. If you forget to assign client, the compiler rejects the code with a missing-field error. This strictness prevents half-baked objects from leaking into your application. The gofmt tool will automatically align the struct fields and the function signature, so you never argue about indentation. Let the tool decide.

Realistic example

Real applications rarely take concrete types directly. You want to swap implementations for testing or configuration changes. The standard Go approach is to accept interfaces and return structs. Here is a more realistic setup with a database repository and a logger.

package main

import (
    "context"
    "fmt"
)

// Logger defines the contract for writing application logs.
type Logger interface {
    Info(msg string)
}

// Repository defines the contract for database operations.
type Repository interface {
    Fetch(ctx context.Context, id int) (string, error)
}

// App holds the runtime dependencies.
type App struct {
    repo   Repository
    logger Logger
}

// NewApp wires dependencies into a configured application instance.
func NewApp(repo Repository, logger Logger) *App {
    // Validate required dependencies before returning.
    // Fails fast instead of panicking later during execution.
    if repo == nil {
        panic("repository cannot be nil")
    }
    return &App{
        repo:   repo,
        logger: logger,
    }
}

// Run demonstrates using the injected dependencies.
func (a *App) Run(ctx context.Context) {
    // Context flows through every long-lived call site.
    // Carries cancellation signals and deadlines automatically.
    a.logger.Info("starting application")
    _ = ctx // used in real Fetch call
}

The NewApp function takes two interfaces. Any type that implements those methods will satisfy the signature. The App struct itself is concrete. Callers build the interfaces elsewhere and pass them in. The receiver name a follows the community convention of using one or two letters that match the type name. The context.Context parameter in Run shows the standard pattern: context always travels as the first argument, conventionally named ctx, and carries cancellation signals and deadlines through the call chain. The underscore _ discards the unused variable intentionally. It tells the compiler you considered the value and chose to drop it. Use it sparingly with errors, but freely for placeholders during development.

Pitfalls and compiler feedback

Constructor injection fails when you try to wire circular dependencies. If ServiceA needs ServiceB and ServiceB needs ServiceA, neither constructor can finish before the other starts. The program deadlocks during initialization or panics with a nil pointer dereference. Break the cycle by extracting the shared logic into a third package, or by using a setter method to inject the dependency after both structs exist.

Another common trap is hiding required configuration behind default values. If your constructor silently creates a http.Client when the caller passes nil, you lose the ability to control timeouts or TLS settings. Explicit is better than implicit. If a field is required, make it a constructor parameter. If it is optional, use a functional options pattern or a configuration struct.

The compiler catches many wiring mistakes early. Pass a concrete type that does not implement the expected interface and you get cannot use client (variable of type *http.Client) as Repository value in argument. Forget to assign a struct field in the literal and you receive missing field in struct literal. These errors force you to fix the wiring before the program ever runs. The verbose if err != nil pattern you will see everywhere is part of the same philosophy. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow a failure.

Testing becomes straightforward when dependencies are injected. You do not need to mock a global state or rewrite package variables. You simply construct a fake implementation of the interface and pass it into NewApp. The test runner gets a clean, isolated instance every time. When constructors grow too large with optional fields, the functional options pattern takes over. You define an Option type as a function that mutates a configuration struct, collect them in a variadic parameter, and apply them inside the constructor. This keeps the public API clean while preserving type safety.

Decision matrix

Use constructor injection when a struct requires specific dependencies to function correctly and those dependencies are known at initialization time. Use setter injection when dependencies are optional or when you need to reconfigure a running object without recreating it. Use the functional options pattern when a struct has many optional configuration fields and you want to keep the constructor signature clean. Use global variables or package-level state only for truly immutable constants or when you are writing a simple script that does not need testing.

Dependencies should be visible. If you cannot see what a struct needs by reading its constructor signature, your design is hiding too much.

Where to go next