Published on

Mastering the Dependency Inversion Principle with Advanced Code Refactoring

The Dependency Inversion Principle (DIP) is the final principle in SOLID design. It helps reduce tight coupling by ensuring high-level modules don't depend on low-level modules directly, but both depend on abstractions.

πŸ“œ Definition

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.

🧩 Problem: Complex Order Processing System

❌ Bad Code (Without DIP)

// Low-level module
class MySQLDatabase {
  saveOrder(order: string) {
    console.log(`Saving order to MySQL: ${order}`);
  }
}

// High-level module
class OrderService {
  private db: MySQLDatabase;

  constructor() {
    this.db = new MySQLDatabase(); // tightly coupled
  }

  process(order: string) {
    console.log(`Processing order: ${order}`);
    this.db.saveOrder(order);
  }
}

// Usage
const service = new OrderService();
service.process("Book");

πŸ” Problems

  • High-level module (OrderService) is tightly coupled to a specific implementation (MySQLDatabase).
  • Difficult to switch to another database (e.g., MongoDB, Redis).
  • Hard to mock for unit testing.
  • Breaks Open/Closed and Single Responsibility principles.

βœ… Good Code (With DIP)

πŸ”§ Step 1: Define an Abstraction

// Abstraction
interface IOrderRepository {
  saveOrder(order: string): void;
}

🧱 Step 2: Implement Low-Level Modules

class MySQLOrderRepository implements IOrderRepository {
  saveOrder(order: string) {
    console.log(`Saving order to MySQL: ${order}`);
  }
}

class MongoOrderRepository implements IOrderRepository {
  saveOrder(order: string) {
    console.log(`Saving order to MongoDB: ${order}`);
  }
}

πŸ—οΈ Step 3: Refactor High-Level Module

class OrderService {
  constructor(private repository: IOrderRepository) {}

  process(order: string) {
    console.log(`Processing order: ${order}`);
    this.repository.saveOrder(order);
  }
}

// Usage
const mysqlRepo = new MySQLOrderRepository();
const mongoRepo = new MongoOrderRepository();

const service = new OrderService(mysqlRepo);
service.process("Laptop");

const testService = new OrderService(mongoRepo);
testService.process("Phone");

βœ… Benefits of DIP

BenefitExplanation
πŸ”„ FlexibilityEasily swap implementations (e.g., switch DBs or APIs).
πŸ§ͺ TestabilityMock dependencies during unit tests using fake implementations.
πŸ”§ MaintainabilityHigh-level logic doesn't break with low-level changes.
🧱 DecouplingReduces dependency on concrete implementations.
πŸš€ ScalabilityEasy to extend or modify functionality without modifying core logic.

πŸ” How to Detect the Need for DIP?

Here are common signs that indicate a need for the Dependency Inversion Principle:

SymptomWhat It Means
πŸ”— Direct usage of concrete classes in servicesLeads to rigid code and hard to test or extend.
❌ Inability to replace dependencies easilyShows tight coupling between layers.
πŸ§ͺ Unit testing is hard due to real implementationsYou're locked into actual services, hard to mock.
πŸ› οΈ Changing low-level code breaks high-level logicSignals low-level classes aren't abstracted properly.

πŸ› οΈ How to Apply DIP Effectively

  1. Extract Interfaces Define contracts (IRepository, ILogger, INotifier, etc.).

  2. Inject via Constructor Pass dependencies through constructor parameters or DI containers.

  3. Use Inversion of Control (IoC) Leverage frameworks like InversifyJS, NestJS, or service locators.

  4. Keep Abstractions Simple Only expose necessary methods in the interface to maintain SRP.

πŸ’‘ Pro Tip for Large Systems

If you're building a layered architecture (e.g., controllers, services, repositories):

  • Keep infrastructure (e.g., DB, network) in outer layers.
  • Keep core domain logic in the center (depends only on abstractions).
  • Use dependency injection containers for managing bindings.

πŸ§ͺ Bonus: Unit Test with Mock

class MockOrderRepository implements IOrderRepository {
  saveOrder(order: string) {
    console.log(`Mock save: ${order}`);
  }
}

const testService = new OrderService(new MockOrderRepository());
testService.process("Test Order");

βš™οΈ Real-World Example: Notification System in a CRM

🧩 Context:

You are building a Customer Relationship Management (CRM) system that sends notifications to customers via different channels: Email, SMS, Push Notifications, etc.

❌ Bad Code (Violating DIP)

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

class NotificationService {
  private emailService = new EmailService(); // directly depends on concrete class

  notifyCustomer(to: string, message: string) {
    this.emailService.sendEmail(to, message);
  }
}

// Usage
const notifier = new NotificationService();
notifier.notifyCustomer("alice@example.com", "Your order has shipped!");

🚨 Problems:

  • NotificationService is tightly coupled to EmailService.
  • If you want to send SMS or Push Notifications later, you have to modify the NotificationService (breaks Open/Closed Principle).
  • Unit testing is hard due to concrete dependency.

βœ… Good Code (Following DIP)

1. Define an Abstraction

interface INotifier {
  send(to: string, message: string): void;
}

2. Create Multiple Implementations

class EmailNotifier implements INotifier {
  send(to: string, message: string): void {
    console.log(`πŸ“§ Email sent to ${to}: ${message}`);
  }
}

class SMSNotifier implements INotifier {
  send(to: string, message: string): void {
    console.log(`πŸ“± SMS sent to ${to}: ${message}`);
  }
}

class PushNotifier implements INotifier {
  send(to: string, message: string): void {
    console.log(`πŸ”” Push Notification to ${to}: ${message}`);
  }
}

3. Refactor High-Level Module to Depend on Abstraction

class NotificationService {
  constructor(private notifier: INotifier) {}

  notifyCustomer(to: string, message: string) {
    this.notifier.send(to, message);
  }
}

4. Usage Example

const emailNotifier = new EmailNotifier();
const smsNotifier = new SMSNotifier();
const pushNotifier = new PushNotifier();

const notificationService1 = new NotificationService(emailNotifier);
notificationService1.notifyCustomer("alice@example.com", "Email: Order Confirmed!");

const notificationService2 = new NotificationService(smsNotifier);
notificationService2.notifyCustomer("+1234567890", "SMS: Your OTP is 1234");

const notificationService3 = new NotificationService(pushNotifier);
notificationService3.notifyCustomer("aliceDeviceToken", "Push: You have a new message");

βœ… Benefits in Practice

BenefitDescription
βœ… TestabilityYou can easily inject a MockNotifier in tests.
βœ… ExtensibilityAdd SlackNotifier, WhatsappNotifier without touching NotificationService.
βœ… MaintainabilityChange how an SMS is sent without affecting the business logic.
βœ… Clean ArchitectureCore logic is decoupled from infrastructure code like APIs or DBs.

πŸ§ͺ Mocking for Unit Tests

class MockNotifier implements INotifier {
  send(to: string, message: string): void {
    console.log(`πŸ§ͺ MockNotifier: to=${to}, message=${message}`);
  }
}

const mockService = new NotificationService(new MockNotifier());
mockService.notifyCustomer("test@fake.com", "Testing mock!");

🧠 DIP Detection Tips

SymptomSign DIP Needed
A service has new SomeClass() insideViolating IoC & DIP
You must edit core logic to swap implementationsTight coupling
You can't easily unit test without real servicesMissing abstraction

πŸš€ Conclusion

The Dependency Inversion Principle is crucial for building scalable, testable, and decoupled systems. By inverting dependencies and relying on abstractions over implementations, you'll dramatically improve the quality of your architecture.

If you're experiencing pain when changing low-level implementations or struggle to test high-level modulesβ€”that's your cue to apply DIP.