Anderson's Blog

From Polymorphism to Dependency Inversion

Lately, I’ve been reading Clean Architecture by Robert C. Martin. Given some personal goals and work challenges that have led me to explore Domain-Driven Design, I thought it would be a good idea to revisit Martin’s ideas to get the most out of them.

In Chapter 5, OOP, Martin introduces independence—a key concept throughout Clean Architecture. This independence evolves into independent deployability and eventually independent developability.

Coming from JavaScript (I know, it’s a mess) and now working with TypeScript (thank God), I wanted to better understand Martin’s transition from polymorphism to dependency inversion. This transition is crucial because it’s where the idea of independence truly takes shape.

How Does Polymorphism Lead to Dependency Inversion?

Polymorphism allows different classes to be used interchangeably if they implement a common interface. This enables dynamic behavior in OOP. How does this leads to Dependency Inversion?

Dependency Inversion states:

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

By making high-level modules depend on abstractions rather than concrete implementations, we achieve dependency inversion. And this is why polymorphism and dependency inversion are deeply connected—it’s polymorphism that makes dependency inversion possible.

Here is a little example in TypeScript to understand this better. This is just an example that violates dependency inversion:

class FileLogger {
  log(message: string): void {
    console.log(`Logging to file: ${message}`);
  }
}

class App {
  private logger: FileLogger;

  constructor() {
    this.logger = new FileLogger(); // Hard dependency on FileLogger
  }

  doSomething(): void {
    this.logger.log("Something happened!");
  }
}

const app = new App();
app.doSomething();
  • ‘App’ directly depends on ‘FileLogger’, a low-level module.
  • If we need a DatabaseLogger, we must modify App, breaking Open/Closed Principle.
  • This makes the system hard to extend and maintain.

Now, Polymorphism allows different classes to share a common interface, making code more flexible, right?

// Define an interface (abstraction)
interface Logger {
  log(message: string): void;
}

// Implement different loggers
class FileLogger implements Logger {
  log(message: string): void {
    console.log(`Logging to file: ${message}`);
  }
}

class DatabaseLogger implements Logger {
  log(message: string): void {
    console.log(`Logging to database: ${message}`);
  }
}

class App {
  private logger: Logger; // Dependency on abstraction

  constructor(logger: Logger) {
    this.logger = logger;
  }

  doSomething(): void {
    this.logger.log("Something happened!");
  }
}
  • ‘App’ depends on Logger (abstraction), not ‘FileLogger’ (concrete class).
  • We can easily switch implementations (‘DatabaseLogger’, ‘FileLogger’).
  • This makes the system flexible, testable, and maintainable.

This is where Dependency Inversion comes in. Instead of ‘App → FileLogger’, We now have ‘App → Logger Interface ← FileLogger’.

Writing software isn’t just about making things work—it’s about building systems that can evolve. The transition from polymorphism to dependency inversion teaches a key lesson: details should serve the architecture, not control it.