On this page

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:

OrderService depends on EmailService. EmailService is a dependency of OrderService.

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 dependencyJava (object) dependency
What is neededA library (JAR)An object
Who needs itYour projectA class
ExampleSpring Boot, LombokEmailService
Where it's declaredpom.xmlInside 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:

  1. It creates the EmailService object itself using new.
  2. 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 couplingLoose coupling
Who creates the dependency?The class itself (new)Someone outside gives it
Depends onOne exact classA general type
Switching email → SMSEdit OrderServicePlug in a different class
TestingSends real emailsPlug 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 {
@Override
public void send(String to, String message) {
System.out.println("Email to " + to + ": " + message);
}
}
public class SmsNotificationService implements NotificationService {
@Override
public 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 email
notifier = 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:

  1. The field type is the interface NotificationService, not a specific class.
  2. There's no new inside. 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) {
// Email
OrderService emailOrders = new OrderService(new EmailNotificationService());
emailOrders.placeOrder("asha@example.com", "Laptop");
// SMS: OrderService is unchanged
OrderService smsOrders = new OrderService(new SmsNotificationService());
smsOrders.placeOrder("9876543210", "Headphones");
}
}

Output:

text
1Order placed: Laptop
2Email to asha@example.com: Your order for Laptop is confirmed
3Order placed: Headphones
4SMS to 9876543210: Your order for Headphones is confirmed

Let'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;
@Override
public 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:

ClassIts one jobChanges when...
OrderServicePlacing ordersOrder rules change
EmailNotificationServiceSending emailsEmail setup changes
SmsNotificationServiceSending SMSSMS 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 {
@Override
public 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: OrderService didn'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:

text
1Dependency → OrderService needs something to send notifications
2Tight coupling → creating it with "new" makes changes and testing hard
3Interface → defines "what" (send), so many classes can provide "how"
4Constructor → the dependency is given from outside (Dependency Injection)
5SRP → each class has one job
6OCP → new notification types without editing OrderService

There'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.

Next TopicDependency Injection (DI)