Start with the problem
The dependent class is making two decisions.
This version of OrderService both places an order and chooses how notifications are sent:
public class OrderService {
private final EmailNotificationSender notificationSender =
new EmailNotificationSender();
public void placeOrder(String orderId, String destination) {
notificationSender.send(
destination,
"Order " + orderId + " placed successfully"
);
}
}The service is coupled to EmailNotificationSender. Replacing email with SMS requires editing the service, and an isolated test cannot provide a harmless fake sender. The problem is not the new keyword by itself; it is where the concrete choice is made.
Read the code from the service's point of view. To place one order it now needs to know that email exists, how an email sender is constructed and that email is the policy currently chosen by the application. Those are wiring decisions, not order-placement decisions.
Business responsibility
What should happen?
After an order is accepted, send a confirmation to its destination.
Construction responsibility
Which object should do it?
Use email today, perhaps SMS tomorrow, and construct the chosen client.
Tight coupling appears because both responsibilities are inside the same class. The cost becomes visible when requirements change: adding SMS, recording sent messages in a test, or wrapping the sender with retry logic all require changes to OrderService.
Three related ideas
DIP changes the direction. DI supplies the object.
Dependency inversion principle
High-level policy such as OrderService depends on an abstraction such as NotificationSender, not directly on email or SMS.
Dependency injection
The dependency is supplied from outside, usually through the constructor. The service does not construct the object it depends on.
Inversion of control
Object creation and wiring move out of business classes. Plain Java can do this manually; Spring later acts as the container that performs the wiring.
public interface NotificationSender {
void send(String destination, String message);
}
public class OrderService {
private final NotificationSender notificationSender;
public OrderService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
}The dependency arrow now points from the high-level service to a stable capability—NotificationSender. Email and SMS also depend on that same contract by implementing it. The interface belongs near the policy that needs the capability; it is not merely a shared folder for implementation classes.
A tempting half-fix
Declaring the field as an interface is not enough.
This code looks more abstract because the field type is an interface, but the service still names and creates the concrete email class:
public class OrderService {
private final NotificationSender notificationSender =
new EmailNotificationSender();
// ...
}The compile-time dependency on EmailNotificationSender remains because of the constructor call. The service cannot receive SMS or a test fake. Dependency injection is missing: nobody outside the service gets a chance to supply the dependency.
public class OrderService {
private final NotificationSender notificationSender;
public OrderService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
}Now construction cannot finish without a sender. That is useful: the class cannot exist in an invalid, partially initialised state, and every caller can see the requirement in the constructor signature.
Why constructor injection
The dependency becomes required, visible and stable.
Required
The constructor must receive a sender before the service can be used.
Visible
A caller or test can discover dependencies without reading private fields.
Stable
A final field prevents replacement after construction.
Setter injection can be useful for a genuinely optional, reconfigurable collaborator. Field injection hides the requirement from normal Java callers and makes isolated tests awkward. For required services, constructor injection is the default we will use throughout this series.
NotificationSender email = new EmailNotificationSender();
OrderService emailOrders = new OrderService(email);
NotificationSender sms = new SmsNotificationSender();
OrderService smsOrders = new OrderService(sms);An object graph is simply the connected set of runtime objects. The two graphs above contain the same service type but different concrete dependencies. No condition or notification-specific branch was added to the service.
Where new belongs
The outer edge assembles the object graph.
In a plain Java program, Application can be the composition root: the one place that chooses concrete implementations and connects them.
public class Application {
public static void main(String[] args) {
NotificationSender sender = new EmailNotificationSender();
OrderService orderService = new OrderService(sender);
orderService.placeOrder("ORD-101", "customer@example.com");
}
}Creating the concrete object here does not make OrderService tightly coupled. The composition root is supposed to know the implementations. If selection later becomes complex, configuration or a factory can centralise it without changing the service.
The composition root is intentionally coupled to concrete implementations. Its job is to know what the rest of the application should not need to know. A small command-line program may use main; a Spring application uses configuration and the container.
public class NotificationSenderFactory {
public NotificationSender create(String channel) {
return switch (channel) {
case "email" -> new EmailNotificationSender();
case "sms" -> new SmsNotificationSender();
default -> throw new IllegalArgumentException(
"Unknown channel: " + channel
);
};
}
}
public class Application {
public static void main(String[] args) {
String channel = System.getenv()
.getOrDefault("NOTIFICATION_CHANNEL", "email");
NotificationSender sender =
new NotificationSenderFactory().create(channel);
OrderService orderService = new OrderService(sender);
}
}The factory is not automatically superior to direct construction. It becomes useful when selection has real logic. The important boundary remains unchanged:OrderService receives a ready-to-use NotificationSender.
The testing payoff
A fake dependency can observe behaviour without sending anything.
Because the constructor accepts the contract, a unit test can provide a tiny implementation that records the call:
public class RecordingNotificationSender
implements NotificationSender {
private String destination;
private String message;
@Override
public void send(String destination, String message) {
this.destination = destination;
this.message = message;
}
public String destination() {
return destination;
}
public String message() {
return message;
}
}@Test
void placesOrderAndSendsConfirmation() {
RecordingNotificationSender sender =
new RecordingNotificationSender();
OrderService service = new OrderService(sender);
service.placeOrder("ORD-101", "customer@example.com");
assertEquals(
"customer@example.com",
sender.destination()
);
assertEquals(
"Order ORD-101 placed successfully",
sender.message()
);
}This test does not start Spring and does not contact an email system. That is a direct benefit of the design boundary. Spring will automate production wiring, but ordinary Java construction remains available for focused unit tests.
Where ApplicationContext fits
Spring becomes the composition root, not a global object lookup.
A Spring ApplicationContext creates and connects managed objects. Controllers and services normally receive dependencies through constructors; they do not call applicationContext.getBean(...). Doing that makes dependencies hidden and turns the context into a service locator.
Direct context access is mostly for bootstrap code, framework integration, specialised tests, legacy boundaries or genuinely dynamic plugin lookup. Also, not every object belongs in the context: DTOs, JPA entities, value objects and exceptions are commonly created with new.
Normal application code
private final NotificationSender sender;
public OrderService(
NotificationSender sender
) {
this.sender = sender;
}The dependency is explicit and easy to replace in a test.
Service-locator style
NotificationSender sender =
context.getBean(
NotificationSender.class
);The dependency is hidden inside a method and the class now knows Spring.
Framework bootstrap code may legitimately hold the context. A plugin host may need runtime lookup when the implementation is not knowable at construction time. Those are specialised boundaries. A regular controller-to-service flow should use injection.
Not every new is a design problem
Manage long-lived collaborators; construct ordinary values normally.
OrderId orderId = new OrderId("ORD-101");
Order order = new Order(orderId, customerId);
List<OrderLine> lines = new ArrayList<>();
IllegalArgumentException error =
new IllegalArgumentException("orderId is required");These objects represent data, domain state or a local collection. They are not application-wide collaborators with configuration and lifecycle needs. In contrast, an email client, repository, clock or service is often shared, configured and injected.
Practice checkpoint
Write it before reading the solution.
Refactor a tightly coupled OrderService so the same class can send order confirmations through either email or SMS.
- Create the contract and two implementations.
- Use constructor injection.
- Create two differently wired services in
Application.main. - Do not use Spring yet.
Hints, in increasing order
- Put the shared
sendoperation in an interface. - Make the
OrderServiceconstructor accept that interface. - Let
Application.mainchoose and construct email and SMS implementations.
Working solution
// NotificationSender.java
public interface NotificationSender {
void send(String destination, String message);
}
// EmailNotificationSender.java
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String destination, String message) {
System.out.println("EMAIL to " + destination + ": " + message);
}
}
// SmsNotificationSender.java
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String destination, String message) {
System.out.println("SMS to " + destination + ": " + message);
}
}
// OrderService.java
public class OrderService {
private final NotificationSender notificationSender;
public OrderService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
public void placeOrder(String orderId, String destination) {
notificationSender.send(
destination,
"Order " + orderId + " placed successfully"
);
}
}
// Application.java
public class Application {
public static void main(String[] args) {
NotificationSender email = new EmailNotificationSender();
NotificationSender sms = new SmsNotificationSender();
new OrderService(email).placeOrder(
"ORD-101",
"customer@example.com"
);
new OrderService(sms).placeOrder(
"ORD-102",
"+919876543210"
);
}
}The important result is not merely the interface. The service no longer creates a concrete sender, and the constructor makes its dependency visible.
Elapsed time · hint level (H0 means no hint; H1–Hn is the last hint used) · conceptual errors · mechanical errors · explanation confidence from 0–2.
Foundation check
Can you explain this without saying “Spring does it”?
Constructor injection is a Java design technique. Spring automates the object creation and wiring, but the loose coupling comes from the dependency boundary you designed.