A Practical Way to Improve Javascript Code using Dependency Injection

by
Tags: , ,
Category:

We have all seen endless articles and walkthroughs for implementing The Greatest Design Pattern Ever™. The one failing many of them seem to have in common is that they all assume a user is starting with a new project. It’s relatively easy to set up an ideal theoretical project, and implement a design pattern, but how many of us get that luxury in our jobs? 

Many, if not most of us, are stuck maintaining other people’s code, sometimes decades old. Any sense of theoretical perfection has been thrown out the window in favor of countless deadlines, and eventually a degree of apathy towards the state of the codebase. The task of fixing it is so overwhelming and daunting, that most will never even make the effort to try. Sound familiar? 

My goal with this article is to give some insight into an easy way to improve the messy real world code we encounter every day. To help you to improve the organization and structure of your project, without requiring an overwhelming amount of work. Let’s begin by looking at the basics of the design pattern we will be using to help us refactor our code; Dependency Injection. 

Dependency Injection is an applied form of the Inversion of Control pattern, that helps us to separate the logic of our code from its dependencies, and to ensure the highest possible level of decoupling between components. This helps the developer to follow SOLID’s dependency inversion and single responsibility principles, which improves the overall quality of the codebase.

The benefits of this approach are many, but the most significant ones are:

  •  Modularity: Decoupling your code’s interface from its implementation makes it significantly easier to refactor code. For example, say we need to switch our data layer from one database to another. If you have followed the DI pattern, and created a common interface, it should be relatively trivial to change your code to use the new data source. Match the interface of the old class, and the rest should just work. Without this organization, rewriting the data access layer could be an overwhelming task that requires touching a large number of files, instead of just the repositories.
  • Testing: Writing unit tests for classes that have implemented this IOC pattern is much simpler, as the classes depend on nothing other than the objects in their constructors/setters. This means they can be easily initialized with mocked dependencies as needed, helping to get developers actually writing practical tests for their code’s logic. 
  • Resilience: This pattern encourages developers to think about the relationship between the different parts of their code, and encourages coding to an interface instead of a specific implementation. The what of the code (business logic) can be separated from the more mundane details of instantiating and injecting dependencies. Individual components can be swapped out with ease, and the entire codebase can be injected into nearly any class that needs it. This helps to prevent issues with copy/paste, and duplication of concerns. Create one service/interface, and use it everywhere.

Of course there are cons to this pattern, and it would be dishonest to not mention them. Nothing is perfect. The primary downside that I have encountered, is that it can obscure the flow of code through the application. You can no longer jump directly into an implementation in your editor with a simple click. Depending on the language/library you’re using, it may require reading through XML configuration files, which can be tedious, especially when something is not working quite right. This annoyance is minor in my opinion however, when weighed against the organizational benefits it brings to the codebase. 

Now that we’ve covered the theoretical, let’s look at a practical example using the javascript library tsyringe. I chose this particular library because it is written by Microsoft, is well tested, and I have used it myself in a production environment. Feel free to do your own research, and choose a different library if you prefer. The caveat for this library is that it does require either Typescript or Babel support for decorators.

Let’s begin by looking at an example backend node/express function that reflects the common procedural pattern of many codebases, and fails to follow proper SOLID principles.

async function getAuthorizedReportsForUser(req, res) {
    const { headers: { token }} = req;

    if ((!helpers.parseJWTToken(token)).userId) {
        throw Error(`Unauthorized request!`);
    }

    const user = await usersModel.findOne({ uuid: userId }).lean();
    if (!user) {
        throw Error(`No user found for userId: ${userId}`)
    }
    const { roles } = user;

    let reports = await reportsModel.find().sort('updatedAt', -1).lean();
    reports = reports.filter(report => roles.includes(report.requiredRole));

    res.send(reports);
}

As it is plain to see, this function, while it does it’s stated job, has too many concerns it addresses. It is at once a route controller, user validation, and report business logic. To make matters worse, none of these things are reusable, and if anything changes around requirements for, say, user validation, then the code would need to be updated in many places. Mistakes and security issues are all but certain eventually. Let’s look at how we could refactor this using tsyringe.

First we need to create our classes to address our different logical domains. My preferred pattern for doing so is the Service/Repository pattern, that separates the data access layer from the core business logic. So we’ll go ahead and do that.

import {injectable, inject} from "tsyringe";
@injectable()
class ReportsRepository {
    constructor(@inject('ReportsModel') reportsModel) {
      this.reportsModel = reportsModel;
    }

    async getForUser(user) {
      return this.reportsModel.find({ requiredRole: { $in: user.roles }}).sort('updatedAt', -1).lean()
    }
}
import {injectable, inject} from "tsyringe";
@injectable()
class ReportsService {   
    constructor(
      @inject('ReportsRepository') reportsRepository,
      @inject('UsersService') usersService
  ) {
      this.reportsRepository = reportsRepository;
      this.usersService = usersService;
    }

    async getAuthorizedReportsForUser(user) {
      return this.reportsRepository.getForUser(user);
    }
}
class UsersService {
  constructor(
      @inject('UsersRepository') usersRepository
  ) {
      this.usersRepository = usersRepository;
  }

  async getUserFromAuthToken(token) {

       const userId = helpers.parseJWTToken(token)).userId;
       

       if ((!userId) {
          throw Error(`Unauthorized request!`);
      }
  
      const user = await this.usersRepository.findByUserId(userId);
      if (!user) {
          throw Error(`No user found for userId: ${userId}`)
      }

      return user;
  }
}

// UserRepository implied

We have now properly organized our different logical concerns, and created a data access layer that is separate from our business logic. Now let’s see how we can bridge the gap between the new code and the old. To actually use our new classes, we need to register them in the dependency container that will handle resolving the dependencies for us. In your main.(js/ts), add the following lines. 

// register container in main.js
import "reflect-metadata";
import { registerContainer } from '/container';
// ....

registerContainer();

Then in whatever directory you prefer, create the container file and register your dependencies. As you will notice, both functions/constants and classes can be registered for use.

import { container } from 'tsyringe';
// ... dependencies


export function registerContainer() {
  container.register('UsersModel', {
      useValue: usersModel
  });
  
  container.register('ReportsModel', {
      useValue: reportsModel,
  });
  
  container.register('ReportsRepository', {
      useClass: ReportsRepository
  });
  
  container.register('ReportsService', {
      useClass: ReportsService
  });
  
  container.register('UsersRepository', {
      useClass: UsersRepository
  });
  
  container.register('UsersService', {
      useClass: UsersService
  });
}

export default container;

We have now successfully registered our dependencies, and can make use of them in our old code! Our refactored controller function now looks like this:

import container from '/container';

async function getAuthorizedReportsForUser(req, res) {
   // important note, these must be resolved at runtime, not as an import at top of the file
  const reportsService = container.resolve('ReportsService');
  const usersService = container.resolve('UsersService');
  const user = await usersService.getUserFromAuthToken(req.headers.token);
  res.send(await reportsService.getForUser(user));
}

This is much cleaner! We have put all our logic in the proper place, and given good descriptive names to our functions to describe what is actually happening here. We can also make reuse of our new code to refactor our other functions, however we are not immediately required to do so. That is the beauty of this approach, it allows for gradual organization and improvement of the code base. All we had to do was write a little boilerplate, install some libraries, and we have converted a badly organized procedural function to one that follows proper SOLID principles. Over time as the refactored code base grows, it becomes easier and faster to code new features, as we can make better use of our existing code. We are forced to think about separation of concerns, and done properly, our code should read more like a book, instead of being required to read the implementation details to figure out what is going on. Eventually our entire code library should also be accessible from just about any place in the codebase with a simple inject decorator or by resolving a token.

Hopefully this example will give you some insight on ways to improve your legacy project with a reasonable amount of effort. By using the proper tools and bringing some organization to our code, we can significantly improve the developer experience and improve the resiliency of our application. Make an effort to improve your code everyday, even if just a small amount, and never give in to apathy around quality. Happy coding!