Dependency Inversion Principle (DIP) in Java with Easy Examples

 


1. What Is the Dependency Inversion Principle?

Hey developers! Today, let’s learn one of the important SOLID principles in Java: the Dependency Inversion Principle (DIP).

Don’t worry about the complicated name. We will understand it using a very simple real-world example.

Imagine you have a MacBook.

You can connect different types of keyboards and mice to it:

  • Wired keyboard
  • Bluetooth keyboard
  • Wired mouse
  • Bluetooth mouse

Now imagine your MacBook is designed to work only with a wired keyboard and a wired mouse.

What happens if you want to use a Bluetooth keyboard?

You need to change the MacBook’s code.

That is not a good design.

Instead, the MacBook should work with any keyboard or mouse that follows a common contract.

This is the basic idea behind the Dependency Inversion Principle.

The Dependency Inversion Principle states:

High-level modules should not depend directly on low-level modules. Both should depend on abstractions.

In simple words, depend on interfaces rather than concrete classes.

Let’s understand this with Java code.

2. What Are High-Level and Low-Level Modules?

Before writing the code, let’s understand two important terms.

High-level module: A class that contains the main business logic or functionality.

In our example, MacBook is the high-level module because it uses a keyboard and mouse.

Low-level module: A class that provides a specific implementation.

In our example, WiredKeyboard, BluetoothKeyboard, WiredMouse, and BluetoothMouse are low-level modules.

Abstraction: An interface that defines what an object should do without specifying its concrete implementation.

For example, Keyboard and Mouse are abstractions.

Our goal is to make the MacBook depend on these abstractions instead of directly depending on particular keyboard and mouse implementations.

3. Example That Violates DIP

Let’s first write code that violates the Dependency Inversion Principle.

Imagine we have a WiredKeyboard class:

public class WiredKeyboard {

    public void getSpecifications() {
        System.out.println("Wired Keyboard");
    }
}

And a WiredMouse class:

public class WiredMouse {

    public void getSpecifications() {
        System.out.println("Wired Mouse");
    }
}

Now let’s create the MacBook class.

public class MacBook {

    private final WiredKeyboard keyboard;
    private final WiredMouse mouse;

    public MacBook(WiredKeyboard keyboard,
                   WiredMouse mouse) {

        this.keyboard = keyboard;
        this.mouse = mouse;
    }

    public void showDevices() {
        keyboard.getSpecifications();
        mouse.getSpecifications();
    }
}

Here, the MacBook directly depends on WiredKeyboard and WiredMouse.

Let’s create the objects:

public class Main {

    public static void main(String[] args) {

        WiredKeyboard keyboard = new WiredKeyboard();
        WiredMouse mouse = new WiredMouse();

        MacBook macBook = new MacBook(keyboard, mouse);

        macBook.showDevices();
    }
}

Output:

Wired Keyboard
Wired Mouse

The code works perfectly for wired devices.

But what happens when we want to use Bluetooth devices?

We cannot pass a BluetoothKeyboard or BluetoothMouse to this constructor because the constructor requires the specific wired classes.

We would need to modify the MacBook class to support the new device types.

Problems with this code

  1. The MacBook is tightly coupled to the wired keyboard and mouse.
  2. We cannot easily replace wired devices with Bluetooth devices.
  3. Adding new device types may require modifying the MacBook class.
  4. Testing the MacBook independently becomes more difficult.

This is an example of violating DIP.

4. How to Follow DIP Using Interfaces

Now let’s improve our design.

Instead of making the MacBook depend on specific classes, we will make it depend on interfaces.

Step 1: Create the Keyboard interface

public interface Keyboard {

    void getSpecifications();
}

Step 2: Create the Mouse interface

public interface Mouse {

    void getSpecifications();
}

These interfaces define a common contract.

Any keyboard must implement Keyboard, and any mouse must implement Mouse.

Step 3: Create the wired keyboard

public class WiredKeyboard implements Keyboard {

    @Override
    public void getSpecifications() {
        System.out.println("Wired Keyboard");
    }
}

Step 4: Create the Bluetooth keyboard

public class BluetoothKeyboard implements Keyboard {

    @Override
    public void getSpecifications() {
        System.out.println("Bluetooth Keyboard");
    }
}

Step 5: Create the wired mouse

public class WiredMouse implements Mouse {

    @Override
    public void getSpecifications() {
        System.out.println("Wired Mouse");
    }
}

Step 6: Create the Bluetooth mouse

public class BluetoothMouse implements Mouse {

    @Override
    public void getSpecifications() {
        System.out.println("Bluetooth Mouse");
    }
}

Notice that all these classes implement their respective interfaces.

Now we can change the MacBook class.

Step 7: Modify the MacBook class

public class MacBook {

    private final Keyboard keyboard;
    private final Mouse mouse;

    public MacBook(Mouse mouse, Keyboard keyboard) {
        this.keyboard = keyboard;
        this.mouse = mouse;
    }

    public void showDevices() {
        keyboard.getSpecifications();
        mouse.getSpecifications();
    }
}

Look carefully at these declarations:

private final Keyboard keyboard;
private final Mouse mouse;

We are no longer using WiredKeyboard or WiredMouse as the field types.

We are using interfaces.

This means the MacBook can work with any keyboard or mouse that implements the appropriate interface.

5. Test the Solution with Different Devices

Let’s create a MacBook with wired devices.

public class Main {

    public static void main(String[] args) {

        Keyboard wiredKeyboard = new WiredKeyboard();
        Mouse wiredMouse = new WiredMouse();

        MacBook wiredMacBook =
                new MacBook(wiredMouse, wiredKeyboard);

        wiredMacBook.showDevices();

        System.out.println("----------");

        Keyboard bluetoothKeyboard =
                new BluetoothKeyboard();

        Mouse bluetoothMouse =
                new BluetoothMouse();

        MacBook bluetoothMacBook =
                new MacBook(bluetoothMouse, bluetoothKeyboard);

        bluetoothMacBook.showDevices();
    }
}

Output:

Wired Keyboard
Wired Mouse
----------
Bluetooth Keyboard
Bluetooth Mouse

Great! We can now use different device combinations without modifying the MacBook class.

We only need to create a new implementation of the appropriate interface and provide it to the MacBook.

That is the main benefit of following DIP.

6. How Does Dependency Injection Help?

You may have noticed this constructor:

public MacBook(Mouse mouse, Keyboard keyboard) {
    this.keyboard = keyboard;
    this.mouse = mouse;
}

The MacBook receives its dependencies from outside instead of creating specific wired devices internally.

This is called constructor dependency injection.

In simple words, rather than creating everything inside a class, we provide the required objects from outside.

For example:

Keyboard keyboard = new BluetoothKeyboard();
Mouse mouse = new BluetoothMouse();

MacBook macBook = new MacBook(mouse, keyboard);

The MacBook does not need to know how a Bluetooth keyboard is created. It only needs an object that follows the Keyboard interface.

Remember, DIP and dependency injection are related but different concepts:

  • DIP is a design principle.
  • Dependency injection is a technique for supplying dependencies to a class.

Dependency injection is one way to implement a design that follows DIP.

7. How Does This Work in Spring Boot?

The same idea is widely used in Spring Boot applications.

Imagine we have a payment application.

We want to support different payment methods, such as card payments and UPI payments.

First, create an interface:

public interface PaymentService {

    void pay(double amount);
}

Now create two implementations.

Card payment:

@Service
public class CardPaymentService
        implements PaymentService {

    @Override
    public void pay(double amount) {
        System.out.println(
            "Card payment: " + amount
        );
    }
}

UPI payment:

@Service
public class UpiPaymentService
        implements PaymentService {

    @Override
    public void pay(double amount) {
        System.out.println(
            "UPI payment: " + amount
        );
    }
}

Now create a high-level service:

@Service
public class OrderService {

    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public void placeOrder(double amount) {
        paymentService.pay(amount);
    }
}

Here, OrderService depends on the PaymentService interface rather than directly depending on CardPaymentService or UpiPaymentService.

Spring can inject an appropriate implementation into the constructor.

Important: Because both payment implementations are annotated with @Service, Spring needs a clear selection strategy. You can use @Qualifier, @Primary, or another explicit configuration to decide which implementation should be injected. Otherwise, Spring will generally report an ambiguity when resolving the dependency.

For example, you can select UPI using a qualifier:

@Service
public class OrderService {

    private final PaymentService paymentService;

    public OrderService(
        @Qualifier("upiPaymentService")
        PaymentService paymentService) {

        this.paymentService = paymentService;
    }

    public void placeOrder(double amount) {
        paymentService.pay(amount);
    }
}

This demonstrates how DIP and dependency injection work together in a real Spring Boot application.

8. Benefits of the Dependency Inversion Principle

Following DIP provides several benefits.

1. Loose coupling

The high-level class does not depend directly on a specific implementation.

2. Easy maintenance

You can introduce new implementations without unnecessarily modifying existing business logic.

3. Better testability

You can provide a mock implementation of an interface when testing a class.

4. Flexibility

The same high-level class can work with different implementations.

5. Easier extension

New keyboard types, mouse types, or payment methods can be introduced by implementing the existing interface.

These benefits make code easier to maintain and extend as an application grows.

9. Dependency Inversion Principle vs Dependency Injection

These two terms are often confused.

Dependency Inversion Principle Dependency Injection
A design principle A technique
Encourages depending on abstractions Supplies dependencies from outside
Helps reduce tight coupling Helps wire objects together
Can be followed without a dependency injection framework Can be performed manually or with a framework such as Spring

For example, MacBook depending on the Keyboard interface follows the DIP approach.

Passing a WiredKeyboard or BluetoothKeyboard through its constructor is dependency injection.

10. Final Summary

Let’s revise the concept in simple words.

When a class depends directly on another concrete class, changing the implementation may require changing the dependent class.

Instead, we can introduce an interface and make both the high-level class and the concrete implementations depend on that abstraction.

In our example:

  • MacBook is the high-level module.
  • WiredKeyboard and BluetoothKeyboard are low-level implementations.
  • Keyboard is the abstraction.
  • Mouse provides a separate abstraction for mouse devices.
  • Constructor injection supplies the required devices to the MacBook.

The same design approach can be applied to payment services, notification services, database access, and many other parts of a Spring Boot application.

Remember this interview line:

The Dependency Inversion Principle states that high-level and low-level modules should depend on abstractions rather than directly depending on each other.

Happy coding! 💻

Leave a Reply