See how Java dependency injection turns a tightly coupled legacy service into flexible, testable code. Follow a real refactor from hard-coded dependencies to
You know the class immediately when you see it. It creates an `EmailClient` inside its constructor, calls `TaxCalculator.getInstance()` three lines later, and somewhere in the middle reaches for a static utility that talks to a database. Java dependency injection for flexible, testable code is the direct answer to this pattern — but most explanations stop at the theory and leave you staring at the same tightly coupled service you started with. This article doesn't. It takes one ugly legacy class and refactors it step by step into something you can actually unit test, using constructor injection, JUnit 5, and Mockito to prove the improvement is real.
The goal is not to introduce a framework. It's to show you what changes when a class stops creating its own collaborators and starts accepting them from the outside — and why that single shift makes every test you write after it noticeably shorter and cleaner.
Why This Legacy Java Service Is Impossible to Test Cleanly
The class looks harmless until you try to unit test it
The problem isn't the business logic. It's that the class has made private decisions about which implementations it wants to use, and those decisions are baked into the construction path. When you try to write a unit test, you're not testing the class — you're testing the class plus every hard-coded collaborator it dragged in. That's not a unit test. That's an integration test wearing a unit test's clothes.
The SOLID principles, and specifically the Dependency Inversion Principle, exist precisely to name this problem: high-level modules shouldn't depend on low-level implementations. They should depend on abstractions. When a class violates this, you feel it first in the test file.
What this looks like in practice
Here is the legacy service:
Now try to write a unit test for `placeOrder`. The constructor runs immediately, which means `EmailClient` tries to connect to an SMTP server, `TaxCalculator.getInstance()` might read from a configuration file, and `AppCache.getSharedInstance()` may initialize a shared in-memory store that bleeds state between tests. Before you've written a single assertion, your test setup is already doing real I/O.
You can't mock `TaxCalculator.getInstance()` without PowerMock or Mockito's static mocking (which is possible but noisy and signals a design problem, not a solution). You can't swap in a fake `EmailClient` because the class constructed the real one itself. The test that should take five lines takes fifty, and it's fragile. This is what java dependency injection for flexible, testable code is designed to prevent.
The Hidden Dependencies Are the Real Problem, Not the Business Logic
Static methods and singletons are dependency traps
Static helpers and singleton access points look like conveniences. They are. That's the trap. They remove the seam — the place where you could have inserted a substitute — and replace it with a direct wire to one specific implementation. The class looks simple because you don't see any configuration. The simplicity is an illusion. The configuration is just hidden inside the static call.
Robert C. Martin's treatment of this in *Clean Code* is direct: dependencies should be visible. A collaborator that arrives through a static method or a singleton accessor is invisible to the class's public API. You can't see it in the constructor signature. You can't replace it in a test. It's a hidden coupling.
What this looks like in practice
Mark every hidden dependency in the original `OrderService`:
Each of these should become an interface or an injected collaborator. `EmailClient` should become a `NotificationSender` interface. `TaxCalculator` should be an interface with a `calculate` method. `Cache` is already abstract enough to become a typed interface. The business logic inside `placeOrder` — check cache, calculate tax, send notification, store result — doesn't change at all. Only the wiring does.
Constructor injection makes these dependencies visible in the class signature. A caller looking at the constructor knows exactly what `OrderService` needs to function. That transparency is the whole point of the dependency inversion principle, and it's what makes the next step — testing — dramatically simpler.
Refactor the Class So It Accepts Dependencies From the Outside
Start with the smallest useful boundary
You don't need to rewrite the class. You need to make three moves: extract an interface for each collaborator, change the constructor to accept those interfaces, and delete the internal construction. That's it. The business logic stays exactly where it is.
The refactor follows the dependency inversion principle directly — the `OrderService` should depend on abstractions (`NotificationSender`, `TaxCalculator`, `OrderCache`), not on concrete implementations. Martin Fowler's writing on Inversion of Control describes this boundary as the essential shift: the class declares what it needs, and something else decides what to provide.
What this looks like in practice
Here is the refactored service:
The changed lines: the three `private final` fields now reference interfaces, and the constructor accepts them as parameters instead of constructing them internally. The `placeOrder` method is identical. That's the whole refactor. The class is now open for testing and closed to implementation details it doesn't need to know about.
Use Constructor Injection Here, and Be Honest About When Setter or Field Injection Makes Sense
Constructor injection wins because the object needs those collaborators to function
An `OrderService` without a `TaxCalculator` is not a partially initialized `OrderService`. It's a broken object. Constructor injection enforces this: you cannot construct the service without providing every required collaborator. The object is valid from the first line of its existence, or it doesn't exist at all.
Setter injection and field injection both allow partially constructed objects. That's sometimes useful, but it's a concession, not a feature.
What this looks like in practice
Compare the same service written three ways:
Constructor injection (recommended for required dependencies):
Testability: pass mocks directly in the test constructor call. No framework needed. The object is valid immediately.
Setter injection (useful for optional collaborators):
This works when a dependency is genuinely optional — for example, an audit logger that should be skippable in certain environments. The tradeoff is that `notificationSender` can be null if you forget to call the setter, which means you need null checks in the business logic or you'll get a `NullPointerException` at runtime. Setter injection is also the right call when you're working with a framework like Spring that needs a no-arg constructor to proxy the class.
Field injection (convenient, weaker):
Field injection is the most common pattern in Spring Boot tutorials because it's the shortest to write. It's also the hardest to test outside a Spring context, because there's no constructor or setter to call — you need reflection or the full application context to inject the mock. The Spring documentation itself recommends constructor injection for required dependencies precisely because it makes the dependency contract explicit and testable without a container.
Use constructor injection as your default. Use setter injection for optional dependencies. Use field injection only when you're in a framework context where constructor injection is genuinely awkward and you understand the testing tradeoff.
Rewrite the JUnit 5 and Mockito Tests After the Refactor
The test gets shorter because the class stops fighting you
Before the refactor, setting up a test for `OrderService` required either a real infrastructure environment or PowerMock-level static mocking. After the refactor, you pass three Mockito mocks into the constructor and you're done with setup. The test shrinks from infrastructure ceremony to pure behavior verification.
This is the measurable payoff of the design change. JUnit 5 and Mockito are not doing anything new — they're just no longer blocked by the class hiding its collaborators.
What this looks like in practice
Before the refactor (clumsy setup):
After the refactor (clean JUnit 5 and Mockito test):
The arrangement section is four lines. The act is one line. The assertions are two or three lines. Compare this to the pre-refactor version, where the arrangement alone would require spinning up real infrastructure or fighting the static API. The Mockito documentation is clear that this pattern — inject mocks through the constructor, verify interactions — is the intended usage. The design makes it possible. Mockito just executes it.
The second test is particularly telling. It verifies that when the cache has a result, neither the `TaxCalculator` nor the `NotificationSender` is called. You couldn't write that test at all against the original class, because you couldn't control what the singleton cache contained without mutating shared state.
What Actually Improved: Flexibility, Readability, and Maintenance
The real win is swapping implementations without rewriting the class
The test suite is the first place you feel the improvement. The second place is six months later, when the business decides to switch from a direct SMTP email sender to a third-party notification service. In the original design, that change means opening `OrderService`, removing the `new EmailClient()` call, and wiring in the new implementation — touching the class that owns the business logic. In the refactored design, you create a new `NotificationSender` implementation and pass it in at the construction site. `OrderService` doesn't change at all.
This is the definition of flexible, testable code. The boundary between what a class does and what it uses is explicit and replaceable.
What this looks like in practice
A realistic swap: your integration test suite uses a `FakeTaxCalculator` that always returns a fixed rate, so tests run fast and deterministically. Your staging environment uses a `SandboxTaxCalculator` that calls a tax API test endpoint. Production uses `ProductionTaxCalculator`. All three implement the same `TaxCalculator` interface. `OrderService` never knows which one it has.
That kind of substitution is what teams mean when they talk about testability paying off in production, not just in unit tests. A payment client that fails in a specific way during chaos testing, a cache implementation that expires entries on a different schedule, a notification sender that logs instead of sending — all of these are one constructor argument away from being testable in isolation.
The maintenance argument is equally concrete. When a collaborator's behavior needs to change, you change the collaborator. You don't touch the service. The blast radius of any single change shrinks, which is the core maintainability benefit of interface-driven design backed by dependency injection.
When This Refactor Is Worth It, and When You Should Leave the Class Alone
Not every helper needs to become an abstraction
Dependency injection has a cost. Every interface you extract is one more file, one more concept, one more thing a new team member needs to understand before they can read the code. That cost is worth paying when the collaborator is unstable, external, or genuinely needs to vary between environments. It is not worth paying for a `DateFormatter` that always formats dates the same way.
The Refactoring guidance from Martin Fowler is useful here: extract an abstraction when it buys you something concrete — a seam for testing, a boundary for future change, a point of substitution. Don't extract it because abstraction feels principled.
What this looks like in practice
Apply this rule to the `OrderService` example. `NotificationSender` is worth abstracting: it talks to an external service, it needs a test double, and it's likely to change as notification channels evolve. `TaxCalculator` is worth abstracting: tax rules vary by region, the implementation might call an external API, and you want to test the service logic without triggering real tax calculations. `OrderCache` is worth abstracting: it's a stateful collaborator that needs to be controlled in tests.
Now consider a `PriceFormatter` that just calls `String.format("%.2f", price)`. Wrapping that in an interface and injecting it is ceremony with no payoff. The formatter is pure, stateless, and deterministic. There's nothing to substitute, nothing to mock, nothing to vary. Leave it as a direct call.
The practical decision rule: if you'd want to mock it in a unit test, it should be an abstraction. If mocking it would be pointless, keep it direct. That single question will handle 90% of the decisions you need to make when applying dependency injection to a real codebase.
FAQ
Q: What is dependency injection in Java, in one interview-ready sentence?
Dependency injection is a design pattern where a class receives its collaborators from an external caller rather than constructing them itself, which decouples the class from specific implementations and makes it straightforward to substitute behavior in tests or different runtime environments.
Q: How does DI make code more flexible and easier to unit test?
When a class accepts its dependencies through a constructor or setter, you can pass in a mock or a fake implementation during testing without touching the class itself. Flexibility follows from the same mechanism: swap the implementation at the construction site, and the class never needs to change.
Q: What is the difference between constructor injection, setter injection, and field injection?
Constructor injection requires all dependencies at object creation time, which guarantees a valid object and makes dependencies visible in the API. Setter injection allows optional or late-bound dependencies but risks null references if setters are skipped. Field injection uses annotations like `@Autowired` directly on fields, which is concise but requires a framework or reflection to inject in tests and hides the dependency contract from the class signature.
Q: How do you refactor a Java class that instantiates its own dependencies into an injectable design?
Extract an interface for each collaborator, replace the `new` calls and singleton accesses in the constructor with interface-typed parameters, and delete the internal construction. The business logic in the class body stays unchanged. The caller — a factory, a Spring context, or a test — is now responsible for deciding which implementations to provide.
Q: How do you test a DI-based class with JUnit 5 and Mockito?
Create Mockito mocks for each injected interface, pass them into the class constructor directly, stub the behavior you need with `when(...).thenReturn(...)`, invoke the method under test, and verify outcomes with JUnit 5 assertions and Mockito's `verify`. No Spring context, no real infrastructure, no shared state between tests.
Q: When is DI worth the added complexity, and when is it overkill?
DI pays off when a collaborator is external, stateful, or likely to vary — email senders, payment clients, caches, repositories. It's overkill for pure, deterministic utilities like string formatters or simple math helpers. The test question is the clearest filter: if you'd want to mock it, abstract it. If mocking it would be pointless, keep it direct.
Q: How do static methods and singletons interfere with testability?
Static methods and singletons remove the seam where a test could insert a substitute. The class calls directly into a fixed implementation with no way to intercept or replace it without framework-level tricks like PowerMock. They look like simplifications but are actually hidden hard-coded dependencies — and they make the class's true collaborators invisible to anyone reading the constructor signature.
Conclusion
Go back to the original `OrderService`. Three `private final` fields. A constructor that builds its own collaborators. A test that either spins up real infrastructure or refuses to run cleanly at all. That class isn't badly written — it's written the way most Java code starts out, before anyone tries to test it in isolation.
The refactored version has the same fields, the same business logic, and the same method signature. The only thing that changed is where the collaborators come from. That shift — from internal construction to external injection — is what makes the JUnit 5 and Mockito tests drop from fifty lines of setup to five. It's what lets you swap a real email sender for a fake one in tests without touching the service. It's what makes the class readable to someone who's never seen the codebase before, because the constructor tells them exactly what the class needs to function.
The practical nudge: if one class in your codebase keeps accumulating test setup noise, keeps requiring real infrastructure to run, keeps breaking when a collaborator changes — that's the class worth refactoring first. You don't need to inject everything. You need to inject the things that are causing the friction. Start there, write the test, and the design improvement will be obvious before you finish the first assertion.
How Verve AI Can Help You Ace Your Coding Interview With Java Dependency Injection
Technical interviews on dependency injection don't stop at definitions. An interviewer who asks "what is DI?" is warming up. The real question is "show me how you'd refactor this class" or "why did you choose constructor injection here" — and those questions require you to reconstruct a coherent design narrative under live pressure, not recall a memorized answer.
That's the exact scenario Verve AI Coding Copilot is built for. It reads your screen in real time, understands the code you're looking at, and surfaces context-aware suggestions as the problem evolves — so when the interviewer asks you to extend the refactor or justify an interface boundary, you're not starting from scratch. Verve AI Coding Copilot works across LeetCode, HackerRank, CodeSignal, and live technical rounds, and it stays invisible while it does it. The Secondary Copilot feature keeps you focused on one problem at a time, which matters when a design question branches into a testing question and then into a framework question inside the same thirty-minute session. If you're preparing to explain a legacy-to-injectable refactor under interview conditions, practice with live code the way Verve AI Coding Copilot lets you — with real-time feedback on what you're actually writing, not a canned prompt about what you should have written.
James Miller
Career Coach

