Dependency Injection Concepts in Java
Executive Summary
Dependency injection in Java means a class receives its collaborators from the outside, typically through its constructor, instead of creating them internally. The dependency should be an interface, the seam from the abstract classes article. That way, the concrete implementation can change without touching the class that depends on it. A composition root, usually one place near main, builds the full object graph. It wires concrete implementations into constructors, and nothing else in the codebase calls “new” on a collaborator. Hand-written factories group related construction logic once the graph grows past a few objects. As a result, you gain testability (swap in a test double) and flexibility (swap implementations without edits). You also get a single place to see how the whole application fits together. Spring automates this wiring with a container and annotations. However, deep coverage of that belongs in the Spring Boot course on this site.
The Problem: A Class That Builds Its Own Dependencies
Start with the wrong code, because the failure mode is the best teacher. A notification service that constructs its own email client:
public class NotificationService {
private final SmtpEmailClient emailClient = new SmtpEmailClient("smtp.prod.example.com");
public void notifyUser(String address, String message) {
emailClient.send(address, message);
}
}
Nothing about this compiles into a warning, and that is the trap. However, three problems are now baked in. Every unit test of notifyUser sends a real email or fails against a real SMTP server. Switching providers, SMTP to an HTTP-based service, means editing NotificationService itself. And nothing in the class’s public surface reveals that it depends on a network call at all.
The fix is not clever. Stop building the dependency inside the class, and accept it from outside:
public interface EmailClient {
void send(String address, String message);
}
public class NotificationService {
private final EmailClient emailClient; // the dependency, injected
public NotificationService(EmailClient emailClient) {
this.emailClient = emailClient;
}
public void notifyUser(String address, String message) {
emailClient.send(address, message);
}
}
NotificationService no longer knows SMTP exists. Instead, it depends on EmailClient, a contract, the way the repository pattern article’s CatalogService depended on BookRepository rather than a JDBC class.
Constructor Injection: The Default Choice
Constructor injection means the dependency arrives as a constructor parameter and lands in a final field, as above. It is the default for three concrete reasons. Firstly, nobody can construct a class in an invalid state. After all, the compiler forces every dependency into the constructor at creation time. Second, final fields communicate immutability: nothing later in the object’s life can swap the dependency out from under it. Third, the constructor signature becomes a complete, honest list of what the class needs, readable without opening the method bodies.
Two other injection styles exist, and both trade away something constructor injection keeps:
// setter injection: dependency can be missing until setEmailClient runs
public class NotificationService {
private EmailClient emailClient; // not final, not guaranteed set
public void setEmailClient(EmailClient client) { this.emailClient = client; }
}
// field injection: no constructor at all, relies entirely on a framework or reflection
public class NotificationService {
private EmailClient emailClient; // set by something external, invisibly
}
Setter injection allows a half-built object, the field is null until someone remembers to call the setter. Meanwhile, field injection hides the dependency from the constructor signature entirely, so only opening the class reveals it. It also generally requires a framework or reflection to populate. In my experience reviewing pull requests, field injection causes the most confusion. For example, a new engineer reads the constructor, sees no dependencies, and assumes the class is dependency-free.
The Composition Root: One Place That Calls “new”
If classes never build their own dependencies, something still has to build them, somewhere. That somewhere is the composition root: one place, usually near main, where you construct concrete implementations and wire them into constructors.
public class Main {
public static void main(String[] args) {
EmailClient emailClient = new SmtpEmailClient("smtp.prod.example.com"); // the ONE "new"
NotificationService notifications = new NotificationService(emailClient);
notifications.notifyUser("user@example.com", "Your order shipped.");
}
}
The rule that keeps this honest: outside the composition root, no class calls “new” on one of its own dependencies. NotificationService never mentions SmtpEmailClient, and the only place that name appears in the entire codebase is Main. Swapping SMTP for an HTTP-based email API means writing one new EmailClient implementation and changing one line in Main. NotificationService does not recompile, let alone change.
Hand-Written Factories: When the Graph Grows
A composition root with three objects reads fine inline. However, a real application has dozens. Inlining all of them into main turns it into an unreadable wall of “new” calls. The fix is a factory, a class whose entire job is building a subsystem’s object graph:
public class ServiceFactory {
public static NotificationService createNotificationService(AppConfig config) {
EmailClient emailClient = config.useHttpEmailProvider()
? new HttpEmailClient(config.emailApiUrl())
: new SmtpEmailClient(config.smtpHost());
return new NotificationService(emailClient);
}
public static OrderService createOrderService(AppConfig config, NotificationService notifications) {
BookRepository repository = new JdbcBookRepository(config.dataSource());
return new OrderService(repository, notifications);
}
}
public class Main {
public static void main(String[] args) {
AppConfig config = AppConfig.fromEnvironment();
NotificationService notifications = ServiceFactory.createNotificationService(config);
OrderService orders = ServiceFactory.createOrderService(config, notifications);
orders.placeOrder("978-0134685991", "user@example.com");
}
}
The factory decides which concrete EmailClient to build based on configuration, the exact decision a dependency injection container automates later. Main stays small: it reads configuration, calls factories in dependency order, and runs the application. In short, this is what a DI container does for you at a much larger scale. Recognizing that is the bridge to the next section.
Interfaces as Seams: Testing Without the Real Dependency
The entire payoff of injecting EmailClient instead of SmtpEmailClient shows up in tests. A fake implementation satisfies the interface without touching a network:
class RecordingEmailClient implements EmailClient {
final List<String> sentMessages = new ArrayList<>();
@Override
public void send(String address, String message) {
sentMessages.add(address + ": " + message); // no network, just a record
}
}
@Test
void notifyUserSendsExactlyOneMessage() {
var fakeClient = new RecordingEmailClient();
var service = new NotificationService(fakeClient); // inject the fake
service.notifyUser("user@example.com", "Your order shipped.");
assertEquals(1, fakeClient.sentMessages.size());
}
This is the same tier split the Mockito article covers. In fact, a Mockito mock of EmailClient works identically here, verifying the interaction without a hand-written fake. Either way, the test runs in microseconds because NotificationService never knew it was talking to a fake. The interface is the seam, and constructor injection is what makes the seam reachable from a test.
Plain Java DI vs a Container
| Aspect | Hand-wired (this article) | DI container (Spring) |
|---|---|---|
| Who calls “new” | You, in the composition root | The container, from component scanning or config |
| Wiring visibility | Fully explicit, readable top to bottom | Implicit, resolved by type and annotations |
| Startup cost | None beyond your own code | Container bootstraps and scans classes |
| Scaling to 200 classes | Factories multiply, gets verbose | Annotations stay flat regardless of graph size |
| Swapping an implementation | Edit one factory method or main | Change a bean definition or profile |
| Debugging “why is this null” | Read the composition root | Read container logs or enable debug wiring |
Neither approach changes the underlying idea. A container does not inject anything differently from the factory above. It simply performs the wiring automatically instead of you typing it out.
How Spring Does It
Spring replaces your composition root with a container. You annotate a class @Component, and a constructor parameter still declares the dependency. Then Spring’s ApplicationContext finds a matching implementation and injects it automatically, no factory method required. @Autowired, @Qualifier, and @Configuration classes with @Bean methods are Spring’s vocabulary for the same composition root job. That job is deciding which concrete type satisfies which interface. Dependency Injection in Spring Boot walks through that vocabulary in depth, including bean scopes and circular dependencies.
The constructor injection pattern in this article is exactly what Spring expects from your classes. A class with a clean constructor taking an interface is already container-ready, nothing about its code needs to change. Deep coverage of components, beans, and the application context belongs in the Spring Boot course on this site.
How Real Systems Do This
Every production Java codebase I have worked in draws the composition root line somewhere, with or without a framework. For example, I once tuned a legacy service that mixed both styles. Half its classes called “new” directly on a database client, and half received it through a constructor. The untestable half was always the one that paged someone at 2 AM. That was because nobody could reproduce the failure without the real database. Migrating the direct-construction classes to constructor injection took two months, one class per sprint. Still, every migrated class immediately got a fast unit test it never had before.
The pattern also explains why interfaces proliferate in mature codebases. They are not decoration. Instead, they are the seam that lets the composition root swap implementations per environment. So tests get a fake client and production gets a real one, without the business logic ever noticing.
Decision Framework
- Does a class construct one of its own collaborators with “new”? Move that construction to a constructor parameter.
- Is the dependency’s concrete type likely to change or need a test double? Depend on an interface, not the concrete class.
- Does the object graph fit in a few lines of main? Wire it inline, no factory needed yet.
- Is the graph growing past a handful of objects? Introduce a factory class per subsystem before main becomes unreadable.
- Are you reaching for a framework to solve a three-object graph? Don’t; hand-written wiring is simpler and has nothing to debug.
- Does the team already use Spring elsewhere in the system? Learn the plain Java pattern first anyway, because it is what makes Spring’s annotations make sense.
When NOT to Use This
- Do not inject a dependency that never varies and will never need a test double, like a stateless utility class. Constructing it directly is simpler and adds no risk.
- Do not introduce an interface for a single implementation that will plausibly never gain a second one. An interface with one implementor and no test double is ceremony, not a seam.
- Do not build a hand-rolled container (a registry that resolves types by reflection) for a small application. That is reinventing Spring’s job poorly; either wire by hand or adopt the real container.
Common Mistakes
- Injecting a concrete class instead of an interface: you still cannot swap or fake the dependency. As a result, the main benefit of injection disappears.
- Scattering “new” calls for the same dependency across many classes: the composition root stops being the composition root. Then swapping an implementation means a codebase-wide search.
- Field injection without a framework: a field with no constructor and no setter depends entirely on something external to populate it. So in plain Java, it usually just stays null.
- Treating every class as needing an interface: speculative interfaces for things with one implementation add indirection with no payoff.
- Letting the composition root grow into one giant main method: past a handful of objects, extract factories per subsystem early.
- Confusing dependency injection with a DI framework: the concept is the constructor pattern. Spring is one way, not the only way, to automate it.
Key Takeaways
- Dependency injection means a class receives its collaborators from outside, usually through the constructor, instead of building them itself.
- Constructor injection is the default: it guarantees a fully initialized object and makes dependencies visible in the signature.
- Dependencies should be interfaces, the same seam from the abstract classes and repository pattern articles. That way, you can swap or fake implementations.
- The composition root is the one place that calls “new” on concrete implementations; everything else depends on contracts.
- Hand-written factories keep the composition root readable once the object graph grows past a handful of classes.
- Tests inject fakes or Mockito mocks through the same constructor production code uses, no framework required.
- Spring automates exactly this wiring with a container; the underlying pattern does not change, only who performs the “new” calls.
FAQ
What is dependency injection in Java?
A design pattern where a class receives its dependencies from outside, typically through constructor parameters, instead of creating them internally. It decouples a class from the concrete implementations it uses, making those implementations swappable and testable.
Is dependency injection the same as Spring?
No. Dependency injection is the underlying design principle. Constructor injection and composition roots work in plain Java with no framework at all. Spring is one tool that automates the wiring with a container, annotations, and reflection.
Why is constructor injection preferred over setter injection?
Constructor injection guarantees a fully initialized object the moment it exists. That is because the compiler demands every dependency at construction time. Setter injection allows a half-built object where a dependency is still null until someone remembers to call the setter.
What is a composition root?
The single place in an application, usually near main, that constructs concrete implementations. It also wires them into the constructors that need them. Outside the composition root, classes depend on interfaces and never call “new” on their own collaborators.
Do I need a DI framework for a small Java project?
No. A composition root and a few factory methods handle object graphs with dozens of classes without any framework. Reach for a container only when manual wiring becomes genuinely unwieldy. Deep coverage of that transition belongs in the Spring Boot course on this site.
Conclusion
Dependency injection in Java is not a framework feature. Instead, it is a constructor discipline you can practice in every class you write, starting today. Get the interfaces right and keep “new” confined to the composition root. Then the rest, including Spring’s annotations, will read as automation of something you already understand.
Next, the following article previews Spring Boot itself with one minimal controller. It uses classes shaped exactly like the ones in this article, while the framework’s depth waits for its own course.
Inject the interface and build the concrete type in one place. Then your tests will thank you before your deploys ever do.
Last updated on 12 September 2026.
