AlgoMaster Logo

Dependency Injection in Go

High Priority17 min readUpdated June 6, 2026

Dependency injection is the practice of handing a piece of code the things it needs from the outside instead of letting it construct them itself. In Go this rarely involves a framework. A constructor takes interfaces as parameters, main() wires the real implementations together, and tests pass fakes in. This chapter walks through the idiomatic pattern, the interface conventions that make it click, where the wiring lives, and when (if ever) to use wire or fx.

The Problem: Hardcoded Dependencies

The fastest way to see why DI matters is to look at code that doesn't use it. Consider an OrderService that places customer orders. A first draft might look like this.

The code runs, but every collaborator is welded into Place. The database, payment provider, and notifier are all hardcoded names and addresses. You can't write a unit test for this without standing up a real Postgres, a real Stripe sandbox, and a real SMTP server. You can't swap Stripe for PayPal without editing Place. And OrderService knows about all three at once, so a change in any one of them lands in the same file.

The fix is to invert the relationship. Instead of Place reaching out to grab its dependencies, the caller hands them in. That's dependency injection.

Hardcoded dependencies don't show up in benchmarks; they show up in CI time, on-call paging, and refactor regret. The cost is paid every time you try to test or change the code.

Constructor Injection

The idiomatic Go pattern is constructor injection: write a New function that takes the dependencies as arguments and stores them on a struct. Callers can pass real implementations in production and fakes in tests.

Here's the same service redone with constructor injection. Note that the dependencies are declared as interfaces, not concrete types. We'll come back to why in the next section.

The struct holds its dependencies as unexported fields. The constructor accepts them as parameters. The method calls them through the interface, never reaching for a global or constructing anything new. This is the whole pattern.

Three things make this work well in Go. First, the fields are unexported, so callers can't reach in and swap a dependency after construction. Anything that needs to change goes through New. Second, the constructor returns a pointer to the concrete struct, not an interface, which we'll cover shortly. Third, the method bodies don't know or care whether they're talking to Postgres or a fake in-memory map. They just see the interface.

Constructor injection adds a few lines of boilerplate per service. The payoff is one-line test setup and trivial swaps in production. For services with three or more collaborators, the trade is clearly worth it.

Accept Interfaces, Return Structs

One of the most quoted Go proverbs is "accept interfaces, return structs". It's a shorthand for two decisions that together make Go code easy to test and easy to refactor.

Accept interfaces means the parameters of your functions and constructors should be the narrowest interface that describes what you need. If OrderService only ever calls Save, the parameter type should be an interface with one Save method, not a concrete *PostgresOrderRepo that has fifteen other methods.

Return structs means the return value of your constructors should be the concrete type, not an interface. NewOrderService returns *OrderService, not some OrderPlacer interface. The caller can then decide what interface, if any, to view it through.

Here's why the two halves matter. Returning a struct lets callers use any method on the value, including ones added later, without changing the constructor's signature. Accepting interfaces lets callers pass in anything that satisfies the method set, including test doubles. The two together mean producers stay flexible and consumers stay decoupled.

NewCartCalculator returns *CartCalculator. PrintReceipt accepts the narrow TaxApplier interface. The interface is defined in the consumer's file, not the producer's. That's the second half of the convention.

Define Interfaces at the Consumer

In many other languages, interfaces are declared next to the implementation: the same file that defines PostgresOrderRepo also defines IOrderRepo. Go pushes the convention the other way. Interfaces should live next to the code that uses them, not next to the code that satisfies them.

The reason is satisfaction. Go interfaces are satisfied implicitly: any type with the right methods counts, no implements keyword needed. That means a consumer can declare exactly the interface it needs without coordinating with the implementations. Producers don't need to know which interfaces will use them, and they don't need to import the consumer's package.

Here's a layout that shows the convention. The payments package exposes a real StripeGateway struct. The orders package declares its own narrow PaymentGateway interface that says "I need something with a Charge method", and StripeGateway satisfies it without payments ever importing orders.

The orders package's PaymentGateway has one method. StripeGateway has two. Go is happy: as long as the method set of the concrete type is a superset of the interface's, satisfaction holds.

This convention has three practical effects. Each consumer's interface lists only the methods that consumer actually calls, which keeps test doubles small. Two consumers of StripeGateway can declare different interfaces over it, naming them according to their own roles ("PaymentGateway" for orders, "Refunder" for the support service). And the producer package stays free of imports it doesn't need.

Defining interfaces at the consumer can feel duplicative ("but the methods are listed twice"). The reward is tests that mock only the methods under test and refactors that don't ripple through unrelated packages.

The Composition Root in main()

If the constructors only accept interfaces, something has to build the real implementations and wire them together. That something is main(), often called the composition root. It's the one place in the program that knows about every concrete type at once.

A typical main() for the order service might look like this. We'll reuse the OrderService and interfaces from the Constructor Injection section, and add three concrete implementations plus the wiring. Lower-level services have no dependencies; each higher-level service depends on the ones below it.

Every concrete type used in production appears in main() exactly once. If you swap Stripe for PayPal, you change one line. The rest of the program doesn't notice.

Here's what the dependency graph looks like.

The arrows point from main down to the leaves it constructs, and then sideways to show which leaves the service uses. The leaves at the bottom have no dependencies; the service in the middle depends on all three; main depends on everything because it builds the whole graph.

One rule for keeping the composition root tidy: it stays in main() (or in a small app package called from main). It does not creep into your library packages. If orders reaches for PostgresOrderRepo directly, the inversion breaks and you're back to hardcoded dependencies in disguise.

Functional Options for Optional Dependencies

Constructor injection works well when every dependency is required. When a service has optional knobs, like a custom logger, a timeout, or a feature flag, stuffing them all into the constructor signature gets ugly fast. The functional options pattern keeps the constructor readable while leaving room for extension.

The idea: the constructor takes the required dependencies plus a variadic list of "option" functions. Each option is a function that modifies the struct. Callers compose them at the call site.

The constructor is short. The defaults are visible. New options can be added later without changing existing call sites. This is the pattern the standard library uses for http.Server setup, and most of the popular Go libraries follow it for the same reasons.

Use functional options for genuinely optional configuration. Don't use them for required collaborators: making OrderRepo an option would let callers forget it entirely and discover the missing dependency only at runtime. Required dependencies belong in the regular parameter list, where the compiler enforces them.

Functional options add a small amount of code per option (one function literal at the call site, one wrapper function in the package). The overhead is invisible at runtime; each option is just a function call against the struct during construction.

Test Doubles via Interfaces

The first test of the chapter is the one that justifies the whole pattern. Once dependencies are interfaces, swapping a fake in for a test is trivial. No mocking framework needed in most cases. A hand-rolled fake satisfies the interface and records what was called.

Here's a test for OrderService.Place using simple fakes. The fakes are structs with the right methods. They keep a record of what happened so the test can assert on it.

In a real project, the assertions use t.Errorf and the function is func TestPlace(t *testing.T) in orders_test.go, run by go test. The structure is otherwise identical.

The fakes do three things at once. They satisfy the interface, so the constructor accepts them. They record arguments, so the test can verify what happened. And they let the test control behavior, like fakePayments.fail, so error paths are easy to exercise.

A common question is whether to use a mocking library like gomock or testify/mock instead of hand-rolled fakes. The Go community leans toward hand-rolled. The fakes are usually under thirty lines. They compile with go build. They show up in IDE navigation. And they don't break when method signatures change in ways the mocking codegen didn't anticipate. Use a mocking framework when you have dozens of dependencies with complex method sets, but default to hand-rolled.

Hand-rolled fakes cost a few lines per dependency. Mocking frameworks save those lines but add a code generator, a build step, and a dependency to your test suite. For services with three or four collaborators, the math favors hand-rolled.

Hand-Rolled DI vs Frameworks

For most Go projects, the wiring in main() is the whole DI story. A composition root with twenty lines of New... calls is easy to read, easy to debug, and has zero runtime cost beyond the constructor calls themselves. There are exactly two situations where a framework starts to pay off.

The first is graph size. When main() grows past a hundred lines of wiring, with chains of dependencies six levels deep, the order of construction starts to matter and humans start getting it wrong. The second is dynamic wiring: when the set of services depends on configuration loaded at startup, and you need to pick implementations at runtime rather than compile time.

Two frameworks dominate the Go space: google/wire and uber-go/fx. They take opposite approaches.

`google/wire` is a code generator. You write provider functions (the same New... constructors you'd write anyway) and an injector signature with a wire.Build directive. At build time, wire reads your code and generates the wiring as a regular Go function. The generated function looks exactly like a hand-rolled composition root, but you didn't have to write it.

After running wire you get a generated file with the actual implementation. Errors in the graph (a missing provider, a cyclic dependency, an interface with no binding) show up at code-generation time, before the program ever runs. Runtime cost is zero because the generated code is identical to what you'd hand-write.

`uber-go/fx` is a runtime framework. You register providers with a container, declare what your top-level service needs, and fx.New walks the graph to build everything. Lifecycle hooks let you register cleanup actions that run in reverse order at shutdown.

fx is well-suited to long-running services with rich lifecycles: HTTP servers that need graceful shutdown, background workers that need orderly startup, observability instrumentation that must be wired into every component. It costs you a runtime container and reflection-based wiring, both of which add startup time and obscure errors that would be caught by the compiler in a hand-rolled setup.

Here's the comparison most teams care about.

ApproachWhen wiring is decidedRuntime costError detectionBest for
Hand-rolledCompile timeZeroCompilerMost projects (< 100 lines of wiring)
google/wireCode-gen timeZeroCode generator + compilerLarge graphs that don't change at runtime
uber-go/fxRuntimeContainer overhead at startupRuntime errorsServices with rich lifecycle hooks

The default should be hand-rolled. Use wire when the wiring itself becomes a maintenance burden but the set of services is fixed. Use fx when you genuinely need a runtime container, usually because your dependency set is configured per-environment and you want lifecycle management on top.

A rough rule: if you can fit your main() wiring on one screen, you don't need a framework. If you can't, look at wire before reaching for fx. The generated code is regular Go you can step through in a debugger.

Pitfalls

A handful of anti-patterns show up often enough that they deserve their own callouts. Each one looks like dependency injection from a distance but breaks down on closer inspection.

God-struct dependencies. A common temptation is to bundle every collaborator into a single Dependencies struct and pass it everywhere. The constructor signature shrinks from six arguments to one. Tests still seem to work. But every consumer now has a parameter that exposes far more than it uses, the interface for a service no longer documents its actual needs, and adding a new dependency to one method grants that dependency to every other method on the type. The narrow-interface convention from earlier in the chapter exists specifically to avoid this. Keep the dependencies explicit on each constructor.

Package-level globals. Another shortcut is to declare a var DB *sql.DB at package scope and let any function in the package use it. The wiring step disappears, but so does testability: there's no way to substitute a fake without mutating global state, and parallel tests fight over the variable. Globals also make startup order brittle, because they're initialized in undefined order across packages. If you find yourself reaching for a package-level var, ask whether it could be a field on a struct instead.

`init()` side effects. Go's init() functions run automatically at program startup. They're convenient for registering things (HTTP handlers, codec implementations, database drivers), but anything that opens a connection, reads a file, or starts a goroutine inside init() makes the program impossible to test in isolation. The same code that runs go test./... will try to open every production connection unless something stops it. Keep init() free of side effects. Move setup into explicit functions called from main().

Interfaces with too many methods. "Accept interfaces" is good advice until the interface has fifteen methods, at which point every test double has to implement fifteen methods just to test one. Split big interfaces. The same StripeGateway struct can satisfy a Charger interface (one method) for the order service and a Refunder interface (one method) for the support service. The struct stays the same; the interfaces are tailored per consumer.

Returning interfaces from constructors. The "return structs" half of the proverb gets dropped surprisingly often. A constructor that returns OrderPlacer instead of *OrderService is harder to extend, because every new method has to go through the interface, and harder to compose, because callers can't use methods that aren't part of the interface. Return the concrete type and let each caller declare the interface it wants.

PitfallSymptomFix
God-struct depsOne struct everyone importsPass narrow interfaces per consumer
Package globalsvar db *sql.DB at package scopeMake it a struct field, inject through constructor
init() side effectsTests open real connectionsMove setup to explicit New... functions called from main
Wide interfacesTest fakes are 50 lines eachSplit into role-specific interfaces
Returning interfacesCallers can't reach new methodsReturn concrete struct, let callers declare interfaces

Each of these shortcuts saves a few lines today and costs an order of magnitude more during the next refactor. The constructor-injection pattern is verbose on purpose, because the verbosity is what keeps the graph visible.

Quiz

Dependency Injection Quiz

10 quizzes