Dependency injection is a good tool, and most teams are right to use it early. It makes dependencies explicit, improves testability, and forces engineers to be honest about what a type actually needs in order to function. In codebases that would otherwise lean on globals, singletons, or hidden side effects, that alone is a meaningful improvement.

What makes DI tricky is how long it can continue looking healthy after the system around it has stopped being healthy.

At the beginning, injection usually reflects a real design. A feature has a small set of collaborators. A service has a clear role. The dependency graph feels like a map of intentional relationships. As the system grows, though, that map begins to shift. A type that once depended on two services now depends on five. A module that used to be self-contained imports another feature because a useful piece of data already exists there. A shared layer expands to support more use cases, partly because it is convenient and partly because drawing a harder boundary would require a more difficult conversation.

DI handles all of this without complaint. The wiring still works. Constructors still compile. The container still resolves everything cleanly. From the outside, the system can continue to look disciplined even while the underlying design gets harder to reason about.

Clarity is usually the first thing to go.

Tests that once exercised a narrow slice of behavior now need half the system to be assembled before anything meaningful can run. Small changes begin touching multiple layers because the path between state, orchestration, and side effects is no longer obvious. The graph grows, but understanding does not grow with it. DI is still doing exactly what it was asked to do, only now it is preserving relationships that should have been questioned earlier.


The deeper problem has less to do with the number of dependencies than with the kind.

Cross-dependencies tend to form quietly. One feature reaches into another feature’s service because the data is already there and the cost of rethinking the boundary feels too high for the current sprint. That service evolves to support both use cases. A repository starts shaping its output to match the needs of a particular screen. Another feature then begins to rely on that shape because it saves time. None of these moves feels dramatic on its own. Each one is local, understandable, and easy to justify. Together, they blur the boundaries the architecture was supposed to protect.

DI makes those dependencies visible, which is useful, but visibility is not the same thing as legitimacy. A dependency can be explicit and still be wrong. As long as the types line up, the system accepts the relationship. Over time, the graph starts reflecting history rather than design. It tells you what happened to be connected, not what should have been.

Around the same time, the promise of flexibility begins to thin out. In theory, DI gives you replaceable implementations, easy mocking, and the freedom to evolve components independently. In practice, once enough layers begin leaning on each other, replacing one piece means understanding every consumer that has quietly grown around it. Tests remain technically possible, but they stop being light. Shared abstractions remain technically reusable, but they become less precise for every team that depends on them. What looked flexible on paper becomes rigid in practice.

That rigidity changes how teams work. Engineers stop asking who should own a decision and start asking where they can get a reference to the thing they need. The shift sounds small, but it reflects a deeper change. Once access becomes the dominant concern, DI stops reinforcing boundaries and starts normalizing their absence.

The issue is not injection itself. The issue is the lack of boundaries strong enough to keep the dependency graph honest.

When responsibilities are narrow and enforced, fewer dependencies are needed in the first place. State can live in one place and remain value-only. Services can perform work without reaching across domains. Orchestration can decide how pieces fit together without smearing that logic across view models and helpers. Independent components can communicate without directly depending on each other’s internals. In that environment, the graph gets smaller not because anyone optimized it aggressively, but because the architecture stopped creating reasons for it to grow.

The goal is not to remove dependency injection. It is to stop asking it to compensate for design problems it cannot solve.

Wiring is useful, but wiring is not design. A system with clear boundaries can use DI effectively and remain understandable as it scales. A system without those boundaries will continue getting harder to change, no matter how clean the constructor signatures look.

DI solves access. Architecture decides what should be accessible.