Dependency injection patterns: Wire, manual, runtime DI
"Dependency injection is a thousand-dollar answer to a fifty-cent question, unless it's the right answer, in which case it's free"
Dependency injection is one of those topics where the literature is louder than the practice. Every senior engineer has opinions, most of which come from the Java-ecosystem wars of the 2000s (Spring vs Guice vs manual). For modern Go, Python, and TypeScript services the question is softer and more interesting: when does DI actually help, when does it get in the way, and what’s the right style for each language?
By the end of this chapter the reader can place any codebase somewhere on the DI spectrum, explain Google’s Wire and the wire.go / wire_gen.go split, articulate why Spring became both the standard and the cautionary tale of runtime DI, and know when to just write a constructor and stop overthinking it. The chapter is short on dogma and long on pragmatism — the goal is to recognize the pattern, not to worship it.
Outline:
- What DI actually is, and what problem it solves.
- The DI spectrum: manual → compile-time → runtime.
- Manual constructor injection — the default that usually wins.
- Wire — Google’s compile-time DI for Go.
- The
wire.go/wire_gen.godiscipline. - Runtime DI — Spring, Guice, Dagger, FastAPI’s
Depends. - When DI helps and when it hurts.
- Testing, mocking, and DI’s real value.
- Anti-patterns: the god container, the Service Locator, magic autowiring.
- The mental model.
103.1 What DI actually is
Dependency injection is a single idea wrapped in a lot of jargon. The idea: a component does not construct its own dependencies. It receives them from outside. Instead of:
func NewService() *Service {
db := postgres.Connect("postgres://...")
cache := redis.Connect("redis://...")
return &Service{db: db, cache: cache}
}
You write:
func NewService(db *sql.DB, cache *redis.Client) *Service {
return &Service{db: db, cache: cache}
}
That is dependency injection. Everything else — Wire, Spring, Dagger, @Inject annotations, XML config, container frameworks — is plumbing around this one refactor. The “injection” is just the act of passing the dependency in as a constructor parameter instead of creating it inside.
The value is separation of concerns. NewService no longer knows where the database lives or which Redis cluster to talk to. Those decisions happen at the call site. In a test, a caller can pass a fake database. In production, the caller passes the real one. In a benchmark, the caller passes an in-memory stub. The code under test does not change; only the wiring changes.
The complication is that once every component takes its dependencies as constructor arguments, something has to call all the constructors in the right order, with the right arguments, at startup. The component graph can have dozens of nodes: database → repository → service → handler → router → server. Wiring them by hand at main() is tedious in small apps and unmanageable in large ones. DI frameworks exist to automate this one step.
The key insight that most DI discussions miss: DI the pattern is universally valuable; DI the framework is contextually valuable. The pattern is worth adopting everywhere; constructors that receive their dependencies are better than constructors that create them. The framework is worth adopting only when the wiring step is actually painful, which happens later than most engineers think.
103.2 The DI spectrum
DI implementations live on a spectrum from “do nothing special” to “let a reflective runtime autowire everything.” The three main stops:
graph LR
A[Manual<br/>constructor injection] -->|more power| B[Compile-time DI<br/>Wire / Dagger]
B -->|more power| C[Runtime DI<br/>Spring / Guice]
A -->|more explicit| X1[Go / Rust<br/>preference]
B -->|middle ground| X2[Go Wire<br/>Android Dagger]
C -->|less explicit| X3[Java Spring<br/>Python FastAPI]
style B fill:var(--fig-accent-soft),stroke:var(--fig-accent)
The DI spectrum trades boilerplate for explicitness — manual wiring is the most visible and safest for small services; compile-time DI generates the wiring code; runtime DI reflects over the graph at startup and loses compile-time safety.
Manual constructor injection. You write the constructors. You call them from main(). No framework involved. The compiler is your only safety net. This is the default for Go, for small Python services, for Rust, and (in 2025) for a growing share of modern Java and Kotlin codebases using simpler frameworks like Koin or plain factories.
Compile-time DI. A code generator reads your components, figures out the dependency graph, and generates the wiring code for you. The output is readable Go or Java or Kotlin that you can grep and debug. Google’s Wire for Go, Dagger 2 for Android/Java, and Kotlin Inject are the canonical examples. You still have constructors; you just let a tool write the 200-line main() that calls them.
Runtime DI. A framework reflects over your classes at startup, builds the dependency graph at runtime, and instantiates the objects in the right order. Spring is the archetype; Guice (also Google, pre-Wire) is the smaller alternative; FastAPI’s Depends is the Python web equivalent. You write less boilerplate but pay for it with reflection magic, startup-time errors instead of compile-time errors, and a framework surface area that becomes part of your mental model forever.
The spectrum is a tradeoff between boilerplate and explicitness. Manual is the most explicit and the most boilerplatey. Runtime DI is the least boilerplatey and the least explicit. Compile-time DI is the middle ground — you generate the explicit code instead of writing it, so you get explicitness at read time but not at write time.
The language also constrains the choice. Go has no runtime reflection story that makes Spring-style DI ergonomic, so compile-time DI or manual are the only sane options. Python’s flexibility makes runtime DI cheap, and frameworks like FastAPI lean into it. Java has every option, and the choice depends on team taste and existing infrastructure.
103.3 Manual constructor injection
Manual DI is underrated. It looks like this:
func main() {
cfg := config.MustLoad()
logger := logging.New(cfg.Logging)
metrics := metrics.New(cfg.Metrics)
db, err := postgres.Open(cfg.Database.DSN)
if err != nil {
logger.Fatal("open db", "err", err)
}
defer db.Close()
cache := redis.New(cfg.Redis)
userRepo := repository.NewUserRepository(db, logger)
billingRepo := repository.NewBillingRepository(db, logger)
userSvc := service.NewUserService(userRepo, cache, logger, metrics)
billingSvc := service.NewBillingService(billingRepo, userSvc, logger)
handler := http.NewHandler(userSvc, billingSvc, logger)
server := http.NewServer(cfg.HTTP, handler, logger)
logger.Info("starting server", "addr", cfg.HTTP.Addr)
if err := server.ListenAndServe(); err != nil {
logger.Fatal("server", "err", err)
}
}
This is 20 lines of wiring. For a small or even medium service, it is perfectly fine. The compiler catches every mismatch. The dependency graph is visible as a top-to-bottom walk of main(). A new engineer reads main.go once and understands the entire startup sequence. There is no hidden magic.
The cost is that main() grows with the service. At 20 components it is 40 lines; at 50 components it is 100+; at 200 components it becomes unmanageable. Refactoring means updating every call site. Adding a new dependency to a deeply nested component means threading it down through every intermediate constructor. This is where compile-time DI earns its keep.
The honest heuristic: if main() is under ~100 lines and changes are infrequent, manual DI is the right choice. The mental overhead of Wire or Dagger is not worth the small saving in boilerplate. Most services are smaller than engineers think. Don’t bring in a framework until the manual version is actually painful.
103.4 Wire — Google’s compile-time DI for Go
Wire is a code generator. You declare “providers” (constructors) and “injectors” (entry points), and Wire generates the wiring code at go generate time. The output is pure Go that you check into the repo. At runtime there is no framework, no reflection, no magic — just ordinary function calls.
The setup. Providers are just your regular constructors:
// pkg/db/db.go
func NewDB(cfg Config) (*sql.DB, error) { /* ... */ }
// pkg/repo/user.go
func NewUserRepository(db *sql.DB) *UserRepository { /* ... */ }
// pkg/service/user.go
func NewUserService(r *UserRepository, cache *redis.Client) *UserService { /* ... */ }
The injector is a function you declare but do not write the body for:
// wire.go
//go:build wireinject
package main
import (
"github.com/google/wire"
"example.com/pkg/config"
"example.com/pkg/db"
"example.com/pkg/repo"
"example.com/pkg/service"
)
func InitializeService(cfg config.Config) (*service.UserService, func(), error) {
wire.Build(
db.NewDB,
redis.New,
repo.NewUserRepository,
service.NewUserService,
)
return nil, nil, nil // unreachable; Wire replaces this
}
wire.Build takes a list of providers. Wire walks the dependency graph from the return type backward — *service.UserService needs a *UserRepository and a *redis.Client; *UserRepository needs a *sql.DB; *sql.DB comes from db.NewDB(config.Config). Wire sorts this topologically and generates the glue.
You run wire ./... (or go generate) and it writes wire_gen.go:
// wire_gen.go — generated by Wire, do not edit
//go:build !wireinject
package main
func InitializeService(cfg config.Config) (*service.UserService, func(), error) {
sqlDB, cleanup1, err := db.NewDB(cfg.DB)
if err != nil {
return nil, nil, err
}
redisClient := redis.New(cfg.Redis)
userRepo := repo.NewUserRepository(sqlDB)
userSvc := service.NewUserService(userRepo, redisClient)
return userSvc, func() {
cleanup1()
}, nil
}
This is the code that actually runs. Your main() calls InitializeService(cfg) and gets back a fully constructed object. The cleanup function is Wire’s way of handling things like db.Close().
The discipline: wire.go is the specification, wire_gen.go is the generated output. Both are checked in. CI runs wire and fails the build if wire_gen.go is out of sync (go generate && git diff --exit-code). Engineers edit wire.go to add providers and run wire to regenerate. They never hand-edit wire_gen.go; it will be overwritten.
103.5 The wire.go / wire_gen.go pattern
The dual-file pattern deserves explicit attention because it is the part Wire newcomers trip over. Both files are in the same package. Both are called wire.go and wire_gen.go. The build tag discriminates:
wire.gohas//go:build wireinject— only compiled when thewireinjectbuild tag is set, which is only duringwiretool invocation.wire_gen.gohas//go:build !wireinject— compiled during every normalgo build.
The effect is that at normal build time, only the generated file exists. The hand-written wire.go is invisible. At wire invocation time, only the hand-written file exists, and Wire reads it to figure out what to generate. This avoids conflicts from having InitializeService defined in both files.
The payoff in a large service is real. When a new dependency is added, you edit one line in wire.go (add the provider), run wire, and wire_gen.go updates. You never thread the dependency through intermediate constructors. When a component is removed, you delete one line and re-run. The dependency graph is declaratively specified, and the wiring is mechanically derived.
The cost is that you now have a code generator in your build pipeline. New engineers have to learn what wire.go is and why it has a strange build tag. CI has to run the generator and check the output. The generated file is sometimes surprising (Wire is strict about types; identical interfaces don’t match; you sometimes need wire.Bind to tell it “this concrete type satisfies this interface”). These frictions are small but real.
The honest evaluation. Wire is worth it when main() has more than ~50 providers, the dependency graph is changing often, and the team is willing to invest a day learning the tool. It is not worth it for a small service; manual wins. In Google’s own Go codebases, the ratio is probably 70% Wire / 30% manual, reflecting that most services eventually grow past the manual threshold.
103.6 Runtime DI — Spring, Guice, Dagger, FastAPI’s Depends
Runtime DI works by reflection. The framework reads your classes, looks for annotations like @Inject or @Autowired, builds the dependency graph at startup, and instantiates everything in the right order. The canonical example is Spring.
Spring’s model: you annotate a class with @Component, @Service, or @Repository and it becomes a “bean.” Other beans that need it declare a constructor with @Autowired (or, since Spring 4.3, just a single constructor, implicitly autowired). At startup, Spring scans the classpath for annotated classes, builds the dependency graph, and calls the constructors. The application context holds all the singletons.
@Service
public class UserService {
private final UserRepository userRepo;
private final CacheClient cache;
public UserService(UserRepository userRepo, CacheClient cache) {
this.userRepo = userRepo;
this.cache = cache;
}
}
At startup Spring looks at UserService, sees the constructor needs UserRepository and CacheClient, finds beans of those types in the context, and wires them. You never call new UserService(...) yourself. Spring Boot makes this even more magical — it autoconfigures data sources, HTTP servers, and metrics based on what is on the classpath.
The good: almost no wiring boilerplate. Adding a component is adding an annotation. Testing is easy because you can replace a bean with a mock by annotation. Large Spring codebases can have thousands of beans without any manual wiring.
The bad: errors are runtime errors. A missing bean, a circular dependency, a name collision — all show up at startup, not compile time. Debugging Spring startup failures has its own folklore. The reflection machinery is slow; Spring Boot apps can take 10-30 seconds to start, which is brutal for a serverless or autoscaled workload (see Chapter 51’s cold-start discussion). The framework itself is enormous and has its own DSL of annotations and XML (legacy) and YAML config. A Spring application is a Spring application; you are not writing Java so much as writing Spring.
Guice is the lighter Google version: same annotations, similar model, less autoconfiguration magic. Dagger 2 is the Android version: despite being “runtime DI” in reputation, Dagger actually generates code at compile time, making it more like Wire than Spring. The terminology collapses here; Dagger is closer to Wire than to Guice.
FastAPI’s Depends is the Python web equivalent. A handler function declares dependencies as parameters annotated with Depends:
from fastapi import Depends, FastAPI
async def get_db():
async with db_pool.acquire() as conn:
yield conn
async def get_current_user(db = Depends(get_db), token: str = Header(...)):
return await db.fetch_one("SELECT * FROM users WHERE token = $1", token)
@app.get("/profile")
async def profile(user = Depends(get_current_user)):
return user
FastAPI builds the dependency graph from type hints and Depends() markers at request time, caches resolved dependencies within a request, and handles cleanup via generators. It is simpler than Spring but follows the same runtime-reflection pattern. For Python web services it is the default modern style.
103.7 When DI helps and when it hurts
DI helps when:
- The component graph is large and changing. A service with 100+ components benefits from mechanical wiring. A service with 10 does not.
- Testing needs to swap dependencies. DI makes this trivial; without it, tests have to either patch globals or construct elaborate integration setups.
- The same code runs in multiple configurations. A service that runs with real Postgres in prod, SQLite in tests, and an in-memory stub in benchmarks benefits enormously. DI is how those three wirings cost one flag each.
- The team has more than ~10 engineers. Conventions matter more than terseness. Enforcing “all dependencies are constructor parameters” scales better than ad-hoc globals.
DI hurts when:
- The service is small and stable. The framework overhead and cognitive cost exceeds the benefit.
- It becomes the architecture. Spring applications where every function is a bean, every state transition is an event, every configuration is a YAML annotation — the framework replaces the programming language.
- Runtime errors replace compile-time errors. A missing bean in Spring manifests as a stack trace at
main()startup. A missing provider in Wire manifests as a build error. The latter is always better. - The team treats DI as the solution to every problem. “How should X talk to Y?” answered with “just inject it” is not a design; it is a shrug.
The middle road: adopt constructor injection as a pattern (always), adopt a framework only when wiring becomes painful (sometimes). For Go, Wire at the point where manual main() starts hurting. For Python, Depends in FastAPI because it is already in the framework. For Java, Spring if you must but strongly consider Micronaut or Quarkus, which use compile-time DI and have fraction-of-a-second startup times.
103.8 Testing, mocking, and DI’s real value
The real argument for DI shows up in tests. Without DI, testing UserService that creates its own database connection means either: (a) running a real database in the test environment (slow, flaky), (b) patching global state (brittle), or (c) using some monkey-patching trick in Python or a mocking library in Java (magical, breaks refactoring). With DI, you pass a fake.
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
}
type fakeRepo struct {
users map[string]*User
}
func (f *fakeRepo) GetByID(ctx context.Context, id string) (*User, error) {
if u, ok := f.users[id]; ok {
return u, nil
}
return nil, ErrNotFound
}
func TestUserService_Get(t *testing.T) {
repo := &fakeRepo{users: map[string]*User{"1": {ID: "1", Name: "Alice"}}}
svc := service.NewUserService(repo, nil, testLogger)
got, err := svc.Get(context.Background(), "1")
require.NoError(t, err)
assert.Equal(t, "Alice", got.Name)
}
The test constructs UserService with a fake repo. No database involved. No patching. No mocking framework. The fake is 10 lines of code, the test is clear, and the same pattern works for every component.
The secondary benefit: DI forces you to define interfaces where you would otherwise have concrete types. UserService now takes a UserRepository interface, not a concrete *PostgresUserRepository. This is both the cost (writing the interface) and the benefit (the interface becomes a documented contract between layers). In practice this tends to make architecture boundaries visible in the type system, which is worth more than the DI framework itself.
103.9 Anti-patterns
Three failure modes that show up constantly.
The god container. The DI framework holds every object in the application. New engineers add components to the container because that’s how the rest of the codebase works, even when they don’t need injection. The container becomes a giant pool of global state accessed via container.get(X). This is DI regressing into the Service Locator anti-pattern — the thing DI was supposed to replace.
Magic autowiring. Spring’s @Autowired on fields (instead of constructors). The test can’t construct the object directly; it has to go through the framework. Refactoring breaks silently because field annotations are invisible to the type system. The rule: constructor injection only, no field injection, no setter injection.
Injecting everything. Every primitive, every string, every integer becomes a named bean. @Value("${app.max-retries}") int maxRetries. The dependency graph has 3000 nodes, most of which are trivially known at compile time. The DI framework is doing work that should be a constant. Keep configuration in a config struct and pass the struct.
Circular dependencies. A needs B needs A. Runtime DI frameworks have elaborate workarounds (lazy injection, setter injection). Compile-time DI just fails the build, which is usually the right answer — the design is wrong. Break the cycle by extracting the shared concept into a third component.
The DI-as-architecture problem. The entire application becomes about the framework. “Where does X live?” “In a bean.” “How does X talk to Y?” “Inject Y.” “What is the startup sequence?” “Whatever Spring decides.” The framework has eaten the architecture. The cure is to draw a mental box around the DI framework and keep it inside the box — it wires things together at startup and then disappears. If the framework is showing up in your business logic, something has gone wrong.
103.10 The mental model
Eight points to take into Chapter 104:
- DI the pattern is always good. Constructor arguments beat constructor-internal construction.
- DI the framework is sometimes good. Adopt it only when manual wiring is actually painful.
- Manual wiring wins for small services. Under ~50 components,
main()is fine and readable. - Wire is compile-time DI for Go.
wire.gospecifies,wire_gen.gois generated, both are checked in. - Spring/Guice are runtime DI. Powerful, magical, slow to start, errors at startup instead of compile time.
- FastAPI’s
Dependsis the Python equivalent. Built into the framework; use it. - DI’s real value is testability. Fakes and stubs become trivial to swap in.
- Anti-patterns are worse than no DI. God containers, field injection, magic autowiring, and DI-as-architecture.
Chapter 104 steps up one level to the contracts between services — how APIs are designed, specified, and versioned.
Read it yourself
- Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern (2004). The original article that named the pattern. Still the clearest explanation.
- Google’s Wire README and “best practices” doc on
github.com/google/wire. Short and concrete. - Spring Framework Reference, “Core Technologies” → “The IoC Container.” The canonical runtime-DI explanation, with all the annotations.
- FastAPI documentation, “Dependencies” section at fastapi.tiangolo.com. Walks through
Dependswith testable examples. - The Dagger 2 user guide (dagger.dev). Covers annotation processing and why it generates code instead of using reflection.
- Clean Architecture (Robert Martin), chapters on the Dependency Rule. Grounds DI in a broader architectural argument.
Practice
- Refactor a function that constructs its own database connection to take the connection as a parameter. What did you gain? What did you lose?
- Write a
wire.goand runwireto produce awire_gen.gofor a trivial service (config → DB → repo → service). Check in both files. - A team has a
main.gothat is 400 lines of manual wiring. Should they adopt Wire? What questions would you ask before deciding? - Why does Wire’s build-tag split exist? What goes wrong if you don’t have
//go:build wireinjectinwire.go? - Write a 10-line fake for a repository interface in the language of your choice. Use it in a unit test.
- Spring applications can take 20 seconds to start. Why is that a problem for autoscaled serverless workloads? (See Chapter 51.)
- Stretch: Pick an open-source Java Spring service and rewrite its DI to use Micronaut (compile-time DI). Compare startup time and memory usage. Report what broke.