Software Engineer's Blog

Dependency Inversion Principle in Java: Depend on Abstractions, Not Concrete Classes

Dependency Inversion Principle in Java: Depend on Abstractions, Not Concrete Classes

The Dependency Inversion Principle, also known as DIP, is the fifth principle in SOLID.

The definition is usually written like this:

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

Another common version is:

Depend on abstractions, not concrete implementations.

At first, this can sound a little academic.

What is a high-level module?
What is a low-level module?
What does “abstraction” really mean?

A simpler way to understand DIP is this:

Business logic should not be tightly coupled to specific implementation details.

This principle is especially important in large codebases.

When business logic directly depends on concrete classes, the code becomes harder to change, harder to test, and harder to reuse.

Let’s look at a simple Java example.

A Bad Example: OrderService Directly Depends on EmailSender

Imagine we are building an order system.

When an order is completed, we want to send a notification to the customer.

At first, we might create an EmailSender class.

public class EmailSender {

    public void send(String message) {
        System.out.println("Send email: " + message);
    }
}

Then we use it directly inside OrderService.

public class OrderService {

    private final EmailSender emailSender = new EmailSender();

    public void completeOrder() {
        System.out.println("Order completed");

        emailSender.send("Your order has been completed.");
    }
}

This code works.

But the design has a problem.

OrderService is high-level business logic.
EmailSender is a low-level implementation detail.

The high-level module directly depends on the low-level module.

OrderService → EmailSender

This means OrderService is tightly coupled to email.

Why Is This a Problem?

At first, email is enough.

But later, the business may ask for SMS notifications.

So we create an SmsSender.

public class SmsSender {

    public void send(String message) {
        System.out.println("Send SMS: " + message);
    }
}

Now we need to modify OrderService.

public class OrderService {

    private final SmsSender smsSender = new SmsSender();

    public void completeOrder() {
        System.out.println("Order completed");

        smsSender.send("Your order has been completed.");
    }
}

The problem is that the order business logic changed because the notification method changed.

That is not ideal.

Completing an order and sending an email are different concerns.

If we later add Slack, push notification, or WhatsApp, we may keep changing OrderService.

Email notification changes → modify OrderService
SMS notification changes   → modify OrderService
Slack notification changes → modify OrderService
Push notification changes  → modify OrderService

This makes the system fragile.

The core business logic should not care too much about how the notification is actually sent.

Applying DIP

To apply DIP, we introduce an abstraction.

In Java, this is usually an interface.

public interface NotificationSender {

    void send(String message);
}

Now EmailSender implements this interface.

public class EmailSender implements NotificationSender {

    @Override
    public void send(String message) {
        System.out.println("Send email: " + message);
    }
}

SmsSender can also implement the same interface.

public class SmsSender implements NotificationSender {

    @Override
    public void send(String message) {
        System.out.println("Send SMS: " + message);
    }
}

Now we change OrderService.

Instead of depending on EmailSender, it depends on NotificationSender.

public class OrderService {

    private final NotificationSender notificationSender;

    public OrderService(NotificationSender notificationSender) {
        this.notificationSender = notificationSender;
    }

    public void completeOrder() {
        System.out.println("Order completed");

        notificationSender.send("Your order has been completed.");
    }
}

Now the dependency direction is different.

Before:

OrderService → EmailSender

After:

OrderService → NotificationSender
EmailSender  → NotificationSender
SmsSender    → NotificationSender

OrderService no longer cares whether the notification is sent by email, SMS, or something else.

It only depends on the abstraction.

That is DIP.

Using the Code

If we want to send email:

public class Main {

    public static void main(String[] args) {
        NotificationSender notificationSender = new EmailSender();

        OrderService orderService = new OrderService(notificationSender);
        orderService.completeOrder();
    }
}

Output:

Order completed
Send email: Your order has been completed.

If we want to send SMS:

public class Main {

    public static void main(String[] args) {
        NotificationSender notificationSender = new SmsSender();

        OrderService orderService = new OrderService(notificationSender);
        orderService.completeOrder();
    }
}

Output:

Order completed
Send SMS: Your order has been completed.

The important point is this:

OrderService did not change.

We changed the implementation from email to SMS, but the business logic stayed the same.

That is the practical value of DIP.

What Are High-Level and Low-Level Modules?

This part can be confusing.

A high-level module usually contains business rules or important application logic.

Examples:

OrderService
PaymentService
InvoiceService
UserRegistrationService
ShipmentService

A low-level module usually handles technical details.

Examples:

EmailSender
SmsSender
JpaOrderRepository
S3FileUploader
StripePaymentClient
SlackNotificationClient

High-level modules answer business questions:

What should happen when an order is completed?
How should we calculate the final price?
When should we create an invoice?

Low-level modules answer technical questions:

How do we send an email?
How do we save data to the database?
How do we call an external API?
How do we upload a file to cloud storage?

DIP says high-level business logic should not directly depend on those concrete technical details.

Instead, both should depend on abstractions.

A More Practical Backend Example: Payment Gateway

Let’s look at another common example.

Suppose we have a payment service that directly uses Stripe.

public class StripePaymentClient {

    public void charge(int amount) {
        System.out.println("Charge by Stripe: " + amount);
    }
}

Then PaymentService uses it directly.

public class PaymentService {

    private final StripePaymentClient stripePaymentClient = new StripePaymentClient();

    public void pay(int amount) {
        stripePaymentClient.charge(amount);
    }
}

This works, but now PaymentService is tightly coupled to Stripe.

If we later switch to PayPal, Adyen, or another payment provider, we need to modify PaymentService.

A better design is to introduce an interface.

public interface PaymentGateway {

    void charge(int amount);
}

Now Stripe can implement it.

public class StripePaymentGateway implements PaymentGateway {

    @Override
    public void charge(int amount) {
        System.out.println("Charge by Stripe: " + amount);
    }
}

PayPal can also implement it.

public class PaypalPaymentGateway implements PaymentGateway {

    @Override
    public void charge(int amount) {
        System.out.println("Charge by PayPal: " + amount);
    }
}

Now PaymentService depends on PaymentGateway.

public class PaymentService {

    private final PaymentGateway paymentGateway;

    public PaymentService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public void pay(int amount) {
        paymentGateway.charge(amount);
    }
}

Now the service is more flexible.

PaymentService → PaymentGateway
StripePaymentGateway → PaymentGateway
PaypalPaymentGateway → PaymentGateway

The business logic is protected from provider-specific details.

Spring Boot Style Example

DIP appears naturally in Spring Boot when we use constructor injection.

First, define an abstraction.

public interface NotificationSender {

    void send(String message);
}

Create an implementation.

@Component
public class EmailNotificationSender implements NotificationSender {

    @Override
    public void send(String message) {
        System.out.println("Send email: " + message);
    }
}

Then inject the abstraction into the service.

@Service
public class OrderService {

    private final NotificationSender notificationSender;

    public OrderService(NotificationSender notificationSender) {
        this.notificationSender = notificationSender;
    }

    public void completeOrder() {
        System.out.println("Order completed");

        notificationSender.send("Your order has been completed.");
    }
}

This is a common Spring Boot pattern.

The service depends on the interface, and Spring provides the actual implementation.

The service does not call:

new EmailNotificationSender()

That is important.

When a service creates its own dependency with new, it becomes tightly coupled to that concrete class.

Constructor injection helps avoid that.

What If There Are Multiple Implementations?

Sometimes we have multiple implementations of the same interface.

For example:

@Component
public class EmailNotificationSender implements NotificationSender {

    @Override
    public void send(String message) {
        System.out.println("Send email: " + message);
    }
}
@Component
public class SmsNotificationSender implements NotificationSender {

    @Override
    public void send(String message) {
        System.out.println("Send SMS: " + message);
    }
}

If Spring sees two beans of the same interface, it may not know which one to inject.

In that case, we can use @Qualifier.

@Service
public class OrderService {

    private final NotificationSender notificationSender;

    public OrderService(
            @Qualifier("emailNotificationSender") NotificationSender notificationSender
    ) {
        this.notificationSender = notificationSender;
    }

    public void completeOrder() {
        System.out.println("Order completed");

        notificationSender.send("Your order has been completed.");
    }
}

Now Spring knows which implementation to use.

This still follows DIP because OrderService depends on the interface, not directly on the concrete class type.

DIP Makes Testing Easier

One of the biggest benefits of DIP is testing.

Let’s say we want to test OrderService.

If OrderService directly creates EmailSender, testing becomes harder.

public class OrderService {

    private final EmailSender emailSender = new EmailSender();

    public void completeOrder() {
        System.out.println("Order completed");
        emailSender.send("Your order has been completed.");
    }
}

In this design, we cannot easily replace EmailSender with a fake implementation.

But with DIP, we can pass a fake sender.

public class FakeNotificationSender implements NotificationSender {

    private String sentMessage;

    @Override
    public void send(String message) {
        this.sentMessage = message;
    }

    public String getSentMessage() {
        return sentMessage;
    }
}

Now we can test OrderService without sending a real email.

public class OrderServiceTest {

    public static void main(String[] args) {
        FakeNotificationSender fakeSender = new FakeNotificationSender();

        OrderService orderService = new OrderService(fakeSender);
        orderService.completeOrder();

        System.out.println(fakeSender.getSentMessage());
    }
}

Output:

Your order has been completed.

In real projects, we usually use JUnit and Mockito.

For example:

class OrderServiceTest {

    @Test
    void completeOrder_sendsNotification() {
        NotificationSender notificationSender = Mockito.mock(NotificationSender.class);
        OrderService orderService = new OrderService(notificationSender);

        orderService.completeOrder();

        Mockito.verify(notificationSender)
                .send("Your order has been completed.");
    }
}

This is much easier because OrderService depends on an abstraction.

We can replace the real implementation with a mock.

That is one of the reasons DIP is so important in large applications.

DIP and Dependency Injection

DIP and Dependency Injection are related, but they are not exactly the same.

DIP is a design principle.

It says:

Depend on abstractions, not concrete implementations.

Dependency Injection is a technique.

It means:

Provide dependencies from the outside instead of creating them inside the class.

For example, this does not use dependency injection:

public class OrderService {

    private final EmailSender emailSender = new EmailSender();
}

This uses dependency injection:

public class OrderService {

    private final NotificationSender notificationSender;

    public OrderService(NotificationSender notificationSender) {
        this.notificationSender = notificationSender;
    }
}

In Spring Boot, dependency injection is usually handled by the Spring container.

So DIP is the principle.
Dependency Injection is one common way to apply it.

DIP and OCP

DIP is closely related to OCP.

OCP says code should be open for extension but closed for modification.

DIP helps make that possible.

For example, if OrderService depends directly on EmailSender, adding SMS requires modifying OrderService.

But if OrderService depends on NotificationSender, we can add a new implementation.

public class SmsNotificationSender implements NotificationSender {

    @Override
    public void send(String message) {
        System.out.println("Send SMS: " + message);
    }
}

The existing business logic does not need to change.

So DIP supports OCP.

By depending on abstractions, we make it easier to extend the system with new implementations.

DIP and Large Codebases

In small projects, directly using concrete classes may not feel like a big problem.

But in large codebases, tight coupling becomes expensive.

For example, imagine many services directly depend on StripePaymentClient.

OrderService → StripePaymentClient
SubscriptionService → StripePaymentClient
InvoiceService → StripePaymentClient
RefundService → StripePaymentClient

If the Stripe integration changes, many services may be affected.

A better design is:

OrderService → PaymentGateway
SubscriptionService → PaymentGateway
InvoiceService → PaymentGateway
RefundService → PaymentGateway

StripePaymentGateway → PaymentGateway

Now the business services depend on the stable abstraction.

The Stripe-specific code is isolated behind the implementation.

This makes the system easier to change.

That is why DIP is often the foundation of maintainability in large codebases.

Common Signs of DIP Violations

In real projects, DIP violations often appear like this:

private final EmailSender emailSender = new EmailSender();

or:

private final StripePaymentClient stripePaymentClient = new StripePaymentClient();

or service code directly depending on infrastructure details:

public class OrderService {

    private final JpaOrderRepository repository;
    private final AwsS3Uploader uploader;
    private final SendGridEmailClient emailClient;
}

This is not always wrong.

But if the business logic becomes tightly connected to technical details, the code becomes harder to test and change.

Some warning signs are:

The class creates its own dependencies with new.
Business logic directly calls external API clients.
Testing requires real infrastructure.
Changing one implementation affects many services.
Mocking is difficult.
A service knows too much about low-level details.

When I see these signs, I usually ask:

Should this service depend on an interface instead?

Does Every Class Need an Interface?

No.

This is an important point.

DIP does not mean every class must have an interface.

Creating interfaces everywhere can make the code unnecessarily complex.

For example, this may be overengineering:

public interface UserNameFormatter {
    String format(String firstName, String lastName);
}

If there is only one simple implementation and no expected variation, an interface may not be needed.

DIP is most useful when:

There are multiple implementations.
The implementation may change later.
The dependency talks to external systems.
The code is hard to test without mocking.
The dependency is a technical detail behind business logic.

Examples where an interface often makes sense:

PaymentGateway
NotificationSender
FileStorage
MessagePublisher
ExternalApiClient
ReportExporter
DiscountPolicy

The goal is not to create more files.

The goal is to reduce harmful coupling.

Before and After

Before DIP:

public class OrderService {

    private final EmailSender emailSender = new EmailSender();

    public void completeOrder() {
        System.out.println("Order completed");

        emailSender.send("Your order has been completed.");
    }
}

Problem:

OrderService directly depends on EmailSender.

After DIP:

public interface NotificationSender {

    void send(String message);
}
public class EmailSender implements NotificationSender {

    @Override
    public void send(String message) {
        System.out.println("Send email: " + message);
    }
}
public class OrderService {

    private final NotificationSender notificationSender;

    public OrderService(NotificationSender notificationSender) {
        this.notificationSender = notificationSender;
    }

    public void completeOrder() {
        System.out.println("Order completed");

        notificationSender.send("Your order has been completed.");
    }
}

Better design:

OrderService depends on NotificationSender abstraction.
EmailSender is only one possible implementation.

A Practical Way to Think About DIP

When I think about DIP, I usually ask these questions:

Is my business logic depending directly on a concrete class?
Is this dependency an implementation detail?
Would testing be easier if this dependency were an interface?
What happens if this implementation changes?
Can I replace this dependency without changing the business logic?

These questions are more useful than memorizing the formal definition.

DIP is really about protecting business logic from unstable technical details.

Summary

The Dependency Inversion Principle says that high-level modules should not depend on low-level modules. Both should depend on abstractions.

In simpler words:

Business logic should depend on interfaces, not concrete implementation classes.

In the order notification example, OrderService should not directly create and depend on EmailSender.

Instead, it should depend on a NotificationSender interface. Then EmailSender, SmsSender, or any other notification method can implement that interface.

This makes the code easier to change, easier to test, and easier to extend.

DIP is especially important in large codebases because it reduces tight coupling between business logic and technical details.

The key question is:

Can I replace this implementation without changing the business logic?

If the answer is yes, the design is likely moving in the right direction.