Written by Filip SkibiΕ„ski
Published August 22, 2023

How to inject dependencies in TypeScript?

 

An object-oriented approach in the JS world

The development of many front-end applications is oriented rather to the functional and declarative paradigm. This approach facilitates solving problems specific to this type of software – e.g. making the displayed view a function of the application state.

In the case of backend applications, the same solutions do not always appear to work as well when reaching for more classic methods long present in frameworks like Spring or ASP.NET. In this article, I would like to discuss one widely known object-oriented programming design pattern that significantly affects the final application architecture: Inversion of Control and its implementation – Dependency Injection.

πŸ€” What problems do we want to solve?

Before we move on to the practical part, let’s discuss the specific needs we can meet through this method.

Applications written in object-oriented languages usually create a complicated structure of components (classes), in which some are dependencies of others. Following a traditional approach, classes manage their own dependencies. Due to the scale effect, this can lead to multiple issues:

  • when application components are tightly coupled, it can be challenging to modify or extend the system, resulting in inflexibility;
  • creating direct dependencies between components can make it difficult to test individual components in isolation;
  • components may need to duplicate code to create and manage their own dependencies;
  • tight coupling between components can limit their reusability in other contexts.

In general terms, Inversion of Control (IoC) is about inverting the traditional control flow in an application. Instead of letting its components create and manage their own dependencies (in the form of other components of the app, e.g. particular services), we delegate this responsibility to an external entity, e.g. an IoC container or framework – depending on the specific implementation.

This pattern is also related to the loose coupling (as opposed to tight coupling) system design principle – which promotes building components to have minimal dependencies on each other. Thanks to that, individual components in a system can be modified, replaced or extended without affecting the rest of the application because every component is encapsulated and does not rely on extensive knowledge of other components.

Dependency Injection (DI) is a specific technique for achieving IoC in our system. When following this approach, dependencies are “injected” as needed into a component by an IoC entity. They should be declared as such in the form of a constructor parameter, method parameter or property in a class (our component) – the rest will be done “outside” by the tool we use.

Overall, IoC and its implementation – DI, enable us to make our code more modular, flexible and testable and, thus, improve its maintainability. This technique can also have a positive impact on the performance of our system because we rely on a dedicated tool that instantiates the dependencies and – at the same time – controls the number of those instances.

It is worth stressing that, for the reasons discussed above, IoC/DI adheres to the principle sewn under the letter D in the acronym SOLID – Dependency Inversion.

βš™οΈ Event handlers-based approach

Moving on to the practical part, let’s look at how we implement the logic following a more “classical” approach based on event handlers which is characteristic of, e.g. the Express.js web framework.

Below is an example of a simple endpoint part of a specific CRUD API. It is relatively simple and focused instead on presenting a general pattern than a more complex implementation.

import express, { Request, Response, NextFunction } from 'express';
import { initialiseDatabase } from './database/config';

const app = express();
app.use(express.json());

const databaseConnection = await initialiseDatabase();

const authenticate =
    (req: Request, res: Response, next: NextFunction) =>
        req.isAuthenticated() ? next() : res.status(401).send();

const getUserById = async (id: number) =>
    await databaseConnection.userRepository.findOne({ id });

app.get('api/user/:id', authenticate, async (req, res) => {
   try {
      const user = await getUserById(parseInt(req.params.id, 10));
      res.send(user);
   } catch (error) {
      console.error(error)
      res.status(500).send('An error occurred');
   }
});

The first thing that immediately becomes apparent is the extensive usage of functions that return specific data or handle incoming requests (as events) accordingly. The request is processed through a series of middleware – functions arranged to resemble the functional pipeline pattern. However, due to potential modification made on the request (req) object, it can’t be called a “fully functional” approach.

So in effect, we don’t instantiate a structure of objects communicating with each other – we provide functions in a specific order. It’s not an issue for someone who is used to functional patterns and JavaScript specificity, but as I noticed, it is often unclear for many backend developers from other languages.

Also, in a standard Express.js app, we have less control over typings than other object-oriented frameworks. It’s not so easy to, e.g. validate legibly if someone performing a request to our API did provide proper body or parameters. We must proceed with such a check in one of the passed middleware functions. That’s related to another issue: When using TypeScript, the next middleware (handler) in the chain won’t know if – following the example above – req.params.id does exist. Even if our validation middleware confirmed it, this information is not inferred automatically without additional configuration.

Similarly, we can’t quickly determine the type returned by an endpoint without checking the exact implementation details.

Aside from that, the route of an endpoint is a function argument. Some people could consider it as not very legible. Even if we’d like to create an Express.js Router object, we need to keep this convention in our code. Many popular backend frameworks (such as Sprint or ASP.NET) offer a slightly different (and more precise/straightforward, in my opinion) way to do that – we’ll see it later in the article.

And finally, the same functionality could be written in many other ways. Express.js doesn’t provide any strict structure or convention. Depending on the project, it can benefit some developers but also lead to messy and hard-to-maintain code.

Of course, the inconveniences mentioned above do not mean that Express.js is a poor tool, but rather are related to the functional nature of JavaScript and the default approach when creating many tools in its ecosystem πŸ˜‰

🧰 Switching to classes

Now, let’s try to write the same endpoint in a more object-oriented manner. For this purpose, we can use three libraries offering individual functionalities we need:

 

Of course, there are “all-in-one” frameworks such as NestJS that offer analogical solutions (with differences mainly in details). However, introducing them in this article would unnecessarily complicate the example due to the complexity and comprehensiveness of such tools as frameworks.

Also, the article’s primary topic refers to the Dependency Injection pattern, but I also cover some subject-related aspects like routing to complete the picture of a more object-oriented approach.

What’s important, it’s necessary to provide some additional configuration to handle the given code structure – you can read more about this in the docs of particular packages and the resources linked at the end of this document.

You will also notice that all examples below are ES6 class-based and (almost) don’t make use of “standalone” functions present in the previous “pure Express.js” example. Therefore, familiarity with the class syntax and OOP concepts is required.

This time, let’s divide the code into individual files and discuss them one by one. We will omit the main entry file (index.js) and other config files as they are rather generic and irrelevant to our considerations.

// our-project/modules/user/controllers/user.ts

import {
  Get,
  JsonController,
} from "routing-controllers";
import { Service } from "typedi";

import { IdAndBody } from "../decorators/id-and-body";
import { UserService } from "../services/user";
import { GetUserDTO } from "../dto/get-user";

@Service()
@JsonController("api/user")
export class UserController {
  constructor(
    private readonly userService: UserService,
  ) {}

  @Get("/:id")
  getUserById(@IdAndBody() dto: GetUserDTO) {
    const user = this.userService.getUserById(dto);
    return user;
  }

A Controller class is a gateway between the input (data passed as request parameters/body) and the domain logic. It decides what to do with the input and how to output a response to a request.

In this file, we should pay attention to the following elements:

  • @Service is an annotation (a decorator in TypeScript nomenclature) marking a class as an injectable service or as one able to receive injectable dependencies – here: UserService.
  • @JsonController marks a whole class as a controller providing methods returning JSON-formatted responses with an appropriate Content-Type header set. The argument passed to the annotation (api/user) is a route enabling to call the controller using HTTP.
  • @Get marks a class method as a GET action with a parameterised sub-route (/:id) as its argument.
  • @IdAndBody is a custom decorator that merges the id provided as a request parameter with its body to a single DTO. You can find the implementation of this decorator right below the discussed file.
  • GetUserDTO is a Data Transfer Object dedicated to this particular method. It will be covered in more detail later.

Notice that the class constructor only contains a parameter with its type definition. UserService is not instantiated anywhere in the class, only marked as a read-only dependency and used in particular methods. The rest of the work (including creating, passing and maintaining objects) is taken over by the IoC Container 😎

After launching the app, the routing-controllers package instantiates all controllers and typedi – the whole chain of dependencies in particular classes. Thanks to that, all affected classes will reuse the same cached instance of a dependency.

// our-project/modules/user/decorators/id-and-body.ts

import { createParamDecorator } from "routing-controllers";
import { Request } from "express";

export const IdAndBody = () =>
  createParamDecorator({
    value: (action) => ({
      id: (action.request as Request).params?.id,
      ...((action.request as Request).body as Record<string, string>),
    }),
  });

As mentioned above, IdAndBody is a custom decorator written to merge the id query parameter and the request body into a single Data Transfer Object. This step allows for keeping a clean API routing and, at once – a clear and consistent structure of data passed from one endpoint layer (a controller) to another (a service).

You may see that this decorator is a function – and this will be a highly accurate observation because all TypeScript decorators are indeed functions tied up to class methods marked with themΒ  (or all class methods when annotating a whole class) and executed at a certain point when a class method is called. Since releasing TypeScript 5.0, they are officially part of the language and don’t require using an experimental flag anymore.

Also, (action.request as Request).params?.id may look very familiar to what we did in the “pure Express.js” approach. Not without reason – many OOP solutions in the JS environment are layers over Express.js (or another HTTP framework) and can be considered a form of syntactic sugar.

// our-project/modules/user/services/user.ts
import { Service } from "typedi";
import { DataSource, Repository } from "typeorm";

import { User } from "../entities/user";
import { GetUserDTO } from "../dto/get-user";

@Service()
export class UserService {
  private readonly repository: Repository<User>;

  constructor(private readonly dataSource: DataSource) {
    this.repository = this.dataSource.getRepository(User);
  }

  public async getUserById(dto: GetUserDTO) {
    const userFound = await this.repository.findOne({
      relations: { assignments: { team: true } },
      where: dto,
    });
    return userFound;
  }
}

A Service class is a layer “in the middle” between a Controller and a Repository. It takes responsibility for the execution of the business logic of our API. In order not to break the Dependency Injection SOLID principle, it should be the only layer having access to the repository classes.

As you can see in the example above, the dependency of this service is a generic TypeORM Repository<User> class. To not expand the scope of this article, I’d like to emphasise that a repository is an abstraction layer to our database access and provides a gateway between our domain layer and a data mapping layer.

In this context, User is an entity corresponding to a specific database table and containing fields mapped to its appropriate columns – such as user first name, last name, creation date etc. The Repository allows us to access this data via a mapper (ORM).

Thanks to that, UserService can access this data, transform it to an expected form and return it to UserController.

Ultimately, we preserve a consistent and easily trackable structure between all layers. Repository<User> is a dependency of UserService and the service – of UserController. All of them are instantiated, cached, injected and maintained by the IoC container provided by the typedi package. We just need to declare in particular layers that we require specific dependencies.

// our-project/modules/user/dto/get-user.ts

import { IsString, IsUUID } from "class-validator";

export class GetUserDTO {
  @IsString()
  @IsUUID(4)
  id: string;
}

Finally, let’s take a look at GetUserDTO. At first glance, you can see that it’s devoted to a precise controller method. This is one of the possible approaches, but of course, we can also create, for example, a structure of classes containing specific fields.

It’s a Data Transfer Object class and follows a well-known pattern in the OOP world. Based on the Spring framework, I included an excellent explanation of it in the resources. In general, it’s an object being a data carrier between particular processes in the API, which should reduce the number of method calls. In this case, it serves us to specify the required input data passed from the Controller to further layers.

It also has two decorators above its only field. Both come from the class-validator package and check whether the passed id meets the method requirements. It may look a bit clearer than performing the same check in one of the not-so-well-typed middleware in a chain πŸ˜‰

πŸ“ Conclusion

JavaScript on the backend offers very different possibilities for structuring an API. In addition to more function-oriented patterns, it is also possible to base an application on a class structure.

This way, we can use the experience gained from other popular languages and frameworks – such as Spring Boot or ASP.NET. Also, object-oriented solutions (especially the NestJS framework) provide a more structured and opinionated approach, making it easier to build complex APIs with proper separation of concerns. We can write a more structured and modular code, leading to better maintainability and scalability in the long run.

However, this does not come easily because it requires adherence to a particular strict structure and rules. “Pure” Express.js is more manageable, lightweight, and flexible, making it a popular choice for simple or small-scale APIs. Developers without much experience in the backend can also profit from using it.

Each of these solutions has some positive and negative sides and should be tailored to the needs of a given team. And so, a team working on a daily basis in, for example, Spring technology may find it easier to use the approach described in this article. Similarly, in the case of a team focused on the front-end, which is using the “classical”, more functional approach could write and maintain the application much more efficiently than learning from scratch OOP patterns.

Resources for further reading

Written by Filip SkibiΕ„ski
Published August 22, 2023