DEV Community

Cover image for Inside Dependency Injection : Comprehensive Guide
Mihir Verma
Mihir Verma

Posted on

Inside Dependency Injection : Comprehensive Guide

πŸ‘‰ Introduction

In backend development, writing clean and easy to maintain code is not always simple. Many developers, especially when starting a new project, end up tightly coupling different parts of their code. This makes it harder to update, test, and scale the application later on.

I have been in situations where I needed to write unit tests, update existing code, or reuse logic but everything quickly turned into a mess.

If you’ve ever struggled to test a class because it creates its own dependencies, or tried to replace an implementation but had to change code in multiple places, then you have already experienced the pain of poor dependency management.

Here comes the "Dependency Injection" design pattern to rescue.


Topics Covered

  • What is Dependency Injection?
  • Why Should You Care?
  • Real-Life Example
  • The Traditional Approach: Problems
  • The Solution: Dependency Injection
  • Ways to Implement Dependency Injection
  • Testing with DI and Industry Libraries
  • Example of Dependency Injection in Production
  • Conclusion

πŸ‘‰ What is Dependency Injection?

Dependency Injection is a software design pattern where a class receives the objects (dependencies) it needs from the outside instead of creating them itself.

In simple terms, instead of a class creating everything on its own, the required objects pieces are provided to it. These can be other services, utilities, or helpers. This approach keeps the code clean and separates the responsibility of creating objects directly into the class itself from actually using them.

The Core Principle

A class should focus on its primary responsibility and shouldn't be concerned with how to construct or manage its dependencies. Dependencies should be "injected" into the class, typically through the constructor, setter methods, or method parameters.

This principle is closely aligned with the Dependency Inversion Principle (DIP), one of the five SOLID principles in OOPs. The DIP states that:

  • 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.

By adapting to DI you naturally follow the DIP leading to more flexible, maintainable code.


Why Should You Care about DI?

The DI provides you long term advantages for ex:

  • Testability: Easy to write unit tests by mocking dependencies.
  • Flexibility: Swap implementations without changing core code.
  • Maintainability: Clear separation of concerns between classes.
  • Reusability: Use the same dependencies across different classes.
  • Scalability: Build modular and loosely coupled backend system.
  • SOLID Application: Helps you follow the Single Responsibility Principle and other SOLID principles.

πŸ‘‰ Real Life Example

To understand Dependency Injection I have prepared a cool real life example. Imagine you are a five year old child who's hungry.

Scenario 1: Doing It Yourself (Tightly Coupled)

You tell your parents "I'm hungry!" and instead of waiting for their help you decide to get food yourself:

  • You go to the fridge and open it.
  • You see various vegetables, fruit, and an expired box of ice cream.
  • You don't know which items are safe but you grab the expired ice cream anyway.

Result: You get food poisoning or you accidentally leave the fridge door open or drop items which you don't need.

Consequences:

  • Food Poisoning. (expired food)
  • Collateral damage. (left open fridge)
  • No food safety checks.
  • You're responsible for everything.

Scenario 2: Asking for Help (Dependency Injection)

You tell your parents "I'm hungry! I want something to eat."

  • Your parents understand your need.
  • They go to the fridge. (they know how it works)
  • They select safe and fresh food which is healthy.
  • They warm it up if needed.
  • They hand you the prepared meal.

Result: You get healthy food, the fridge is fine, and no risks.

Advantages:

  • Expert handling. (parents know the fridge)
  • Safety checks. (they verify food freshness)
  • Proper preparation. (warming, etc.)
  • You focus on eating not on checking and preparing food.
  • The fridge is protected from misuse.

Mihir Verma explanins dependency injection


πŸ‘‰ The Traditional Approach (Problem)

Let's look at code that demonstrates the tightly coupled problematic approach:

import V6Engine from "@/service/engine.ts";

export default class Car {
  private engine: V6Engine;

  constructor() {
    // creating the dependency directly inside the class (tightly coupled)
    this.engine = new V6Engine(); // V6 instantiated
  }

  async startCar() {
    const petrol = true;
    const isStarted = await this.engine.start(petrol);
    return isStarted;
  }
}

// Usage
const car = new Car(); // Car creates its own engine
await car.startCar();
Enter fullscreen mode Exit fullscreen mode

Problems with this approach:

  • Hard to Test: You need a real Engine to test Car.
  • Not Extensible: Suppose you have instantiated a V6 in car which is hard coded then in future you can't use V8 Engine, V12 Engine or ElectricEngine.
  • Single Responsibility Violated: Car handles both operation and creation.
  • Not Reusable: Can't share same Engine instance across multiple cars.

πŸ‘‰ The Solution (Dependency Injection)

Now, let's refactor the code to use Dependency Injection.

import V8Engine from "@/service/engine.ts";
import V12Engine from "@/service/engine.ts";

export default class Car {
  private engine: Engine;

  // The dependency is now injected through the constructor
  constructor(engine: Engine) {
    this.engine = engine;
  }

  async startCar() {
    const petrol = true;
    const isStarted = await this.engine.start(petrol);
    return isStarted;
  }
}

// Usage
const v8Engine = new V8Engine();
const v12Engine = new V12Engine();

const sportsCar = new Car(v8Engine); // Pass the v8 engine
const hyperCar = new Car(v12Engine); // Use v12 in same car body

await sportsCar.startCar();
await hyperCar.startCar();
Enter fullscreen mode Exit fullscreen mode

Instantly solves all problems:

  • Easy to test with mock engines.
  • Supports any engine type via interfaces.
  • Car only manages cars. (single responsibility)
  • Reuse the same engine across same/multiple cars.


πŸ‘‰ Ways to Implement Dependency Injection

There are three primary ways to implement DI:

1. Constructor Injection (Most Used)

class UserService {
  constructor(private database: Database) {}

  async getAllUsers(id: string) {
    return this.database.query(`SELECT * FROM users`);
  }
}

const database = new Database(); // database instance
const userService = new UserService(database); //injected to userSrv
Enter fullscreen mode Exit fullscreen mode
  • βœ… Dependencies passed via constructor.
  • ❌ Long parameter lists can become unwieldy.

2. Setter Injection

class UserService {
  private database: Database;

  setDatabase(database: Database) {
    this.database = database; // setter method
  }

  async getAllUsers(id: string) {
    return this.database.query(`SELECT * FROM users`);
  }
}

const userService = new UserService();
userService.setDatabase(new Database()); // inject after creation
Enter fullscreen mode Exit fullscreen mode
  • βœ… Flexibility to change dependencies after creation.
  • βœ… Useful for optional dependencies.
  • ❌ Object might be in an invalid state if setter isn't called.
  • ❌ Less clear and more error-prone.

3. Method Injection

class UserService {
  async getUserById(userId: string, database: Database) {
    // use the provided database
    return database.query(`SELECT * FROM users`);
  }
}

const database = new Database();
const userService = new UserService();
const user = userService.getUserById('123', database); // pass db
Enter fullscreen mode Exit fullscreen mode
  • βœ… Maximum flexibility as different methods can use different dependencies.
  • ❌ Repetitive code as the dependencies must be passed to every method call.

πŸ‘‰ Testing With DI

// mock implementation of engine dependency
class MockEngine {
  async start(petrol: boolean) {
    return true;
  }
}

describe('Car', () => {
  it('should start the car', async () => {
    const mockEngine = new MockEngine();
    const car = new Car(mockEngine); // can pass mock implementation
    const result = await car.startCar();
    expect(result).toBe(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

πŸ‘‰ DI Frameworks & Libraries for Node.js

  • NestJS: Best for full backend applications.
  • TSyringe: Best for lightweight TypeScript projects (Easy).
  • TypeDI: Best for complex applications.
  • Awilix: Best for flexible setups.

Mihir Verma explanins dependency injection frameworks
My Recommendation: Use NestJS for full backend apps and TSyringe for simple or lightweight projects.


πŸ‘‰ Example of Dependency Injection in Production

// 1. Define interfaces (contracts)
interface IDatabase {
  query(sql: string): Promise<any[]>;
}

interface IUserRepository {
  getUserById(id: string): Promise<User | null>;
}

// 2. Implement services
class PostgresDatabase implements IDatabase {
  async query(sql: string) {
    // Real DB implementation
    return [];
  }
}

class UserRepository implements IUserRepository {
  constructor(private db: IDatabase) {}

  async getAllUsers(id: string) {
    const results = await this.db.query(`SELECT * FROM users`);
    return results[0] || null;
  }
}

// 3. Use in controllers
class UserController {
  constructor(private userRepo: IUserRepository) {}

  async handleGetUser(req, res) {
    const user = await this.userRepo.getAllUsers(req.params.id);
    res.json(user);
  }
}

// 4. Wire everything together
const db = new PostgresDatabase();
const userRepo = new UserRepository(db);
const controller = new UserController(userRepo);
Enter fullscreen mode Exit fullscreen mode

Mihir Verma explanins dependency injection
If you want to see how this works in a real project, feel free to check out this repository: https://github.com/mihirverma7781/Coworkz.Space.

It’s not perfect but it should give you a solid starting point for using Dependency Injection in your node.js projects.


πŸ‘‰ Conclusion

Dependency Injection is more than a design pattern it's a philosophy of building modular, testable, and maintainable software. By inverting the control of dependency creation, you gain: Testability, Flexibility, Scalability, Maintainability, Reusability.

Key Takeaways

  • Don't create dependencies inside your classes have them injected.
  • Depend on abstractions, not concrete implementations. (use interfaces)
  • Constructor injection is usually simplest and best.
  • Test everything with mock implementations. (DI helps us achieve this)

I Hope now you have a better understanding of the Dependency Injection design pattern.

If you like the content please share it with your friends, colleagues, and coding buddies. Have a nice day ❀️.

Top comments (0)