DEV Community

Fredy Daniel Flores Lemus
Fredy Daniel Flores Lemus

Posted on

Decoupling Code: Why Dependency Injection is a Must-Have in Programming?

https://linktr.ee/fredyflemus

Dependency Injection is a design pattern adopted by prominent frameworks such as Angular and NestJS. If you've ever worked with these frameworks, you've likely encountered Dependency Injection and grasped 'how' to implement it. But have you delved into the 'why' behind its incorporation into these frameworks?

Dependencies

We refer to a 'dependency' when, for instance, we have a class that requires an instance of another class to function – in other words, it depends on another class. For example:

class Engine {
    start() {
        return "Engine started";
    }
}

class Car {
    engine: Engine;

    constructor() {
        this.engine = new Engine();
    }

    drive() {
        return `Driving with ${this.engine.start()}`;
    }
}

// Use:
const myCar = new Car();
console.log(myCar.drive());  // Outputs: "Driving with Engine started"
Enter fullscreen mode Exit fullscreen mode

In this example, the Car class has a dependency on the Engine class. When we create an instance of Car, we also need to create an instance of Engine for it to operate correctly. That is, Car relies on Engine to execute its drive() function.

However, by creating an instance of the Engine class within the Car class constructor, we are violating one of the main principles in software development: the Inversion of Control Principle.

Inversion of Control (IoC)

This principle dictates that if you want to achieve reusable code, you should write classes that don't instantiate their dependencies on their own. To accomplish this, I'll show you two different approaches:

Firstly, instead of creating the instance of the Engine class within the Car class as we did in the previous code, we'll pass the instance as an argument to the Car class constructor.

class Engine {
    start() {
        return "Engine started";
    }
}

class Car {
    engine: Engine

    constructor(engine) {
        this.engine = engine;
    }

    drive() {
        return `Driving with ${this.engine.start()}`;
    }
}

// Use:
const myEngine = new Engine();
const myCar = new Car(myEngine);
console.log(myCar.drive());  // Outputs: "Driving with Engine started"
Enter fullscreen mode Exit fullscreen mode

The significant downside to this method is that we specifically have to pass an instance of the Engine class. This limitation is what the next approach aims to address. Instead of passing an explicit Engine instance, we'll define an interface. Then, whenever we create an instance of the Car class, all we need to provide is an object that fulfills that interface.

interface IEngine{
    start();
}

class Engine {
    start() {
        return "Engine started";
    }
}

class Car {
    engine: IEngine

    constructor(engine) {
        this.engine = engine;
    }

    drive() {
        return `Driving with ${this.engine.start()}`;
    }
}

// Use:
const myEngine = new Engine();
const myCar = new Car(myEngine);
console.log(myCar.drive());  // Outputs: "Driving with Engine started"
Enter fullscreen mode Exit fullscreen mode

This approach shines because it isn't rigid about the kind of dependency it needs. It just looks for any object that fits the defined 'blueprint' or interface. For example, it simply asks for something with a start() method. In the real application, this might be our Engine class. But when testing, it allows us the flexibility to use a mock object, making unit tests smoother and more adaptable.

However, Inversion of Control isn't without its drawbacks. For instance, when you need to create an instance of a class that relies on another class, which in turn has its own dependencies, the code can become quite verbose as you find yourself having to instantiate each class manually.

const myBattery = new Battery();
const myEngine = new ElectricEngine(myBattery);
const myCar = new Car(myEngine);
Enter fullscreen mode Exit fullscreen mode

In this usage example, you can see the step-by-step instantiation of each dependent class, which emphasizes the potential verbosity Inversion of Control can introduce.

Dependency Injection

Dependency injection is a technique that allows us to adhere to the Inversion of Control principle without the need to manually create different instances every time we require a service, component, or controller, which, in the end, are just classes in the previously mentioned frameworks.

Dependency Injection operates through something called the DI container (or Injector). This container is an object with various properties. To simplify the explanation, we'll focus on it holding two types of information.

  • List of classes and their dependencies
  • List of instances that I have created

The DI container acts like a registry, cataloging all the classes along with their associated dependencies. When it's time to instantiate a particular class, the Dependency Injection system takes charge. It automatically recognizes and creates all the necessary instances for the class's dependencies, ensuring everything is in place for smooth operation.

DI Container

In summary, the DI Container follows this flow:

  1. Upon startup, all classes are registered with the container.
  2. The container analyzes and determines the dependencies for each class.
  3. We request the container to instantiate a specific class for us.
  4. The container autonomously creates all the necessary dependencies and provides us with the desired instance.
  5. For efficiency, the container retains these instantiated dependencies, reusing them as required.

This last step, where the container retains and reuses instantiated dependencies, is commonly known as the Singleton pattern. In essence, it ensures that a class has just one instance and provides a point of access to that instance from any other part of the application.

Dependency Injection (DI) serves as a testament to the evolution of software design, placing emphasis on modular, decoupled, and testable code. Through the utilization of the DI container, we streamline the process of managing class dependencies, freeing developers from the intricacies of manual instantiation. As we embrace patterns like Singleton within this realm, we further enhance efficiency and maintainability. Ultimately, while Dependency Injection and its associated practices come with their learning curve, the benefits they offer in scalability, testability, and organization are undeniable. Embracing DI is not just about adhering to modern coding standards, but about paving the way for sustainable, adaptable software development.

Top comments (0)