Skip to content

Dependency Inversion Principle (DIP)

Business logic should not depend on details like SMS vendors or databases. Both depend on an interface that the business side owns.

Builds on Interfaces and Dependency injection

Stage 2core11 min read

What it says

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

The problem: if your business code is tied to one SMS vendor or one database, changing the vendor means changing the business code. DIP fixes this by controlling which way the source-code dependencies point. A source-code dependency is anything one file needs in order to compile: an import, a field type, a constructor parameter, or an interface it implements. DIP does not say business code never calls infrastructure at runtime. It still does.

Here, a module can be a class, package, or larger component:

TermMeaningExample
High levelBusiness policy: what the application is trying to accomplish"When an order ships, tell the customer."
Low levelTechnical detail: how the work reaches the outside worldMSG91 HTTP call, MySQL query, Kafka producer
AbstractionA contract stated in the high-level side's languageNotificationChannel.send(String msg)

A notification flow has two layers (the MSG91 names here are illustrative and not in the repo). The business rule says "notify the customer." The delivery detail says "build an MSG91 request and send it over HTTP." DIP stops the first from importing and creating the second.

Why "inversion"?

Without DIP, the import arrow follows the runtime call:

  1. OrderService needs to send an SMS.
  2. It imports and creates Msg91SmsClient.
  3. The business class now depends on the vendor class.

With DIP, the runtime call is the same, but the import arrows change:

  1. The business side defines NotificationChannel with the one operation it needs.
  2. OrderService depends on that interface.
  3. Msg91SmsClient implements the interface.
  4. Code at the edge of the application creates both objects and connects them.
  5. At runtime, the call still reaches MSG91, but the business package does not import the MSG91 SDK.

Before, the business class pointed at the vendor class. Now the vendor class points at an interface that the business side owns. That flip in the import arrow is the inversion.

Loading diagram…

Who owns the interface?

Most explanations skip this part. It is what separates a real understanding from a memorised line.

The interface belongs to the high level side. It lives in the business package, uses business words, and has only the methods the business needs. The low level code adapts to it.

Suppose the interface lives in the vendor package and copies the vendor API (sendMsg91Template(templateId, dltId, ...)). You have added an interface but inverted nothing. The business code still has to think in MSG91 terms, and moving to another provider still means editing the business code.

An everyday analogy

A restaurant writes a spec: "20 kg of grade A basmati every Monday, delivered by 9 AM". Any supplier who meets the spec can get the contract. If one supplier lets them down, the restaurant switches to another and its recipes stay the same.

The restaurant (high level) owns the spec (abstraction). The suppliers (low level) adapt to it. The restaurant never rewrites its biryani recipe because a supplier changed the packaging.

How to spot a violation

  • new of an infrastructure class inside business logic. new MySqlOrderDao(), new RazorpayClient(KEY), new SmtpMailer() inside a service method or constructor.
  • Static calls to infrastructure. SmsUtil.send(...), DbHelper.getConnection(), S3Uploader.upload(...) from business code.
  • Business classes importing vendor packages. import com.razorpay.*; at the top of CheckoutService.
  • Field types that are concrete low level classes. private MySqlOrderDao dao;
  • Hidden dependencies on time and randomness. LocalDateTime.now() or new Random() inside a rule you need to test.
  • Unit tests that need a real DB, network or a sleep. Or tests that only pass between 10 AM and 11 AM.
  • Switching a vendor means editing business code.
  • getInstance() singletons called from business logic. It hides a new behind a method call.

Example 1: notifications in this repo

Before

BadCode/NotificationService.java builds the classes it depends on, inside its own constructor:

public class NotificationService {
    private EmailService emailService;
    private SMSService smsService;

    public NotificationService() {
        this.emailService = new EmailService();
        this.smsService = new SMSService();
    }

    public void notifyByEmail(String msg) { emailService.sendEmail(msg); }
    public void notifyBySMS(String msg)   { smsService.sendSMS(msg); }
}

Problems:

  • The business class picks the concrete classes. You can't use it unless both email and SMS are set up.
  • Adding WhatsApp means a new field, a new new, and a new notifyByWhatsApp method. That breaks OCP (Open-Closed Principle) too.
  • In a test you can't stop it from sending real messages, because it creates the senders itself.
  • The method names (notifyByEmail, notifyBySMS) force every caller to know the delivery method.
Loading diagram…

After

GoodCode adds an interface and passes the sender in through the constructor:

public interface NotificationChannel {
    void send(String msg);
}

public class SMSService implements NotificationChannel {
    @Override
    public void send(String msg) { System.out.println("Sending SMS : " + msg); }
}

public class NotificationService {
    private NotificationChannel notificationChannel;

    public NotificationService(NotificationChannel channel) {
        this.notificationChannel = channel;
    }

    public void notify(String msg) {
        notificationChannel.send(msg);
    }
}

And GoodCode/Main.java decides which channel each service uses. It is the only place that knows about EmailService and SMSService. The real file builds two services, one per channel:

NotificationService emailNotification = new NotificationService(new EmailService());
emailNotification.notify("Your order has been shipped");

NotificationService smsNotification = new NotificationService(new SMSService());
smsNotification.notify("OTP is 1234");
Loading diagram…

What changed:

  • NotificationService only knows NotificationChannel. No new, no vendor names.
  • EmailService and SMSService now depend on the interface (they implement it). Both arrows point at the interface.
  • A test can pass a fake channel and check what was sent.
  • WhatsApp is a new class. NotificationService doesn't change.

A few things to tweak in real code:

  • Make the field private final. It is set once in the constructor and should never change.
  • Rename notify(String). It is legal, because it overloads Object.notify() rather than overriding it. But anyone who reads service.notify(...) will think of thread signalling first. Something like notifyCustomer is clearer.
  • To send on every channel, inject a List<NotificationChannel> and loop over it. The next snippet shows this.
public class NotificationService {
    private final List<NotificationChannel> channels;

    public NotificationService(List<NotificationChannel> channels) {
        this.channels = List.copyOf(channels);
    }

    public void notifyCustomer(String msg) {
        channels.forEach(c -> c.send(msg));
    }
}

Example 2: UPI checkout and a payment gateway

This example is illustrative. RazorpayClient and the other classes here are not in the repo.

Before

public class CheckoutService {

    public void checkout(Order order, String upiId) {
        RazorpayClient client = new RazorpayClient(System.getenv("RZP_KEY"), System.getenv("RZP_SECRET"));
        JSONObject request = new JSONObject();
        request.put("amount", order.totalPaise());
        request.put("currency", "INR");
        // ... vendor specific fields
        var payment = client.payments.create(request);   // vendor SDK call (simplified)
        order.markPaid(payment.get("id"));
    }
}

CheckoutService is the most important business code in the app, and it is stuck to one vendor's SDK, its JSON format and its environment variables. To test "order is marked paid after a successful payment" you need network access and test keys. If finance gets a better rate from another gateway, you have to rewrite checkout.

After

The checkout side defines what it needs, in its own words. charge returns a PaymentResult, a small record of success, transaction id and failure reason, so checkout never sees vendor JSON:

// package com.food.checkout   (high level owns this)
public interface PaymentGateway {
    PaymentResult charge(String orderId, long amountPaise, String upiId);
}

public record PaymentResult(boolean success, String txnId, String failureReason) { }

public class CheckoutService {
    private final PaymentGateway gateway;

    public CheckoutService(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    public void checkout(Order order, String upiId) {
        PaymentResult result = gateway.charge(order.id(), order.totalPaise(), upiId);
        if (result.success()) {
            order.markPaid(result.txnId());
        } else {
            order.markPaymentFailed(result.failureReason());
        }
    }
}

The vendor details move to an adapter in an infrastructure package:

// package com.food.infra.razorpay   (low level depends on checkout's interface)
public class RazorpayGateway implements PaymentGateway {
    private final RazorpayClient client;

    public RazorpayGateway(RazorpayClient client) {
        this.client = client;
    }

    @Override
    public PaymentResult charge(String orderId, long amountPaise, String upiId) {
        // build the vendor request, call the SDK, map the response
        // ...
    }
}
Loading diagram…

Look at the package arrows. infra imports checkout. checkout imports nothing from infra. You can check this inversion in a code review. In a larger codebase, don't rely on reviewers to catch it. Use a small ArchUnit test (it checks package dependencies), or split the code into separate Maven or Gradle modules where checkout has no dependency on infra. Then a wrong import fails the build.

Testing is now plain Java. The fake treats any UPI id ending in @fail as a failed payment:

class FakePaymentGateway implements PaymentGateway {
    public PaymentResult charge(String orderId, long amountPaise, String upiId) {
        return upiId.endsWith("@fail")
                ? new PaymentResult(false, null, "Insufficient balance")
                : new PaymentResult(true, "TXN-" + orderId, null);
    }
}

@Test
void marksOrderPaidOnSuccess() {
    var order = new Order("ORD-1001", 49_900);   // ₹499 in paise
    new CheckoutService(new FakePaymentGateway()).checkout(order, "ravi@okhdfc");
    assertTrue(order.isPaid());
}

Switching to another gateway is one new adapter class and one line of wiring.

Example 3: IRCTC Tatkal booking and the hidden dependency on time

Not every low level detail is a vendor. The system clock is one too, because the answer to "what time is it?" changes every run.

Tatkal booking opens at 10:00 AM for AC classes and 11:00 AM for non-AC classes, one day before the journey.

Before

public class TatkalBookingService {
    private final SeatRepository seats = new MySqlSeatRepository();

    public Booking book(TatkalRequest request) {
        LocalTime opensAt = request.travelClass().isAc() ? LocalTime.of(10, 0) : LocalTime.of(11, 0);
        LocalDateTime windowOpens = request.journeyDate().minusDays(1).atTime(opensAt);

        if (LocalDateTime.now().isBefore(windowOpens)) {
            throw new BookingNotOpenException(windowOpens);
        }
        // ... find seat, save booking
    }
}

There are two hidden dependencies: a concrete MySQL repository, and LocalDateTime.now(). The second one is easy to miss. A test for "booking at 9:59 AM is rejected" can only run at 9:59 AM.

After

public class TatkalBookingService {
    private final SeatRepository seats;
    private final Clock clock;

    public TatkalBookingService(SeatRepository seats, Clock clock) {
        this.seats = seats;
        this.clock = clock;
    }

    public Booking book(TatkalRequest request) {
        // ... same window calculation
        if (LocalDateTime.now(clock).isBefore(windowOpens)) {
            throw new BookingNotOpenException(windowOpens);
        }
        // ...
    }
}

java.time.Clock is an abstraction the JDK already gives you. It is a source of "now". Production passes Clock.system(ZoneId.of("Asia/Kolkata")), the real clock. Tests pass a clock frozen at one moment:

Clock nineFiftyNine = Clock.fixed(
        Instant.parse("2026-11-14T04:29:00Z"),        // 09:59 IST
        ZoneId.of("Asia/Kolkata"));

var service = new TatkalBookingService(new InMemorySeatRepository(), nineFiftyNine);
assertThrows(BookingNotOpenException.class, () -> service.book(acRequestFor15Nov));

No sleeping, no flaky tests, and the same trick works for Random, UUID generation and anything else that changes between runs.

DIP vs Dependency Injection vs IoC

People mix these up, partly because two of them have "inversion" in the name and mean different things by it.

DIP is a design rule about which way source code dependencies point and who owns the abstraction.

Dependency Injection (DI) is a technique: a class receives its dependencies (usually through the constructor) instead of creating them, as covered in 12-DependencyInjection.md.

Inversion of Control (IoC) is a broader idea: your code gives up control of some job and a framework does it instead. With a web framework, you don't write the request loop. The framework calls your controller. With object creation, you don't call new on your dependencies. Something outside creates them and passes them in. What gets inverted here is who is in control, not which way an import points.

An IoC container like Spring does the creating and injecting for you. It finds classes marked @Component or @Service and connects them.

The three are independent. You can use DI without DIP (injecting a concrete MySqlOrderDao is still injection). You can follow DIP without any container (connect everything by hand in Main, like this repo does).

ThingWhat it isAnswers the question
DIPDesign principleWhat should my business code depend on?
DITechniqueHow does the dependency get into my class?
IoCGeneral ideaWho is in control: my code or the framework?
IoC containerFramework / toolWho creates and wires all the objects?

When not to apply it

Many codebases end up with an "interface for everything" because of a wrong reading of DIP. Be picky.

  • Don't add an interface for every class. OrderPricer is pure logic with no I/O and one implementation. Depend on it directly. You can still pass it through the constructor.
  • Abstract the things that change or are slow: network calls, databases, vendors, file systems, time, randomness. These are what you swap and what you fake in tests.
  • Don't wrap the JDK or stable libraries. Nobody needs an IList around ArrayList or a StringUtils interface.
  • FooService + FooServiceImpl with one implementation and no test fakes is ceremony, not DIP. If you need a stand-in for just one test, Mockito (a mocking library) can mock a concrete class.
  • Watch for interfaces that copy the vendor API. If your PaymentGateway has a razorpayOrderId parameter, the wrong side owns the abstraction.
  • Small scripts and one-off jobs can call the DB directly.
Depend on an abstractionDepend on the concrete class
Payment gateways, SMS, email, storagePure calculation classes
Repositories over a real databaseValue objects and records
Clock, random, UUIDJDK collections and utilities
Anything you will fake in a unit testClasses with one stable implementation

How it connects to the rest

  • OCP: DIP is the usual way to get OCP. Once NotificationService depends on NotificationChannel, a new channel is added as a new class and no existing class is edited. See OCP-Notes.md.
  • LSP: every implementation of the abstraction must honour its contract. A PaymentGateway that returns null instead of a PaymentResult breaks checkout. See LSP-Notes.md.
  • ISP: keep the owned interface small and shaped by what the high level needs. That is ISP (small interfaces) and DIP working together. See ISP-Notes.md.
  • SRP: splitting out OrderNotifier or InvoiceRepository is SRP. Making them interfaces is DIP. See SRP-Notes.md.

Patterns that show up around DIP:

  • Adapter: RazorpayGateway is an adapter from the vendor SDK to your own interface.
  • Factory Method and Abstract Factory: where the new of concrete classes goes when it leaves business code.
  • Strategy: the context depends on a strategy interface, not on concrete strategies.
  • Observer: the publisher knows only a listener interface.
  • Template Method: the "don't call us, we'll call you" idea behind inversion of control.

Before you commit, ask

  • Does any business class call new on a DB, HTTP, SMS or vendor class?
  • Does any business class import a vendor SDK package?
  • Does the business logic read the clock or generate random values directly?
  • Who owns this interface? Is it in the business package, named in business words?
  • Can I unit test this class without a network, a DB or a specific time of day?
  • Am I adding an interface for something that will really vary or be faked, or just out of habit?

Interview questions

"Explain the Dependency Inversion Principle in simple terms."

Business logic should not depend directly on low level details like a database or an SMS vendor. Both should depend on an interface that says what the business needs. The business code calls the interface, and the vendor code implements it. This way a vendor change does not touch the business code.

"What exactly gets inverted?"

The direction of the source code dependency. Normally the high level class imports the low level class it calls. With DIP the low level class implements an interface owned by the high level side, so the import arrow points the other way. At runtime the call still goes from high to low.

"What is the difference between DIP and Dependency Injection?"

DIP is a design principle about depending on abstractions owned by the business side. Dependency Injection is a technique for handing a class its dependencies, usually via the constructor. You can inject a concrete class without following DIP, and you can follow DIP while wiring everything by hand without a container.

"Who should own the interface, and why does it matter?"

The high level module should own it, in its package and in its language, with only the methods it needs. If the interface sits in the vendor package and mirrors the vendor API, the business code still thinks in vendor terms and a vendor change still ripples into it. That is an interface without any inversion.

"Should every class have an interface then?"

No. Abstract the volatile or slow edges: databases, external services, file systems, time and randomness. Pure logic classes with one implementation can be used directly. An interface with one implementation and no fakes in tests is usually just ceremony.

"How does DIP help with testing?"

Because the class receives an abstraction, a test can pass a fake, like a FakePaymentGateway or a fixed Clock. Tests then run without a network or database and give the same result at any time of day.

"How would you remove a hidden dependency on LocalDateTime.now()?"

Inject a java.time.Clock through the constructor and call LocalDateTime.now(clock). Production passes a system clock for the right zone, and tests pass Clock.fixed(...) so time based rules like the Tatkal window can be tested at any hour.

In this topic

Done with this topic?

How well do you know it?

PreviousInterface Segregation Principle (ISP)
Practise 7 questions