OOP Interview Prep
SOLID Principles

Dependency Inversion Principle

High-Level Modules Must Never Depend on Low-Level Details

LinkedIn Hook

Here is a code smell that is extremely common in production codebases, and almost nobody names it correctly in an interview:

A high-level class directly creates or imports a low-level class inside its own body.

Swap that low-level class, and you have to rewrite the high-level class. Write a test for the high-level class, and you pull in the real database, the real email server, the real payment gateway. The classes cannot be used apart from each other.

That is a DIP violation. The Dependency Inversion Principle says your high-level business logic should never depend on a specific implementation. It should depend on a contract — an interface or abstract class — and the real implementation is injected from the outside.

One principle. Cleaner architecture, testable code, and swappable infrastructure.

Read the full lesson with before/after code and patterns → [link]

#OOP #SOLID #TypeScript #JavaScript #DependencyInjection #InterviewPrep


Dependency Inversion Principle thumbnail


What You'll Learn

  • Why the Dependency Inversion Principle exists and what problem it solves at the architecture level
  • The precise meaning of "high-level module" and "low-level module" with real examples
  • How tight coupling breaks testability and makes systems fragile to change
  • The before-and-after refactoring from a tightly coupled design to dependency injection with an abstraction layer
  • Constructor injection pattern in JavaScript and TypeScript
  • How DIP connects to interfaces, abstract classes, and the broader concept of programming to contracts
  • The three most common DIP-related interview traps

The Analogy That Makes It Click

Think about how power outlets work.

Your laptop, phone charger, and desk lamp all plug into the same wall socket. The wall socket does not know or care what device is plugged in. The devices do not need to know which power station is generating the electricity, or how the wiring inside your walls is routed. All they share is a contract: the shape of the plug and the voltage specification.

Now imagine the opposite. Your laptop is hardwired directly into a specific power plant. Switching from coal to solar? You have to redesign the laptop. Moving to a different city with different voltage? Redesign the laptop. Running a test without electricity? Impossible.

That second scenario is exactly what happens when a high-level class instantiates a low-level class directly inside itself. The high-level class is "hardwired" to one specific implementation.

DIP says: connect through a contract (the socket standard), not through a direct wire to a specific implementation.

[INTERNAL-LINK: how interfaces define contracts in OOP → Lesson 3.3: Interface]


What Is the Dependency Inversion Principle?

The Dependency Inversion Principle is the fifth and final SOLID principle, first described by Robert C. Martin. It has two rules:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

"High-level module" means a class that contains business logic — the core rules of your application. OrderService, UserAuthenticator, InvoiceProcessor are high-level modules.

"Low-level module" means a class that handles infrastructure or implementation details. MySQLDatabase, SendGridEmailSender, StripePaymentGateway are low-level modules.

The violation: OrderService directly creates a new MySQLDatabase() inside its own constructor or methods. The high-level class knows the specific type of the low-level class. That knowledge is the problem.

The fix: OrderService only knows about an IDatabase interface. Whatever concrete database class satisfies that interface gets passed in from the outside at construction time.

[INTERNAL-LINK: abstract classes as an alternative to interfaces for DIP contracts → Lesson 3.2: Abstract Class]


Citation Capsule: The Dependency Inversion Principle states that high-level modules must not depend on low-level modules, and both should depend on abstractions rather than concrete implementations. This principle, formalized by Robert C. Martin in his 1996 paper "The Dependency Inversion Principle" (C++ Report), is foundational to testable, maintainable object-oriented architecture.


Before: Tight Coupling — The Problem

This example shows a common violation. Read the comments carefully. Notice every place where OrderService reaches directly into a concrete class.

// LOW-LEVEL MODULE — handles database details
class MySQLDatabase {
  save(order) {
    // Directly talks to MySQL — hard to swap or fake in tests
    console.log(`[MySQL] Saving order ${order.id} to database`);
    return true;
  }

  find(orderId) {
    console.log(`[MySQL] Fetching order ${orderId} from database`);
    return { id: orderId, total: 99.99, status: "pending" };
  }
}

// LOW-LEVEL MODULE — handles email details
class SendGridEmailer {
  send(to, subject, body) {
    // Directly calls SendGrid API — requires network in tests
    console.log(`[SendGrid] Sending email to ${to}: ${subject}`);
    return true;
  }
}

// HIGH-LEVEL MODULE — contains business logic
// PROBLEM: OrderService CREATES its own dependencies internally
class OrderService {
  constructor() {
    // Tight coupling — OrderService is hardwired to MySQL and SendGrid
    // To test OrderService, you MUST have a real MySQL connection
    // To test OrderService, you MUST have a real SendGrid account
    // To swap MySQL for PostgreSQL, you MUST change this class
    this.database = new MySQLDatabase();
    this.emailer = new SendGridEmailer();
  }

  placeOrder(order) {
    // Business logic — this is the valuable part
    if (!order.id || !order.total) {
      throw new Error("Invalid order: missing id or total");
    }

    // But it is locked to specific infrastructure
    const saved = this.database.save(order);

    if (saved) {
      this.emailer.send(
        order.customerEmail,
        "Order Confirmed",
        `Your order ${order.id} has been placed.`
      );
    }

    return saved;
  }

  getOrder(orderId) {
    return this.database.find(orderId);
  }
}

// Usage — works fine in production
const service = new OrderService();
service.placeOrder({ id: "ORD-001", total: 99.99, customerEmail: "alice@example.com" });

// Testing — IMPOSSIBLE without real MySQL and real SendGrid
// const testService = new OrderService(); // pulls in real infrastructure
// testService.placeOrder(mockOrder);      // sends a real email, hits a real database

[IMAGE: Diagram showing OrderService box with two hardwired arrows going directly into MySQLDatabase and SendGridEmailer boxes - search terms: "tight coupling dependency class diagram OOP"]

The exact problem: OrderService controls its own dependency creation. It decides what concrete types it uses. The business logic and the infrastructure details are fused into one class, and you cannot separate them.


After: Dependency Injection with Abstractions — The Fix

The fix has two parts. First, define contracts (interfaces) for the dependencies. Second, inject concrete implementations from the outside rather than creating them inside.

// ABSTRACTION — defines the contract for any database implementation
interface IDatabase {
  save(order: Order): boolean;
  find(orderId: string): Order | null;
}

// ABSTRACTION — defines the contract for any email implementation
interface IEmailSender {
  send(to: string, subject: string, body: string): boolean;
}

// Type definition for clarity
interface Order {
  id: string;
  total: number;
  customerEmail: string;
  status?: string;
}

// LOW-LEVEL MODULE — implements the contract
class MySQLDatabase implements IDatabase {
  save(order: Order): boolean {
    console.log(`[MySQL] Saving order ${order.id} to database`);
    return true;
  }

  find(orderId: string): Order | null {
    console.log(`[MySQL] Fetching order ${orderId} from database`);
    return { id: orderId, total: 99.99, customerEmail: "alice@example.com", status: "pending" };
  }
}

// LOW-LEVEL MODULE — implements the contract
class SendGridEmailSender implements IEmailSender {
  send(to: string, subject: string, body: string): boolean {
    console.log(`[SendGrid] Sending email to ${to}: ${subject}`);
    return true;
  }
}

// HIGH-LEVEL MODULE — depends only on abstractions, not on concrete classes
class OrderService {
  private database: IDatabase;
  private emailer: IEmailSender;

  // CONSTRUCTOR INJECTION — dependencies are passed in, not created internally
  // OrderService does not know or care whether the database is MySQL, PostgreSQL,
  // or an in-memory mock. It only knows the IDatabase contract.
  constructor(database: IDatabase, emailer: IEmailSender) {
    this.database = database;
    this.emailer = emailer;
  }

  placeOrder(order: Order): boolean {
    // Business logic is identical to before
    if (!order.id || !order.total) {
      throw new Error("Invalid order: missing id or total");
    }

    const saved = this.database.save(order);

    if (saved) {
      this.emailer.send(
        order.customerEmail,
        "Order Confirmed",
        `Your order ${order.id} has been placed.`
      );
    }

    return saved;
  }

  getOrder(orderId: string): Order | null {
    return this.database.find(orderId);
  }
}

// PRODUCTION: inject the real implementations
const productionService = new OrderService(
  new MySQLDatabase(),
  new SendGridEmailSender()
);

productionService.placeOrder({
  id: "ORD-001",
  total: 99.99,
  customerEmail: "alice@example.com"
});

// Output:
// [MySQL] Saving order ORD-001 to database
// [SendGrid] Sending email to alice@example.com: Order Confirmed

[UNIQUE INSIGHT]: The business logic in placeOrder did not change at all between the before and after examples. The only change is where the dependencies come from: created internally vs. received from the outside. This is the key insight interviewers want to hear. DIP does not change what your code does. It changes who is responsible for wiring the pieces together.


Testing With DIP Applied

This is where DIP's real payoff becomes visible. Because OrderService depends on abstractions, you can pass in test doubles without any framework or mocking library.

// TEST DOUBLES — lightweight implementations for testing
// No MySQL connection required. No SendGrid account required.

class MockDatabase implements IDatabase {
  public savedOrders: Order[] = [];
  public shouldSaveFail: boolean = false;

  save(order: Order): boolean {
    if (this.shouldSaveFail) {
      return false;
    }
    // Store in memory for assertions
    this.savedOrders.push(order);
    return true;
  }

  find(orderId: string): Order | null {
    return this.savedOrders.find(o => o.id === orderId) ?? null;
  }
}

class MockEmailSender implements IEmailSender {
  public sentEmails: Array<{ to: string; subject: string; body: string }> = [];

  send(to: string, subject: string, body: string): boolean {
    // Record calls for assertions, do not actually send anything
    this.sentEmails.push({ to, subject, body });
    return true;
  }
}

// TEST — completely isolated, no real infrastructure
function testPlaceOrderSendsConfirmationEmail(): void {
  const mockDB = new MockDatabase();
  const mockEmail = new MockEmailSender();

  // Inject mocks instead of real implementations
  const service = new OrderService(mockDB, mockEmail);

  const testOrder: Order = {
    id: "TEST-001",
    total: 49.99,
    customerEmail: "test@example.com"
  };

  service.placeOrder(testOrder);

  // Assert database was called correctly
  console.assert(mockDB.savedOrders.length === 1, "Expected one saved order");
  console.assert(mockDB.savedOrders[0].id === "TEST-001", "Expected correct order id");

  // Assert email was sent correctly
  console.assert(mockEmail.sentEmails.length === 1, "Expected one email sent");
  console.assert(
    mockEmail.sentEmails[0].to === "test@example.com",
    "Expected email sent to customer"
  );

  console.log("Test passed: placeOrder sends confirmation email");
}

function testPlaceOrderDoesNotSendEmailOnSaveFailure(): void {
  const mockDB = new MockDatabase();
  mockDB.shouldSaveFail = true; // Simulate database failure
  const mockEmail = new MockEmailSender();

  const service = new OrderService(mockDB, mockEmail);

  service.placeOrder({
    id: "TEST-002",
    total: 29.99,
    customerEmail: "test@example.com"
  });

  // If save fails, no email should be sent
  console.assert(mockEmail.sentEmails.length === 0, "Expected no email when save fails");

  console.log("Test passed: no email sent when database save fails");
}

testPlaceOrderSendsConfirmationEmail();
testPlaceOrderDoesNotSendEmailOnSaveFailure();

// Output:
// Test passed: placeOrder sends confirmation email
// Test passed: no email sent when database save fails

[PERSONAL EXPERIENCE]: In every team where I have seen untestable code, the root cause is almost always the same: classes create their own dependencies with new inside the constructor or method body. The moment you move to constructor injection, entire test suites become possible without any special setup. This single change is often the highest-leverage refactor in a legacy codebase.


Swapping Implementations Without Changing Business Logic

DIP also enables infrastructure swaps. Here is what a database migration looks like with DIP applied.

// NEW LOW-LEVEL MODULE — PostgreSQL replaces MySQL
// The contract is identical: IDatabase
class PostgreSQLDatabase implements IDatabase {
  save(order: Order): boolean {
    console.log(`[PostgreSQL] Saving order ${order.id} to database`);
    return true;
  }

  find(orderId: string): Order | null {
    console.log(`[PostgreSQL] Fetching order ${orderId} from database`);
    return { id: orderId, total: 99.99, customerEmail: "alice@example.com", status: "pending" };
  }
}

// NEW LOW-LEVEL MODULE — AWS SES replaces SendGrid
class AWSEmailSender implements IEmailSender {
  send(to: string, subject: string, body: string): boolean {
    console.log(`[AWS SES] Sending email to ${to}: ${subject}`);
    return true;
  }
}

// OrderService is UNCHANGED — zero edits to the business logic class
// Only the composition root changes (the place where dependencies are wired)
const migratedService = new OrderService(
  new PostgreSQLDatabase(),  // swapped
  new AWSEmailSender()       // swapped
);

migratedService.placeOrder({
  id: "ORD-002",
  total: 149.99,
  customerEmail: "bob@example.com"
});

// Output:
// [PostgreSQL] Saving order ORD-002 to database
// [AWS SES] Sending email to bob@example.com: Order Confirmed

Dependency Inversion Principle visual 1


Common Mistakes

  • Injecting through setters instead of constructors when the dependency is required. Constructor injection forces the caller to provide all mandatory dependencies upfront. If a dependency is optional, a setter or default argument is acceptable. But if OrderService cannot function without a database, accepting it through the constructor makes that requirement explicit. Setter injection hides it and creates objects that are only half-initialized.

  • Creating the abstraction only on paper, then depending on a concrete detail inside the interface. A common mistake is defining interface IDatabase but giving it a method called runMySQLQuery(sql: string). That method is a detail of MySQL, not a general database contract. The abstraction leaks the implementation. A correct abstraction defines operations in terms of business concepts: save(order), find(orderId), delete(orderId).

  • Confusing Dependency Inversion with Dependency Injection. DIP is the principle: high-level modules depend on abstractions. Dependency Injection (DI) is a technique for implementing DIP: you pass dependencies in from outside rather than creating them inside. DI is how you achieve DIP. They are not the same thing, and conflating them in an interview suggests you have only memorized terms without understanding the relationship.

  • Skipping the abstraction layer and injecting the concrete class directly. If your constructor is constructor(db: MySQLDatabase), you have used dependency injection but violated DIP. The type annotation is still a concrete class, so you cannot swap it for a PostgreSQLDatabase without changing the constructor signature. DIP requires the type to be an abstraction: constructor(db: IDatabase).

  • Over-abstracting every dependency. Not every class needs an interface. A DateFormatter utility class that has no alternative implementation and never needs to be mocked does not benefit from an abstraction. DIP matters most for external systems: databases, email services, payment gateways, HTTP clients. Apply it where the implementation is likely to change or where isolation in tests is valuable.


Interview Questions

Q: What does the Dependency Inversion Principle state, in your own words?

DIP has two rules. High-level modules, which contain business logic, should not depend on low-level modules, which handle infrastructure. Both should depend on a shared abstraction such as an interface. And that abstraction should be defined in terms of what the business logic needs, not in terms of what the infrastructure happens to offer. In practice: your OrderService should accept an IDatabase interface, not a MySQLDatabase class. The concrete database is provided from the outside and can be swapped without touching the business logic.

Q: What is the difference between Dependency Inversion and Dependency Injection?

Dependency Inversion is the principle: depend on abstractions, not concrete implementations. Dependency Injection is a technique for implementing that principle: instead of a class creating its own dependencies, they are passed in (injected) from the outside through a constructor, a method parameter, or a property setter. DI is one way to achieve DIP. You can violate DIP while using DI if you inject a concrete class rather than an abstraction. The principle is about the type you depend on. The technique is about where the instance comes from.

Q: How does DIP improve testability?

When a high-level class depends on an abstraction, any class that implements that abstraction can be passed in during testing. You create a lightweight in-memory implementation, a mock, or a stub that satisfies the interface without hitting a real database or external API. The test runs in isolation, executes instantly, and produces deterministic results. Without DIP, the class creates its own dependencies internally, so the test always pulls in the real infrastructure. You cannot test the business logic without the database running.

Q: What is a "composition root" and how does it relate to DIP?

A composition root is the single place in your application where you wire all dependencies together and inject them into the classes that need them. This is typically the application entry point, such as the main function or the server startup file. DIP tells you to depend on abstractions throughout your system. The composition root is where you resolve those abstractions to concrete implementations and construct the object graph. Keeping all new ConcreteClass() calls in the composition root and passing abstractions everywhere else is the practical execution of DIP across a full application.

Q: Can you apply DIP in plain JavaScript without TypeScript interfaces?

Yes. JavaScript has no compile-time interface enforcement, but you can still apply the principle structurally. Your class accepts any object that has the expected methods, which is duck typing. You document the expected contract through JSDoc or by naming conventions. You can also use an abstract base class pattern where the base class throws errors on methods that must be overridden, providing a runtime contract check. TypeScript makes DIP easier because the compiler enforces the contract. But the dependency injection pattern itself works identically in plain JavaScript: pass the dependency in, do not create it inside.


Quick Reference Cheat Sheet

DEPENDENCY INVERSION PRINCIPLE — QUICK REFERENCE
---------------------------------------------------------------------------
The Two Rules (Robert C. Martin, 1996):
  1. High-level modules must not depend on low-level modules.
     Both should depend on abstractions.
  2. Abstractions should not depend on details.
     Details should depend on abstractions.
---------------------------------------------------------------------------

TERMS
---------------------------------------------------------------------------
High-level module   Class with business logic
                    OrderService, UserAuth, InvoiceProcessor

Low-level module    Class with infrastructure/implementation details
                    MySQLDatabase, SendGridEmailer, StripeGateway

Abstraction         Interface or abstract class defining a contract
                    IDatabase, IEmailSender, IPaymentGateway

Dependency          An object that another object uses to do its work
Injection (DI)      Technique: pass dependencies in, do not create them

Composition root    Single place where all dependencies are wired together
                    (entry point, server startup, main function)
---------------------------------------------------------------------------

VIOLATION PATTERN                     DIP PATTERN
---------------------------------------------------------------------------
class OrderService {                  class OrderService {
  constructor() {                       constructor(db, emailer) {
    this.db = new MySQLDB();  <--         this.db = db;
    this.emailer = new SGrid(); <--       this.emailer = emailer;
  }                                     }
}                                     }
  ^--- creates concretes inside           ^--- receives abstractions outside
---------------------------------------------------------------------------

CONSTRUCTOR INJECTION (TypeScript)
---------------------------------------------------------------------------
interface IDatabase {
  save(order: Order): boolean;
  find(orderId: string): Order | null;
}

class OrderService {
  constructor(private db: IDatabase) {}
  // IDatabase, not MySQLDatabase — depends on abstraction
}

// Production
new OrderService(new MySQLDatabase());

// Test
new OrderService(new MockDatabase());
---------------------------------------------------------------------------

THREE FORMS OF DEPENDENCY INJECTION
---------------------------------------------------------------------------
1. Constructor injection (preferred for required dependencies)
   constructor(private db: IDatabase) {}

2. Method injection (for dependencies needed by one method only)
   placeOrder(order: Order, db: IDatabase): boolean {}

3. Property injection (for optional dependencies with defaults)
   private logger: ILogger = new NullLogger();
   set logger(l: ILogger) { this.logger = l; }
---------------------------------------------------------------------------

DI vs DIP — THE DISTINCTION
---------------------------------------------------------------------------
DIP (principle):  depend on IDatabase, not MySQLDatabase
DI  (technique):  pass the IDatabase in from outside

Using DI but violating DIP:
  constructor(db: MySQLDatabase) {}  // DI = yes, DIP = no (concrete type)

Correct DIP via DI:
  constructor(db: IDatabase) {}      // DI = yes, DIP = yes (abstract type)
---------------------------------------------------------------------------

ABSTRACTION DESIGN RULE
---------------------------------------------------------------------------
Wrong: interface IDatabase { runMySQLQuery(sql: string): Row[] }
       ^--- abstraction leaks the implementation detail (MySQL)

Correct: interface IDatabase {
           save(order: Order): boolean;
           find(orderId: string): Order | null;
         }
       ^--- abstraction speaks the language of the business domain
---------------------------------------------------------------------------

WHEN TO APPLY DIP
---------------------------------------------------------------------------
Apply when:
  - The dependency is an external system (DB, API, email, payment)
  - The implementation is likely to change or be swapped
  - You need to test the class in isolation
  - Multiple implementations must be interchangeable at runtime

Skip when:
  - The class has no alternative implementation and never needs mocking
  - The overhead of abstraction outweighs any benefit
  - A simple utility with no side effects (DateFormatter, StringUtils)
---------------------------------------------------------------------------

Dependency Inversion Principle visual 2


Previous: Lesson 7.4 - Interface Segregation Principle Next: Lesson 8.1 - What Are Design Patterns?


This is Lesson 7.5 of the OOP Interview Prep Course — 8 chapters, 41 lessons.

On this page