DEV Community

Cover image for Dependency Inversion Principle (DIP): Why Should High Level Module Depend on Details?
Ashay Tiwari
Ashay Tiwari

Posted on

Dependency Inversion Principle (DIP): Why Should High Level Module Depend on Details?

DIP

We've finally reached the fifth and last principle of SOLID:

Dependency Inversion Principle (DIP).

So far, we've talked about:

  • SRP — Keep responsibilities focused.
  • OCP — Extend behaviour without constantly modifying existing code.
  • LSP — Subtypes should be genuine substitutes for their parent types.
  • ISP — Don't force clients to depend on things they don't need.

DIP brings many of these ideas together.

Its formal definition says:

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

And the second part says:

Abstractions should not depend on details. Details should depend on abstractions.

That's technically correct.

But it's not particularly easy to understand.

So, as always, let's start with a problem.


The Problem

Imagine we're building an e-commerce application.

When an order is placed, we need to send a confirmation email.

A simple implementation might look like this:

class EmailService {
  send(to: string, message: string) {
    console.log(`Sending email to ${to}`);
  }
}

class OrderService {
  private emailService = new EmailService();

  createOrder() {
    // Create order...

    this.emailService.send(
      "customer@example.com",
      "Your order has been placed."
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

At first, this looks perfectly reasonable.

OrderService needs to send an email.

So it creates an EmailService.

What's wrong with that?


The Hidden Dependency

Our OrderService isn't really saying:

I need something that can send notifications.

It's saying:

I specifically need this particular EmailService.

That's an important difference.

Today, we're sending emails.

Tomorrow, the business might say:

"We also want to send a push notification."

Or:

"For some customers, send an SMS instead."

Or:

"We're moving to a different email provider."

Now OrderService is affected by all of those decisions.

Why?

Because the high-level business logic is directly connected to a low-level implementation.


What Is High-Level and Low-Level?

This terminology can sound confusing, so let's simplify it.

High-level code

This represents business decisions.

For example:

OrderService
PaymentService
CheckoutService
Enter fullscreen mode Exit fullscreen mode

These answer questions like:

What should the application do?

Low-level code

This represents implementation details.

For example:

EmailService
StripePaymentService
MySQLRepository
S3Storage
Enter fullscreen mode Exit fullscreen mode

These answer questions like:

How exactly should we do it?

The problem occurs when business logic becomes tightly coupled to implementation details.


Let's Look at a Payment Example

Suppose our checkout service directly uses Stripe.

class CheckoutService {
  private paymentService = new StripePaymentService();

  checkout(amount: number) {
    this.paymentService.charge(amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

Everything works.

Until the business says:

We're moving from Stripe to Razorpay.

Now we need to change CheckoutService.

But why should the checkout logic care which payment provider we're using?

The business requirement is:

Process the payment.

The implementation detail is:

Use Stripe.

Those are two different concerns.


The Idea Behind Dependency Inversion

Instead of making the high-level module depend directly on the implementation, we introduce an abstraction.

interface PaymentProcessor {
  charge(amount: number): void;
}
Enter fullscreen mode Exit fullscreen mode

Now Stripe can implement that abstraction.

class StripePaymentProcessor implements PaymentProcessor {
  charge(amount: number) {
    console.log("Charging through Stripe...");
  }
}
Enter fullscreen mode Exit fullscreen mode

And Razorpay can implement the same abstraction.

class RazorpayPaymentProcessor implements PaymentProcessor {
  charge(amount: number) {
    console.log("Charging through Razorpay...");
  }
}
Enter fullscreen mode Exit fullscreen mode

Our CheckoutService no longer needs to know about either provider.

class CheckoutService {
  constructor(
    private paymentProcessor: PaymentProcessor
  ) {}

  checkout(amount: number) {
    this.paymentProcessor.charge(amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the dependency points toward the abstraction.


What Changed?

Before:

CheckoutService
      ↓
StripePaymentProcessor
Enter fullscreen mode Exit fullscreen mode

After:

CheckoutService
      ↓
PaymentProcessor
      ↑
StripePaymentProcessor
Enter fullscreen mode Exit fullscreen mode

The important part isn't the arrow diagram.

It's the relationship.

CheckoutService no longer cares about Stripe.

It only cares about a capability:

Give me something that can process a payment.

The implementation can change without changing the business logic.


This Is Where Dependency Injection Comes In

You might have noticed something in our example.

We're no longer creating the payment processor inside CheckoutService.

We're receiving it from outside.

const paymentProcessor = new StripePaymentProcessor();

const checkoutService = new CheckoutService(
  paymentProcessor
);
Enter fullscreen mode Exit fullscreen mode

This is Dependency Injection.

Instead of a class creating its own dependencies, those dependencies are provided to it.

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

DIP is a design principle.

Dependency Injection is one technique we can use to implement that principle.

We'll explore Dependency Injection in much more detail in the next article.


Why Is This Useful?

This design gives us several benefits.

Easier to Change

Switching payment providers doesn't require changing CheckoutService.

Easier to Test

We can provide a fake payment processor.

class FakePaymentProcessor implements PaymentProcessor {
  charge(amount: number) {
    console.log("Fake payment");
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can test checkout logic without making a real payment.

Less Coupling

Business logic isn't tied to a specific technology.

Better Extensibility

Adding another implementation doesn't require modifying the high-level module.


DIP Isn't About Interfaces Everywhere

There's another common misunderstanding.

DIP doesn't mean:

Create an interface for every class.

That would quickly turn a simple application into an unnecessarily complicated one.

The real question is:

Which dependencies represent decisions or details that are likely to vary?

Those are the places where abstractions become valuable.

If something is simple, stable, and unlikely to change, introducing another abstraction may only add complexity.


The Bigger Picture

Now look at what we've covered across SOLID.

SRP tells us:

Keep responsibilities focused.

OCP tells us:

The system should be open for extension but closed for modification.

LSP tells us:

Child Entities preserve the behaviour clients expect.

ISP tells us:

Keep interfaces focused.

DIP tells us:

Don't tightly couple business logic to implementation details.

These aren't isolated rules.

They work together.

And together, they help us build scalable and maintainable software.


The Key Takeaway

Dependency Inversion isn't about avoiding dependencies.

Software will always have dependencies.

The goal is to control which direction those dependencies point.

Your business logic shouldn't have to know whether data comes from:

  • PostgreSQL
  • MongoDB
  • REST API
  • GraphQL

Or whether a payment is processed through:

  • Stripe
  • Razorpay
  • PayPal

Those are implementation details.

The business logic should depend on abstractions that represent what it actually needs.


What's Next?

We've now completed all five SOLID principles.

But one important concept has appeared repeatedly throughout this article:

Dependency Injection.

We've used it without fully exploring it.

So in the next article, we'll take a step back and ask a very practical question:

Why should a class create its own dependencies when someone else can provide them?

We'll explore Dependency Injection, understand the problem it solves, and see how it works in real JavaScript and TypeScript applications.

Top comments (0)