DEV Community

Cover image for Dependency Injection: The Anti-Pattern That Killed Object-Oriented Design and Won
Leon Pennings
Leon Pennings

Posted on • Originally published at blog.leonpennings.com

Dependency Injection: The Anti-Pattern That Killed Object-Oriented Design and Won

Enterprise applications didn’t get better with DI - they became slower, harder to change, harder to test, harder to upgrade, and more expensive to maintain. DI didn’t fix complexity; it became the complexity.

Imagine you go to the doctor because you have a sore thumb.

The doctor pulls out a sledgehammer and smashes your foot.

You limp out screaming.

He smiles: “Good. You’re not thinking about your thumb anymore, are you?”

That is Dependency Injection.

EJB2 was the sore thumb. DI was the sledgehammer.

EJB3 cured the thumb in 2006.

We have spent twenty years smashing the rest of the body and calling it “enterprise best practice”.


1. The Historical Accident (2003–2006)

EJB2 forced JNDI lookups and Home interfaces, creating unnecessary layers of indirection and boilerplate for container-managed beans.

Rod Johnson built a lighter container in Spring to hide that pain, making it easier to work around the lifecycle mismatches.

Then EJB3/JPA arrived, simplifying everything—no more home interfaces, no more ugly lookups—and the original pain disappeared forever.

Yet, instead of celebrating the freedom to instantiate objects directly, we kept the sledgehammer and made it mandatory for every class in the system.


2. A Concrete Example Everyone Recognises

In DI frameworks today, something as simple as permanent storage gets bloated with abstractions:

interface Storage {  }

@Component @Qualifier("perm")
class S3Storage implements Storage {  }

@Service
class OrderService(
    @Qualifier("perm") Storage storage
) {  }
Enter fullscreen mode Exit fullscreen mode

The meaning of “permanent” is now an invisible configuration detail scattered across qualifiers, annotations, and YAML files. Change the backend? You’re hunting through the entire codebase for wiring mismatches.

Contrast that with a responsibility-centered approach:

final class PermanentStorage {
    private static final S3Client s3 = resilientInstrumentedS3Client();

    public static Identifier save(Object o) {  }
    public static Object load(Identifier id) {  }
}
Enter fullscreen mode Exit fullscreen mode

Just two public methods. All the gritty details—retries, metrics, tracing, multipart uploads, auth rotation—live inside that one class forever. No callers ever touch AmazonS3 directly. Switching to a different vendor? Change one file, and the rest of the system doesn’t even notice.

This isn’t just cleaner; it’s how encapsulation was meant to work.


3. DI Is Incompatible with Object-Oriented Design

In true OO, a constructor creates a fully valid object, owning its invariants and hiding implementation details behind a clean API.

With DI containers, constructors become mere dependency laundry lists, declaring what the container should inject rather than guaranteeing the object's validity.

Invariants are pushed out to configuration files or annotations, making it impossible to reason about an object's state at creation time.

You can't even instantiate most classes manually without spinning up the entire context—turning new into a code smell and replacing local reasoning with global magic.

Most arguments in favour of DI today are not based on functional necessity or architectural clarity, but on cultural inertia. Developers defend it because it’s what they were taught, not because it solves real problems in modern codebases.


4. The SOLID Justification Is a Myth (DIP and ISP Have Nothing to Do With DI)

The industry loves to justify DI by pointing at SOLID — especially DIP (Dependency Inversion Principle) and ISP (Interface Segregation Principle).

But this is one of the biggest category errors in modern software engineering.

4.1 What DIP Actually Says

The real DIP is a static design rule about source-code dependency direction, not runtime injection.

DIP means:

  1. High-level modules shouldn’t depend on low-level modules.

  2. Both should depend on abstractions.

  3. Those abstractions should represent meaningful roles in the domain.

That’s it.

DIP does not require:

  • a DI container

  • annotation-based wiring

  • interface-per-class

  • configuration-driven object graphs

DIP simply encourages stable, focused abstractions — small interfaces with a single, coherent responsibility (not necessarily one method, but one purpose).

DI, however, promotes the exact opposite:

  • one-implementation interfaces

  • constructor laundry lists

  • leaking implementation details into the API

  • global container coupling instead of local reasoning

Where DIP tries to reduce coupling, DI introduces a new global kind of coupling — to the container itself.

4.2 What ISP Actually Says

ISP is even simpler.

The Interface Segregation Principle means:

Clients should not be forced to depend on methods they do not use.

In practice:

  • Interfaces should be small

  • Interfaces should be cohesive

  • Interfaces should match a role, not a convenience artifact

ISP is about shrinking interfaces, not creating more of them.

DI culture twists ISP into:

  • “Every class must have an interface.”

  • “Every dependency should be injected via abstraction.”

This is backwards.

Creating a pointless wrapper interface for a single implementation is a violation of ISP, not compliance with it.

4.3 The Core Problem

DIP and ISP are design principles.

DI is a runtime framework mechanism.

They solve different problems.

Confusing them led the entire industry to believe:

  • “If we use DI, we’re applying SOLID!”

  • “If we annotate everything, we’re doing OO!”

In reality:

  • DIP wants clean, minimal, meaningful abstractions.

  • ISP wants focused role-based interfaces.

  • DI produces an explosion of meaningless interfaces and container-driven wiring — the opposite of both principles.

The result:

A massive ecosystem treating DI as moral correctness, while quietly eroding encapsulation, readability, and evolutionary design.


5. DI Actively Prevents Evolutionary Architecture

Enterprise software doesn't die after launch—it lives for 10–25 years, with shifting business rules and tech stacks.

You want evolutionary change: small updates, continuous cleanup, minimal surprises.

Rich domain objects enable this by encapsulating logic in plain Java, with minimal framework interference. Framework upgrades touch only a few boundary lines, and the core stays intact.

DI does the opposite, breeding revolutionary software that needs full rewrites every 5–7 years.

Behavior is scattered into anemic service classes, invariants are lost, and logic is de-contextualized across configuration, qualifiers, proxies, and AOP.

Every class is wired into a global graph, so small changes ripple everywhere.

The framework becomes the skeleton, dominating the domain, and upgrades like Spring Boot 2 → 3 become multi-man-year nightmares—complete with seven-figure consulting fees—because the real program lives in proxies and YAML, not readable code.

Without containers, the difference is night and day:

  • builds run faster without classloader noise or spin-ups,

  • code is easier to read with explicit logic instead of magic,

  • the application becomes transparent—like removing a blanket from your hi-fi speakers.

Logic centralizes in plain Java objects, not scattered across services and config files, making evolution incremental instead of catastrophic.


6. My Personal Controlled Experiment

I used Spring for years. I also used EJB2.

And here is the dirty secret I discovered:

EJB2 was already simpler than modern Spring Boot.

The overhead and clutter of EJB2 with RMI for each call got expanded to container maintenance in Spring.

In fact, the legacy of lifecycle management that was present in EJB2 has been amplified by DI by container managing everything and thus making the application unnecessarily more complex.

The goal should be to work without containers. That was the lesson learned by EJB3.

So one day I asked the only honest question that matters:

“What if I only reach for DI when I actually need it?”

The answer, after a decade of real projects, is:

You never need it.

Not once.

Every single thing Spring promises—transactions, current user, persistence context, resilient clients, testability—can be solved with simple straightforward Java and zero container.

And when you do that:

  • the application shrinks to 25–35% of the lines of code,

  • needs 80% fewer developers,

  • becomes immune to framework-upgrade death marches,

  • builds go faster,

  • code becomes explicit and clean,

  • complexity drops dramatically.

That is not ideology.

That is the controlled experiment I ran while everyone else was busy adding another @Configuration class.

Software development is a "non-reference environment"—there's no direct comparison to an alternative implementation, so teams create their own reality where DI "works fine" because they've never seen the other side.

For more on why this creates an expensive mess—and why teams trapped in it rarely notice—see:

Why IT Is an Expensive Mess — And Why Nobody Inside IT Notices

How about this simple comparison: Say you are baking a cake. You can choose between a 1 page full recipe, or a 1 page recipe with reference to 20 small snippets from the snippet box of recipes. Now which recipe is easiest to understand and which recipe is easiest to maintain? And what if the recipe gets more elaborate? That is the simplest representation of what DI really is. Yes, the analogy is simple — because the underlying truth is simple. Fragmentation makes comprehension harder. Indirection multiplies cognitive load.

Whenever I’m hired to revive a stalled project or run a project that failed before, first thing I do is remove the clutter, remove the DI and make sure everybody in the team starts discussing the domain model. The difference is like night and day and projects start moving at lightning pace.


7. DomainInteraction – The Core Concept DI Forbids You to Name

In a real domain, the most important thing is not “an EntityManager”, “a SecurityContext”, or “a Transaction”. The most important thing is the Interaction itself: an atomic, authenticated conversation between an actor and the system that either fully succeeds or fully fails.

That concept deserves a name, a boundary, and explicit control.

(Full background and implementation here:How to Apply Object Orientation to Non-Entities: Implementing the Interaction in Your Business Model)

A DomainInteraction is a first-class representation of that converstion, It is opened once per request, carries the current authenticated user, wraps a JPA EntityManager and transaction, and is cleanly closed at the end. Exactly one place does the opening and closing**:**

// Somewhere on the edge — servlet, controller adapter, message listener, job runner —
// literally the ONLY place in the entire codebase that knows about DomainInteraction
DomainInteraction.execute(() -> {
    authenticateAndSetCurrentUser(request);
    handler.process(request);   // your real business logic starts here
});
Enter fullscreen mode Exit fullscreen mode

And then the rest of your code — repositories, services, domain objects — simply uses it:

public class OrderFinder {
    private static final EntityManager em = DomainInteraction.getInstance();

    public static Order byId(long id)     { return em.find(Order.class, id); }
    public static void save(Order order) { em.persist(order); }
}
Enter fullscreen mode Exit fullscreen mode

That’s literally is all.

No @Transactional, no @PersistenceContext, no proxies, no 40-second application-context startup, no circular-dependency errors, no qualifier hell. You get perfect transaction scoping, perfect current-user propagation, and zero domain code changes when you upgrade Hibernate, migrate to Quarkus, or switch from Hibernate to EclipseLink.

This is nothing more than a classic Context Object using ThreadLocal storage — a pattern documented in 1997 that has always been cleaner, faster, and more explicit than the Spring way.

Spring’s thousands of classes, dozens of annotations, and multi-second startup are a bloated, brittle imitation of these ~100 lines of code.

That is the proof: the most critical concept in your domain finally gets a name and an explicit boundary instead of being smeared across twenty annotations and framework internals.

DI doesn’t just add complexity. It prevents you from modelling the domain correctly.


8. The Three Sacred Excuses, Destroyed

“But testing!”

We tested software for thirty years before constructor injection. Use H2, LocalStack, Testcontainers, or fast integration tests. Polluting production code just so Mockito is happy is indefensible surrender—real tests don't need mocks for everything.

“But large teams need enforcement!”

If your engineers only respect boundaries when Spring throws a NoUniqueBeanDefinitionException at startup, you don't have an architecture problem—you have a hiring and discipline problem. Code reviews and module boundaries work fine without a container babysitter.

“But frameworks force lifecycle!”

Write one explicit filter + one context object like DomainInteraction.

Do not infect or amputate the rest of the application to solve a 0.5% problem—keep the domain pure and the boundary thin.

There are zero legitimate greenfield use cases left in 2025.


9. The Alternative That Actually Works

Model responsibilities and interactions, not wiring.

  • PermanentStorage owns its 300 lines of resilient S3 client forever.

    Callers see exactly two static methods—perfect encapsulation.

  • OrderProcessor owns its PaymentGateway, initializing it internally and exposing only business methods.

  • DomainInteraction owns transaction + current user + EntityManager, providing a clear, explicit context without scattering details.

Sharing happens at:

  • the binary level (jar dependencies), or

  • the domain façade level,

never at runtime wiring.

Accept twenty lines of “duplicated” initialization—it's far cheaper than twenty years of interface bloat, qualifier hell, and circular-dependency errors.


10. The Final Objection: “It looks too simple for enterprise”

Senior architects who reject this because “it looks too simple” are the same people who just spent 18 months and a million dollars migrating from Spring Boot 2 to 3 while the 2018 code using DomainInteraction and PermanentStorage kept running untouched.

Simplicity is not the absence of enterprise.

It is the ultimate expression of it.

Obvious bugs cannot hide in 80 obvious lines of Java.

They thrive in 80 000 lines of annotations, proxies, and YAML that nobody dares touch.


Conclusion: Put the Sledgehammer Down

Dependency Injection containers are the most successful anti-pattern in software history.

They solve nothing that still exists.

They destroy encapsulation, locality, readability, evolvability, and – worst of all – prevent you from naming the core concepts of your domain.

Stop annotating constructors.

Stop writing single-implementation interfaces.

Stop treating the container as God.

The sore thumb was cured in 2006.

Your foot has been broken long enough.

Kill the zombie.

Write real objects and real DomainInteractions again.

Top comments (0)