Demystifying Dependency Injection: An Essential Guide for Software Developers

Boosting code quality and maintainability with Dependency Injection

Pavlo Kolodka
JavaScript in Plain English

--

Photo by Nick Morrison on Unsplash

Dependency injection (DI) is a design pattern and programming technique to manage dependencies between different components.

In DI, the dependencies of a class or other dependent component are constructed and provided from outside (injected) rather that begin created by depended component.

Understanding dependency injection is key to following the dependency inversion principle.

Core components

The three main components of Dependency Injection are:

  1. Dependency: A dependency is an object or service that another class relies on to perform its tasks. It represents a contract or interface that defines the required functionality.
  2. Client: The dependent class, also known as the client class, is the class that relies on dependency to fulfill its functionality. It typically declares a dependency through constructor parameters, setter methods, or interface contracts.
  3. Injector: The injector (aka container, assembler, factory) is responsible for creating and managing the dependencies and injecting them into the dependent class (client). The injector can be a framework or container provided by a DI library, or it can be a custom implementation.

These three components work together to enable the benefits of DI, such as loose coupling, modularity, testability, and flexibility.

The dependent class relies on the injector or container to provide the required dependencies, and the dependencies themselves are defined as contracts or interfaces, allowing for interchangeable implementations. This separation of concerns and inversion of control leads to more maintainable and scalable code.

Implementation

As an example, let’s consider two classes: Engine and Car.

To construct an instance of the Car class, we need an appropriate Engine object.

class Engine {
private horsepower: number;
private fuelType: string;

constructor(horsepower: number, fuelType: string) {
this.horsepower = horsepower;
this.fuelType = fuelType;
}

public start() {
console.log(`Engine started. Horsepower: ${this.horsepower}, Fuel Type: ${this.fuelType}`);
}

public stop() {
console.log("Engine stopped.");
}
}

class Car {
private engine: Engine;
private brand: string;
private model: string;

constructor(brand: string, model: string) {
this.brand = brand;
this.model = model;
this.engine = new Engine(200, "Gasoline");
}

public startCar() {
console.log(`Starting ${this.brand} ${this.model}`);
this.engine.start();
}

public stopCar() {
console.log(`Stopping ${this.brand} ${this.model}`);
this.engine.stop();
}
}

// Example usage
const car = new Car("Toyota", "Camry");

car.startCar();
car.stopCar();

// To consturct a car with Gasoline engine required a manual edit of Car class

For now, it works fine, except if we need to pass different parameters to the Engine class, it’s required a manual edit.

Map of dependencies between the Car and Engine classes

Parameters injection

To resolve such a problem we can take advantage of parameters injection. Let’s rewrite the current code.


class Engine {
// same implementation
}

class Car {
private engine: Engine;
private brand: string;
private model: string;

constructor(brand: string, model: string, horsepower: number, fuelType: string) {
this.brand = brand;
this.model = model;
this.engine = new Engine(horsepower, fuelType);
}

public startCar() {
console.log(`Starting ${this.brand} ${this.model}`);
this.engine.start();
}

public stopCar() {
console.log(`Stopping ${this.brand} ${this.model}`);
this.engine.stop();
}
}

// Example usage
const car1 = new Car("Toyota", "Camry", 200, "Gasoline");

car1.startCar();
car1.stopCar();

// Easy change Engine parameters
const car2 = new Car("BMW", "X5", 300, "Diesel");

car2.startCar();
car2.stopCar();

Now the general logic does not change; instead, we can easily make changes according to our needs.

Map of dependencies between the Car and Engine classes

Constructor/setter injection

In the previous example, we used parameter injection to change horsepower and fuelType for Engine class. However, it may become cumbersome when dealing with a large number of dependencies.

To make these 2 classes more flexible to change and testing, it is customary to create the necessary dependency outside the dependent class. You can attain this outcome by utilizing a constructor or setter injection.

class Engine {
// same implementation
}

class Car {
private engine: Engine;
private brand: string;
private model: string;

constructor(brand: string, model: string, engine: Engine) {
this.brand = brand;
this.model = model;
this.engine = engine;
}

public startCar() {
// same logic
}

public stopCar() {
// same logic
}
}

// Example usage
const gasolineEngine = new Engine(200, "Gasoline");
const car1 = new Car("Toyota", "Camry", gasolineEngine);

car1.startCar();
car1.stopCar();

// Easy change Engine parameters
const dieselEngine = new Engine(300, "Diesel");
const car2 = new Car("BMW", "X5", dieselEngine);

car2.startCar();
car2.stopCar();

By removing the responsibility of creating the engine instance from the Car class, you adhere to the Single Responsibility Principle. The Car class should focus on its own responsibilities related to the car's behavior, while the engine creation and configuration can be handled in a different part of the code.

The same realization, but using setter injection:

class Engine {
// same implementation
}

class Car {
private brand: string;
private model: string;
private engine: Engine;

constructor(brand: string, model: string) {
this.brand = brand;
this.model = model;
}

public setEngine(engine: Engine) {
this.engine = engine;
}

public startCar() {
// same logic
}

public stopCar() {
// same logic
}
}

// Example usage
const gasolineEngine = new Engine(200, "Gasoline");
const car1 = new Car("Toyota", "Camry");
car1.setEngine(gasolineEngine);

car1.startCar();
car1.stopCar();


const dieselEngine = new Engine(300, "Diesel");
const car2 = new Car("BMW", "X5");
car2.setEngine(dieselEngine);

car2.startCar();
car2.stopCar();
Map of dependencies between the Car and Engine classes

Interface injection

Right now, the current implementation of Car is tied to a specific Engine class. This can be a problem if individual instances of the Engine class requires different logic.

To make the Engine and Car classes more loosely coupled, we can bind Car to an interface (or abstract class as an interface) instead of a specific child Engine class.

interface Engine {
start(): void;
stop(): void;
}

class GasolineEngine implements Engine {
private horsepower: number;
private fuelType: string;

constructor(horsepower: number) {
this.horsepower = horsepower;
this.fuelType = "Gasoline";
}

public start() {
console.log(`Gasoline engine started. Horsepower: ${this.horsepower}`);
}

public stop() {
console.log("Gasoline engine stopped.");
}
}

class DieselEngine implements Engine {
private horsepower: number;
private fuelType: string;

constructor(horsepower: number) {
this.horsepower = horsepower;
this.fuelType = "Diesel";
}

public start() {
console.log(`Diesel engine started. Horsepower: ${this.horsepower}`);
}

public stop() {
console.log("Diesel engine stopped.");
}
}

class Car {
private engine: Engine;
private brand: string;
private model: string;

// class Car expect any valid Engine implementation
constructor(brand: string, model: string, engine: Engine) {
this.brand = brand;
this.model = model;
this.engine = engine;
}

public startCar() {
// same logic
}

public stopCar() {
// same logic
}
}

// Example usage
const gasolineEngine = new GasolineEngine(200);
const car1 = new Car("Toyota", "Camry", gasolineEngine);

car1.startCar();
car1.stopCar();

const dieselEngine = new DieselEngine(300);
const car2 = new Car("BMW", "X5", dieselEngine);

car2.startCar();
car2.stopCar();

Now the Car class is decoupled from the specific implementation of the Engine class. This allows you to easily substitute different engine types without modifying the Car class itself.

Map of dependencies between the Car and Engine classes

Injectors

So far, I’ve been talking only about dependencies and clients.

Manual creation of dependencies can be painful. Especially if there are multiple levels of nesting. That’s where injectors come in.

The injector resolves the dependencies and provides them to the client class. You can create your own algorithm for registering and injecting dependencies, or you can use DI containers or DI frameworks that will do this for you.

Examples for JavaSript/TypeScript are InversifyJS, Awilix, TypeDI, and NestJS, for C# — ASP.NET Core Dependency Injection, Java — Spring Framework, and Go — Google Wire.

Let’s rewrite the last implementation with an interface injection using the TypeDI container:

import { Service, Inject, Container } from 'typedi';
import 'reflect-metadata';

interface Engine {
start(): void;
stop(): void;
}

@Service()
class GasolineEngine implements Engine {
private horsepower: number;
private fuelType: string;

constructor(@Inject('horsepower') horsepower: number) {
this.horsepower = horsepower;
this.fuelType = 'Gasoline';
}

start() {
console.log(`Gasoline engine started. Horsepower: ${this.horsepower}`);
}

stop() {
console.log('Gasoline engine stopped.');
}
}

@Service()
class DieselEngine implements Engine {
private horsepower: number;
private fuelType: string;

constructor(@Inject('horsepower') horsepower: number) {
this.horsepower = horsepower;
this.fuelType = 'Diesel';
}

start() {
console.log(`Diesel engine started. Horsepower: ${this.horsepower}`);
}

stop() {
console.log('Diesel engine stopped.');
}
}

@Service()
class Car {
private engine: Engine;
private brand: string;
private model: string;

constructor(@Inject('brand') brand: string, @Inject('model') model: string, @Inject('engine') engine: Engine) {
this.brand = brand;
this.model = model;
this.engine = engine;
}

public startCar() {
console.log(`Starting ${this.brand} ${this.model}`);
this.engine.start();
}

public stopCar() {
console.log(`Stopping ${this.brand} ${this.model}`);
this.engine.stop();
}
}

// Register dependencies with the container
Container.set('horsepower', 200);
Container.set('brand', 'Toyota');
Container.set('model', 'Camry');
Container.set({ id: 'engine', type: GasolineEngine });
Container.set({ id: Car, type: Car });

Container.set('horsepower', 300);
Container.set('brand', 'BMW');
Container.set('model', 'X5');
Container.set({ id: 'engine', type: DieselEngine });
Container.set({ id: Car, type: Car });

// Example usage
const car1 = Container.get(Car);
car1.startCar();
car1.stopCar();

const car2 = Container.get(Car);
car2.startCar();
car2.stopCar();

// console.log:
Starting Toyota Camry
Gasoline engine started. Horsepower: 200
Stopping Toyota Camry
Gasoline engine stopped.
Starting BMW X5
Diesel engine started. Horsepower: 300
Stopping BMW X5
Diesel engine stopped.

Using a DI container simplifies dependency and client management. This not only allows you to create a complex dependency graph but also makes it easy to test components with stubs and mocks.

Conclusion

In summary, Dependency injection is a valuable technique for designing flexible, modular, and testable software systems. It promotes loose coupling, enhances code reusability, and simplifies the configuration and management of dependencies.

By adopting DI, you can write more maintainable, scalable, and robust applications.

References:

  1. Wikipedia: Dependency injection
  2. Martin Fowler: Inversion of Control Containers and the Dependency Injection pattern

--

--

Passionate Software Engineer with a love for learning and sharing interesting things.