Dependencies & Coupling
In this page, we'll build one small piece of an online shop: placing an order and notifying the customer. We'll start with the simplest code, find its problems, and fix them one by one. By the end, you'll understand the ideas that Spring is built on.
What is a dependency in Java?
A dependency is an object that another object needs to do its job.
When a customer places an order, our shop should send a confirmation message. So the class that places orders needs a class that sends messages:
public class EmailService {public void sendEmail(String to, String message) {System.out.println("Email to " + to + ": " + message);}}
public class OrderService {private EmailService emailService = new EmailService();public void placeOrder(String customer, String product) {System.out.println("Order placed: " + product);emailService.sendEmail(customer, "Your order for " + product + " is confirmed");}}
OrderService can't finish its job without EmailService. So we say:
OrderServicedepends onEmailService.EmailServiceis a dependency ofOrderService.
Not the same as a Maven dependency. In the Maven pages, a dependency was a library (a JAR) your project needs. Here, a dependency is an object your class needs. The idea is the same, "something I need to work", just at a different level:
| Maven dependency | Java (object) dependency | |
|---|---|---|
| What is needed | A library (JAR) | An object |
| Who needs it | Your project | A class |
| Example | Spring Boot, Lombok | EmailService |
| Where it's declared | pom.xml | Inside your Java code |
Our code works. But look closely at this one line:
private EmailService emailService = new EmailService();
This line causes all the problems in the next section.
Tight coupling vs loose coupling
Coupling means how strongly two classes are tied together.
Tight coupling
Our OrderService is tightly coupled to EmailService, because:
- It creates the
EmailServiceobject itself usingnew. - It knows the exact class: email, and only email.
Now imagine some new requirements:
"Customers want SMS instead of email."
We must open OrderService and change its code, even though nothing about placing orders changed.
"We want to test placeOrder()."
Every test run would send a real email, because OrderService always creates a real EmailService. There's no way to swap it for a fake one.
"Some customers want email, others want SMS."
OrderService is stuck with email. It can't use anything else.
Think of a phone with its charger soldered to it. It works, but if the charger breaks or you want a faster one, you have to open up the phone.
Loose coupling
With loose coupling, OrderService doesn't care which notification class it uses, as long as it can send a message. And it doesn't create the object itself; someone gives it one.
That's like a phone with a USB-C port. The phone doesn't care which charger you plug in, as long as it fits the port.
| Tight coupling | Loose coupling | |
|---|---|---|
| Who creates the dependency? | The class itself (new) | Someone outside gives it |
| Depends on | One exact class | A general type |
| Switching email → SMS | Edit OrderService | Plug in a different class |
| Testing | Sends real emails | Plug in a fake class |
To build the "USB-C port" in Java, we need an interface.
Interfaces and multiple implementations
An interface describes what something can do, without saying how it does it.
Here's our "port": anything that can send a notification.
public interface NotificationService {void send(String to, String message);}
Now different classes can provide the how. Each one is called an implementation:
public class EmailNotificationService implements NotificationService {@Overridepublic void send(String to, String message) {System.out.println("Email to " + to + ": " + message);}}
public class SmsNotificationService implements NotificationService {@Overridepublic void send(String to, String message) {System.out.println("SMS to " + to + ": " + message);}}
Both classes are a NotificationService, so a variable of the interface type can hold either one:
NotificationService notifier = new EmailNotificationService();notifier.send("asha@example.com", "Hello"); // sends an emailnotifier = new SmsNotificationService();notifier.send("9876543210", "Hello"); // sends an SMS
The code calling send() doesn't change. Only the object behind it changes.
This is the key: if OrderService talks only to NotificationService, it will work with any implementation. Let's rebuild it that way.
OrderService and NotificationService example
Here's OrderService, now loosely coupled:
public class OrderService {private final NotificationService notificationService;public OrderService(NotificationService notificationService) {this.notificationService = notificationService;}public void placeOrder(String customer, String product) {System.out.println("Order placed: " + product);notificationService.send(customer, "Your order for " + product + " is confirmed");}}
Two important changes:
- The field type is the interface
NotificationService, not a specific class. - There's no
newinside. The object is passed in through the constructor.
Now someone outside decides which implementation to use:
public class Main {public static void main(String[] args) {OrderService emailOrders = new OrderService(new EmailNotificationService());emailOrders.placeOrder("asha@example.com", "Laptop");// SMS: OrderService is unchangedOrderService smsOrders = new OrderService(new SmsNotificationService());smsOrders.placeOrder("9876543210", "Headphones");}}
Output:
1Order placed: Laptop2Email to asha@example.com: Your order for Laptop is confirmed3Order placed: Headphones4SMS to 9876543210: Your order for Headphones is confirmedLet's check the three problems from the Tight coupling section:
Switching to SMS: done in main. OrderService wasn't touched.
Supporting both: each OrderService gets whichever implementation we pass in.
Testing: we can pass in a fake that sends nothing and just remembers the message:
public class FakeNotificationService implements NotificationService {String lastMessage;@Overridepublic void send(String to, String message) {lastMessage = message; // no real email or SMS}}
FakeNotificationService fake = new FakeNotificationService();OrderService orderService = new OrderService(fake);orderService.placeOrder("test@example.com", "Book");// fake.lastMessage is "Your order for Book is confirmed"
Giving a class its dependencies from the outside, instead of letting it create them, has a name: Dependency Injection. Here, we "inject" the notification service through the constructor.
The changes we made follow two well-known design rules. Let's name them.
SOLID principles: SRP and OCP
SOLID is a set of five design principles for writing code that's easy to change. Two of them explain exactly why our new design is better.
SRP: Single Responsibility Principle
A class should have only one reason to change.
In other words, each class should do one job.
Imagine if OrderService also contained all the email-sending code: connecting to the mail server, building the email, handling failures. Then it would have two reasons to change:
- the order rules change (for example, "check stock before ordering"),
- the email setup changes (for example, "switch email providers").
In our design, the jobs are split:
| Class | Its one job | Changes when... |
|---|---|---|
OrderService | Placing orders | Order rules change |
EmailNotificationService | Sending emails | Email setup changes |
SmsNotificationService | Sending SMS | SMS setup changes |
A change to email sending never touches order code, and the other way around.
OCP: Open/Closed Principle
Code should be open for extension, but closed for modification.
In other words, you should be able to add new behavior without editing code that already works.
Say the business now wants WhatsApp notifications. We just add a new class:
public class WhatsAppNotificationService implements NotificationService {@Overridepublic void send(String to, String message) {System.out.println("WhatsApp to " + to + ": " + message);}}
OrderService orderService = new OrderService(new WhatsAppNotificationService());
- Open for extension: we added WhatsApp support.
- Closed for modification:
OrderServicedidn't change at all. Code that was already tested stays untouched.
With the tightly coupled version, adding WhatsApp would have meant editing OrderService again.
Putting it together
Everything on this page connects:
1Dependency → OrderService needs something to send notifications2Tight coupling → creating it with "new" makes changes and testing hard3Interface → defines "what" (send), so many classes can provide "how"4Constructor → the dependency is given from outside (Dependency Injection)5SRP → each class has one job6OCP → new notification types without editing OrderServiceThere's one thing left. Look at main again: we still create every object and connect them by hand. In a small example, that's fine. In a real application with hundreds of classes, each needing others, writing all that connecting code becomes a big job of its own.
That's exactly the job Spring does for you, which is where we go next.