Why DI frameworks are the wrong answer in every language — and what to do instead.
A dependency injection framework is itself an unnecessary dependency
DI frameworks do one thing consistently across every language we've worked in: add an unnecessary dependency.
You pull in wire, dig, fx, Dagger, or whatever the current favourite is, and your project now has a hard dependency on a framework to tell it how its own components relate to each other. Information your code already contained. Information you just stopped writing down.
We've done this in Go. We've done this in Elixir. We've worked in Python codebases where FastAPI's Depends had quietly become the skeleton the entire application hung off. Different languages, different ecosystems, same outcome — a layer of indirection between you and your own architecture, maintained by people you've never met, solving a problem you gave yourself.
That's not a framework problem. That's a thinking problem.
Two languages where it goes wrong, one where it never started
The first time you encounter a DI framework it looks like infrastructure. Mature. Serious. The kind of thing production codebases use.
Someone discovers wire or dig and spends a week wiring it up. The wire_gen.go file appears. The build tag appears. The separate binary appears. Six months later it quietly disappears, and nobody puts that in the commit message.
The control case. No DI framework took hold here, because the runtime already does the job — Application.start/2 is your wiring, explicit and hierarchical, and supervision makes lifecycle a language concern rather than a container's. Nothing was left for a framework to sell you.
Depends starts as a convenience. Then it's load-bearing. Then it's structural. Then you can't test a handler without understanding the container, and you can't understand the container without reading docs for a framework you didn't choose.
The pattern is the same everywhere. The framework arrives, the explicit wiring disappears, and the dependency graph moves from your code into metadata — provider lists, build tags, decorator stacks — that you can no longer follow with your eyes alone.
We stopped doing this. Not because we're allergic to dependencies — we're not purists for its own sake. Because every time, without exception, the framework added complexity it claimed to remove.
What we actually do instead
The alternative isn't clever. That's the point.
In Go, every component takes its dependencies as arguments. No globals. No service locators. No context.Value smuggling. Constructors are just functions — they take what they need and return what they produce.
func NewUserService(
repo UserRepository,
mailer Mailer,
logger *slog.Logger,
) *UserService {
return &UserService{repo: repo, mailer: mailer, logger: logger}
}
Wiring lives in one place — a thin app package, called from a main.go that stays clean:
func New(cfg *config.Config) (*App, error) {
db, err := database.Connect(cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("connecting to database: %w", err)
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
mailer := notification.NewMailer(cfg.SMTPHost)
userRepo := repository.NewUserRepository(db)
userService := service.NewUserService(userRepo, mailer, logger)
userHandler := handler.NewUserHandler(userService, logger)
mux := http.NewServeMux()
userHandler.RegisterRoutes(mux)
return &App{
Server: &http.Server{Addr: cfg.Addr, Handler: mux},
DB: db,
}, nil
}
That's the entire dependency graph. Readable in one pass. No generated files. No external binary. A new engineer understands how this application is assembled before their first coffee goes cold.
In Elixir the idiom is different but the discipline is identical. Your supervision tree in Application.start/2 is explicit wiring — you declare what starts, in what order, with what arguments. The language made lifecycle a first-class concern rather than something a container retrofits.
Elixir is the useful case precisely because nothing went wrong there. No DI framework became standard, no ecosystem fight happened, nobody had to be talked out of anything — because the pattern was in the runtime from the start and there was no gap for a framework to fill. That is the whole argument in one language: where the platform provides the pattern, the dependency never gets a foothold. Go and Python show you the cost of the gap. Elixir shows you what it looks like when there isn't one.
Which is also the fairest thing that can be said about Java, since that is where this argument usually gets pointed. Spring earned its place. In the early 2000s a Java application genuinely needed something to manage object lifecycles and cross-cutting concerns, because the language and the platform of the day gave you almost nothing to do it with — the alternative was EJB, and Spring was a mercy. That was a real gap and a real answer to it.
What happened next is the interesting part. A fix for one language at one point in its life hardened into a general assumption: that a serious application has a container, and that wiring is a solved problem you buy rather than a design question you answer. Two decades later that assumption is still being applied in languages built long after the conditions that produced it. We moved to Go in large part to leave that model behind — not because those frameworks were bad, but because the gap they were filling was never ours to begin with.
Two languages. Different concurrency models, different runtime guarantees, different ecosystem cultures. Same answer: write down your dependency graph in plain code, in one place, where anyone can read it.
What explicit wiring actually buys you
The case for constructor injection is usually framed as testability — you can swap implementations. That's true, and it's the least interesting thing about it.
The deeper property is this: your dependency graph becomes a static, compiler-verified artifact. With dig or fx, a missing dependency surfaces at runtime startup. With explicit wiring, it doesn't compile. That's not a convenience difference. That's a correctness difference. One class of error has been eliminated, not deferred.
That argument only lands against the reflection-based tools, and it is worth being precise about which is which. dig and fx resolve the graph at runtime through reflection. wire and Dagger do not — they are code generators that resolve at build time and emit plain constructor calls, so they catch a missing dependency at compile time too. Against those, the correctness argument gives you nothing.
The case against them is different, and it is the one the Go card above describes. wire produces exactly the wiring we are advocating you write — it simply requires a generator, a build tag, a second binary in your toolchain and a generated file in your repository to produce code you could have typed by hand in the same afternoon. You accept a build-time dependency and a layer of indirection, and what you get back is a file you are told not to edit. When the output is the thing you would have written anyway, the tool is pure overhead.
Everything else follows from that. Auditability isn't a feature you get on top of explicit wiring — it's the same property viewed from a different angle. The graph is in the code, so the code is the document, and the document is always current because it has to be. You can't have a dependency that isn't declared and you can't declare one that doesn't compile. The architecture can't drift from its description.
Onboarding collapses as a consequence. The single most expensive thing in a growing codebase is the time a new engineer takes to build a working mental model. With a framework container, that model requires understanding the framework, the provider registration pattern, the resolution order, and whatever project-specific conventions have accumulated on top. With explicit wiring, it requires reading one file.
When something fails at initialisation, the stack trace points at the constructor that failed. Not into reflection internals. Not into a version of the framework documentation that predates your current dependency version. The constructor. The line. The argument that was nil.
These aren't aesthetic preferences. They're production properties that compound quietly over years.
If you feel like you need a container, listen to that feeling
The honest argument for a DI framework is that the wiring gets painful at scale. That's a real observation. It's also a misdiagnosis.
We've been here. In an early iteration of SafeOps365, app.New() started accumulating. Permit workflows, LOTO procedures, notification dispatchers, audit log writers, role resolution — each one legitimate, each one pulling in its own dependencies. The function grew. Someone on the team floated fx. The conversation lasted about ten minutes before we asked the question that actually mattered: why is this function so long?
The answer wasn't that we needed a container. The answer was that we'd been treating infrastructure initialisation and domain wiring as the same problem. They aren't. Infrastructure — database connections, HTTP clients, logger configuration — has a lifecycle that starts early and stays alive. Domain services compose on top of that. Once we separated those two concerns into distinct functions, app.New() became readable again. No framework required. The complexity hadn't gone away — it had been named correctly, which turned out to be enough.
When the wiring grows, it's telling you something about your architecture. The components are too coupled, the service boundaries are unclear, or something is doing more than one job. A container doesn't fix any of that. It hides the symptom behind a layer of reflection and lets you defer the conversation.
The conversation is usually one of three things: split the service, clarify the boundary, or recognise that the component doing six things should be six components. None of those require a dependency. All of them require understanding your domain well enough to have the argument. The framework exists to help you avoid that argument. We've found it's always worth having it.
Patterns over deps — always
Every dependency is a liability. Not a metaphor — a literal liability. Maintenance cost, upgrade cost, security surface, conceptual overhead that every engineer on your team pays on every touch. The question is always whether the value justifies the cost.
DI frameworks fail that test cleanly because the value they offer — assembling your components — is something you were always capable of doing yourself. The pattern is constructor injection. The tool is your language. The document is your app package. That's the whole thing. None of it requires a third party.
This is the same argument we make about every dependency we don't add. Stdlib HTTP handling before a heavyweight router. SQLite before a distributed database. A supervised process tree before a message broker. Self-hosted inference before an API call. The question is always the same: is there a pattern that does this job without adding a dependency? If yes, use the pattern. Not because dependencies are philosophically impure, but because every one you add is a bet that it will keep being maintained, keep being compatible, keep behaving the way you expect — bets you pay interest on indefinitely.
DI frameworks are a particularly instructive case because the dependency they introduce is invisible in normal operation. Everything works until it doesn't — until the framework version lags the language version, until the reflection behaviour changes subtly across a release, until the new engineer spends two days debugging initialisation order in a container they didn't choose. The cost arrives late and lands on someone else. That's the worst kind of liability.
The discipline is simpler than it sounds. Understand your problem well enough to write down its solution in plain code. If you can't, that's information — about the problem, about your current model of it, about what you haven't yet decided. A framework doesn't resolve that uncertainty. It papers over it.
Understand your problem well enough not to outsource the solution.
That's the discipline. Patterns over deps.