Understanding the Dependency Inversion Principle with TypeScript and Databases

Understanding the Dependency Inversion Principle with TypeScript and Databases

Learn how to implement the Dependency Inversion Principle in TypeScript to create flexible, maintainable database interactions.

The Dependency Inversion Principle (DIP) is a key SOLID principle that helps us write flexible, maintainable code by decoupling high-level logic from low-level details. Instead of hardcoding dependencies, we rely on abstractions. Today, we’ll explore DIP with a practical example: a UserService that works with MongoDB, MySQL, and PostgreSQL, all in TypeScript.

The Problem: Tight Coupling

Imagine a UserService that directly uses a MySQL database. If you later need to switch to MongoDB, you’d rewrite the service. That’s rigid and messy. DIP fixes this by making both the service and the database depend on an abstraction.

Step 1: Define the Abstraction

We start with an interface that defines what our database should do—here, saving a user.

interface UserRepository {
  saveUser(user: { id: string; name: string }): Promise<void>;
}

This is our contract. The UserService will depend on this, not a specific database.

Step 2: Implement Concrete Repositories

Next, we create implementations for MongoDB, MySQL, and PostgreSQL, each adhering to UserRepository.

MongoDB Repository

import { MongoClient } from "mongodb";

class MongoUserRepository implements UserRepository {
  private client: MongoClient;

  constructor() {
    this.client = new MongoClient("mongodb://localhost:27017");
  }

  async saveUser(user: { id: string; name: string }): Promise<void> {
    const db = this.client.db("mydb");
    await db.collection("users").insertOne(user);
    console.log(`Saved ${user.name} to MongoDB`);
  }
}

MySQL Repository

import mysql from "mysql2/promise";

class MySQLUserRepository implements UserRepository {
  private connection: mysql.Connection;

  constructor() {
    this.connection = await mysql.createConnection({
      host: "localhost",
      user: "root",
      database: "mydb",
    });
  }

  async saveUser(user: { id: string; name: string }): Promise<void> {
    await this.connection.execute(
      "INSERT INTO users (id, name) VALUES (?, ?)",
      [user.id, user.name]
    );
    console.log(`Saved ${user.name} to MySQL`);
  }
}

PostgreSQL Repository

import { Pool } from "pg";

class PostgresUserRepository implements UserRepository {
  private pool: Pool;

  constructor() {
    this.pool = new Pool({
      user: "postgres",
      host: "localhost",
      database: "mydb",
      password: "password",
    });
  }

  async saveUser(user: { id: string; name: string }): Promise<void> {
    await this.pool.query(
      "INSERT INTO users (id, name) VALUES ($1, $2)",
      [user.id, user.name]
    );
    console.log(`Saved ${user.name} to PostgreSQL`);
  }
}

Each class implements UserRepository, but the details (SQL queries, MongoDB collections) are hidden behind the interface.

Step 3: The High-Level Service

Now, the UserService depends only on the UserRepository abstraction, not the concrete databases.

class UserService {
  private repository: UserRepository;

  constructor(repository: UserRepository) {
    this.repository = repository; // Dependency injected
  }

  async addUser(user: { id: string; name: string }) {
    await this.repository.saveUser(user);
  }
}

Step 4: Putting It Together

We can now swap databases without touching UserService.

async function main() {
  const user = { id: "1", name: "Alice" };

  // Use MongoDB
  const mongoRepo = new MongoUserRepository();
  const mongoService = new UserService(mongoRepo);
  await mongoService.addUser(user);

  // Switch to MySQL
  const mysqlRepo = new MySQLUserRepository();
  const mysqlService = new UserService(mysqlRepo);
  await mysqlService.addUser(user);

  // Switch to PostgreSQL
  const postgresRepo = new PostgresUserRepository();
  const postgresService = new UserService(postgresRepo);
  await postgresService.addUser(user);
}

main().catch(console.error);

Why DIP Shines Here

  • Flexibility: Need a new database? Just write a new repository class.
  • Testability: Mock UserRepository for unit tests.
  • Clean Code: UserService doesn’t care about database details—it’s blissfully ignorant.

Wrapping Up

DIP turns dependencies upside down: instead of UserService dictating a specific database, both rely on the UserRepository interface. This small shift makes your TypeScript apps more modular and adaptable. Try it out next time you’re wiring up a service—your future self will thank you!


Album of the day: