Talking about Inversion of Control and Dependency Injection in Frontend Development

May 23, 2022 (3y ago)

Background

Frontend technology, centered around Web technologies, has developed particularly rapidly in recent years. Many frontend technologies such as Typescript and Angular have emerged, supporting the birth of epic projects like VSCode. In these large-scale engineering projects, architects have deeply practiced the object-oriented (OO) programming paradigm to effectively control complexity under such large-scale collaboration. Among them, Inversion of Control (hereinafter referred to as IoC) and Dependency Injection (hereinafter referred to as DI) are two technical means that are widely used.

This article aims to discuss, from a frontend perspective using Typescript as the programming language, how to use mechanisms like IoC and DI to effortlessly manage code dependencies, reuse, and extension in large-scale frontend projects.

What is Inversion of Control

First, let's talk about what Inversion of Control is. Suppose we are a car manufacturing company, and we have our own suppliers for car parts. In the early stages, we worked closely with our suppliers to co-create and establish a dependency on a specific car engine:

import { V8Engine } from 'vendor';
 
class CarA {
  private engine: unknown;
  constructor() {
    this.engine = new V8Engine();
  }
  drive() {
    // ... drive ... 🚗
  }
}
 
const car = new CarA();

At first glance, there seems to be no problem. However, as time goes by, we may want to replace the existing engine with a newer version, such as V9. If all our car models directly depend on the V8Engine provided by the vendor, this tight coupling makes it very inflexible to adapt to changes - a single change can have widespread effects. To solve this problem, we adopted the idea of IoC, so that we do not directly depend on the specific design of V8Engine, but rather on an abstract design of Engine, namely: the standard of the engine.

In the context of program design, a very important concept in Object-Oriented Design (OO Design) is Programming to an Interface(Interface Oriented). An interface describes the standard specifications that classes and other abstract structures should follow. Therefore, we upgrade our code to make the implementations depend on interfaces rather than other specific implementations.

First, we define an abstract interface that can describe the specifications of the Engine:

// standard-engine-interface.ts
export interface IEngine {
  // Number of cylinders in the engine
  cylinders: number;
  // ... other properties
  // Engine start function
  start(): void;
  // Engine stop function
  stop(): void;
  // ... other methods
}

Next, suppliers only need to implement the specific engine according to the engine interface, and we implement the necessary interfaces for the engine class according to IEngine:

import { IEngine } from 'standard-engine-interface';
 
export class V9Engine implements IEngine {
  cylinders = 4;
  start() {
  	// start engine
  }
  stop() {
    // stop engine
  }
}
 
export class V8Engine implements IEngine {
  cylinders = 2;
  start() {
  	// start engine
  }
  stop() {
    // stop engine
  }
}
 

Next, for the same car model, we can try to equip it with different engines:

import { IEngine } from 'standard-engine-interface';
import { V8Engine, V9Engine } from 'vendor';
 
export class CarB {
  private engine: IEngine;
  constructor(engine: IEngine) {
  	this.engine = engine;
  }
}
 
const carV9 = new CarB(new V9Engine());
const carV8 = new CarB(new V8Engine());

This is the classic principle of Inversion of Control (IoC) in object-oriented program design:

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.

Our car manufacturing factory no longer depends on the specific model of the engine for assembly, but on the design standard of the engine. Program design is also like this. Implementation no longer depends on specific implementations, but on abstract interfaces. When instances change, the upper-level modules can replace the lower-level modules without any awareness, which is wonderful, isn't it? A UML structure diagram can further clarify the structural changes in OO design:

Before: CarA and V8Engine are tightly coupled (directly coupled).

image.png

After: CarA, V8Engine, and V9Engine all depend on an abstract interface, namely IEngine.
image.png

The benefit of this approach is that in the future, as long as the Engine type implements the interfaces required by IEngine, it can be compatible with CarA, and CarA can also replace the specific Engine type without any awareness.

What is Dependency Injection

Once we understand IoC, DI becomes easier to understand.

Let's continue with the car manufacturing example to explain how IoC and DI work together to solve practical production problems.

Imagine in the car manufacturing process, the engine (Engine) also depends on the bearings (Bearing) of the engine, and there are many specifications for bearings. Therefore, when we produce a car (Car), we not only need to manufacture a suitable engine upstream, but the upstream supplier also needs to request the required bearings from its upstream supplier, and so on. This creates a complex production relationship, which in program design, we call the dependency network.

If we were to illustrate this with code, it would look like this:

const car = new CarA(new V8Engine(new Bearing(new BearingMaterial(), ... )));

Dependency injection mainly helps us systematically analyze the dependency network, encapsulating and simplifying the production process of an object or a class. The dependency injection framework is essentially a production factory for classes or objects.

A simple DI system can dynamically resolve the objects or other data types required by the caller at runtime. In strongly typed language design, this is an important way to achieve decoupling.

In TS, it would be something like this:

// The provider of the dependency can provide the constructor (class) of the engine and bearing through the bindDependency method
// Bind the specific classes of the engine and bearing to the production line of the factory
DIFactory.bindDependency('engine', V8Engine);
DIFactory.bindDependency('bearing', Bearing);
// ...
 
// When it's time to get the car (get), the factory will start the car manufacturing process according to the bound production line
// The complex "assembly process": dependency analysis, object instantiation, etc. will be handled by this dependency factory function
const car = DIFactory.get<ICar>('car');
car.drive();

We have encapsulated the assembly process, and with a simple get function, we have achieved the assembly and installation of car parts. This dynamic combination and assembly process (the process of instantiating the object dependency network) is the process of dependency injection.

Let's break it down a bit more:

  • The identifiers like 'engine', 'car', and 'bearing' are generally called dependency injection Tokens. They are usually designed to be globally unique, used to mark unique types.
  • The bindDependency function is what we call dependency binding, which associates the type implementation (class, object, etc.) with the Inversion of Control container (IoCContainer, also called Injector in some frameworks).
  • The get function is a process of object resolution (analyzing dependency relationships, assembling and generating objects). The dependency injection framework analyzes the type corresponding to the requested dependency injection Token, recursively analyzes its dependencies, and finally instantiates these objects to complete the assembly of the final object.

A well-known Inversion of Control framework in the industry is Inversify.

:::info Earlier, I analyzed the implementation principle of Inversify 5.0. I won't go into detail here. If you are interested in dependency injection frameworks, you can read this article for in-depth understanding. The key classes used in the framework's implementation are referenced in the UML diagram below, which can provide a preliminary impression of the framework. :::

image.png
Figure: Main classes and object relationships in the Inversify framework

Exploring Best Practices

Having understood DI and IoC, let's combine practical cases and try to find the current best practices for DI and IoC in frontend projects, especially in terms of program structure design and engineering to find a design balance.

Angular's Implementation Mechanism

This section may be relatively obscure for those who are not familiar with Angular. You may skip to the next section.

I have always believed that Angular is a framework most suitable for building complex Web UI systems. It has a very complete modular (Modular) and DI system, especially suitable for frontend teams with rich OO experience. Taking several key types defined in Angular 12 as an example:

Angular's main types are divided into the structure above. It can be seen that the service (Service), as a provider (Provider) of Angular's dependency injection system, is injected into Angular's Injector (dependency injector) through the @injectable() decorator. In NgModule / Directive / Component, the service can be provided in a declarative way on the class decorator (Decorator). When Angular's runtime instantiates these objects, it uses the internal Injector to resolve the objects that meet the configuration requirements and inject them into the constructor function.

import { Injectable } from '@angular/core';
import { HEROES } from './mock-heroes';
import { Engine } from '../engine.service';
 
@Injectable({
  providedIn: 'root',
})
export class CarService {
  constructor(private engineService: Engine) {  }
  getEngine() {
    this.engineService.start('start ...');
    return this.engineService;
  }
}

Its DI form is quite elegant 😎 ~

:::info I have previously analyzed the source code of Angular 12's DI system. Interested readers can move to this article to gain an in-depth understanding of its design and implementation ideas. :::

Building a DI Business Framework Based on Inversify

After looking at the system design of Angular, for a big factory like Ali that uses React, how should we implement a similar DI system in the business to achieve IoC? Here, I will provide some practical thoughts and discuss the best practice paradigms with you.

Assumed readers:

1. Using Decorators to Implement Dependency Injection System

💍 Decorators to rule them all !

Thanks to the design of Java's Annotation System, Typescript provides a similar Decorator method. This allows for the implementation of DI system requirements in a clear, understandable, and minimally invasive way through decorators for classes, constructor parameters, properties, etc. Let's continue to use the car manufacturing example to see the basic API usage of Inversify:

import { Container, injectable, inject } from "inversify";
 
// Declare that V8Engine can be recognized by the dependency injection framework
@injectable()
class V8Engine {
    public start() {
      	// ... 🏁 ...
        return "v8";
    }
}
 
@injectable()
class V9Engine {
    public start() {
        // ... 🏁 ...
        return "v9";
    }
}
 
// Declare that the Car class can be recognized by the dependency injection framework
@injectable()
class Car implements ICar {
    private engine: V8Engine;
    public constructor(engine: V8Engine) {
        this.engine = engine;
    }
    public start() { return this.engine.start(); };
    public stop() { return this.engine.stop(); };
}
 
// Create a dependency injection container
const container = new Container();
// Perform dependency binding
container.bind<IEngine>(V8Engine).to(V8Engine);
container.bind<IEngine>(V9Engine).to(V9Engine);
container.bind<ICar>(Car).to(Car);
 
// Resolve the Car instance, and pass the V8Engine instance to the constructor of Car as a parameter to initialize the Car instance
container.get(Car);

We can see that the entire DI process is completed by the @injectable() decorator, which lightly invades the DI framework, allowing us to associate with the framework with very little code.

2. Further Encapsulation of the IoC Container

🚀 Inversify itself is a good framework, but its various fancy API usages also bring a higher cognitive cost to the business layer. We can consider cleverly encapsulating some methods to provide to the upper layer for use, in order to reduce the "cognitive cost". Inversify still has a certain learning cost, with over 40+ APIs. Directly using it can be very unfriendly to beginners and novice users. Taking the 👇 document as an example, understanding the usage of Inversify requires reading the following articles at least once to form an understanding. image.png

Inversify provides a relatively primitive version of the IoC container, offering the most atomic functions. We can actually further encapsulate it to make it easier for users to use, without worrying about the implementation details.

Here are a few feasible design rules:

  • The business dependency injection framework should only resolve object types (single type resolution can reduce a lot of understanding cost. In fact, objects can cover 99% of designs, and can avoid the cognitive cost brought by complex API usage).
  • Automate the bind process in the business framework, so that developers do not have to worry about the bind process.
  • Provide stability guarantees for the get process in the IoC container layer, so that there are fallback (downgrade) solutions in case of resolution errors.
  • Integrate performance analysis plugins for IoC resolution in the IoC container layer, which can perform high-static analysis on high-performance operations.
  • Provide a configuration-based way in the IoC container layer to encapsulate the Inversify API, so that the business framework users do not need to care about the underlying implementation of the Inversify container.
  • ...

The IoC container can do a lot of things at the business layer to shield the underlying complexity and provide simple solutions for business development. For the users, they only need to know that there is a get method.


Arno Crafting Apps

ELABORATION STUDIO 🦄

Elaborate your ideas and solve your problems with AI in fully boosted context way ~