Dependency Injection in Typescript with tsyringe

Why Dependency Injection

In any large object oriented codebase, managing dependencies can get difficult. Each class can require any number of third parties or other classes to function, and it can be hard to test the behavior of a single class with mocks if those dependencies aren’t easy to provide.

Fortunately, there’s a popular design pattern that can be applied to solve this problem, and that is dependency injection.

When using dependency injection, classes can be provided their dependencies through a constructor, and those dependencies can be swapped out easily for other implmentations. In tests, mocks can simply be substituted in to test class behavior.

While most of the time, this pattern is implemented with a framework, even without one manual dependency injection can give you some of these benefits.

Here at Gamechanger, we previously had a form of manual dependency injection in our typescript codebase. Each class would have a constructor that accepted its dependencies, which could be swapped out with mocks or a real implementation.

class BusinessLogic {
  constructor (dependencyA: DependencyA, dependencyB: DependencyB) {}

  private void foo() {
    this.dependencyA.action();
  }
}

// To instantiate this class, both dependencies must be created
const businessLogic = new BusinessLogic(new DependencyA(), new DependencyB());

// to test this class, mocks can be passed in
const testBusinessLogic = new BusinessLogic(new MockDependencyA(), new MockDependencyB());

In this example, if you need to replace a dependency, you can just supply a different class in the constructor for BusinessLogic.

This can work great for a small number of classes with a tiny dependency tree, but as your codebase’s number of dependencies grow, it can become difficult to manage. Once your dependencies have dependencies, its not as straightforward to get an instance of a class.

class DependencyC {
  constructor(dependencyE, DepedencyE) {

  }
}

class DependencyA {
  constructor(dependencyC: DependencyC, dependencyD: DependencyD) {

  }
}

// Now, to instantiate BusinessLogic, we need to create a tree of instances.
const dependencyC = new DependencyC(new DependencyE());
const businessLogic = new BusinessLogic(new MockDependencyA(dependencyC, new DependencyD()), new MockDependencyB());

Even in this relatively mild example, it’s starting to get complicated to manage the dependency tree. If you want to mock dependency C in the business logic dependency chain, you have to create all of the dependencies around it and pass those in.

When you need to test a particularly complicated class, setting up all its dependencies can take more time that writing the test itself! If you only need to mock a single subdependency, you need to instantiate everything all the way down until the mock is required, and then pass it in there.

Fortunately, there are dependency injection frameworks for typescript that can simplify the work that needs to be done.

Using Tsyringe

Since we use typescript, we’ve moved to using https://github.com/microsoft/tsyringe

Tysringe allows you to tag a particular dependency as injectable with a decorator, and then very easily get an instance of it.

At its core, tsyringe provides you a dependency container that keeps track of all your dependencies. When you need to create an instance of a class, you can call resolve on the the container with an injection token and it will return you the right dependency registered under that token.

Our previous example becomes much easier to manage with this:

import { container, injectable } from 'tsyringe';

@injectable()
class DependencyC {
  constructor(dependencyE, DepedencyE) {

  }
}

@injectable()
class DependencyA {
  constructor(dependencyC: DependencyC, dependencyD: DependencyD) {

  }
}

@injectable()
class BusinessLogic {
  constructor (dependencyA: DependencyA, dependencyB: DependencyB) {}

  private void foo() {
    this.dependencyA.action();
  }
}

// Now, all we need to do if we need an instance of business logic, is resolve it
const businessLogicInstance = container.resolve(BusinessLogic);

That’s it! All you need to do is tag your classes as injectable and tsyringe can take care of instantiating the whole dependency tree.

Writing tests also becomes much easier with the framework, when you need to mock a low level dependency, you can just register it with the dependency container, and leave everything else in place.

To register a mock, you can call registerInstance on the container, and provide it with the injection token you want to replace, and what you want to replace it with. Once you’re done with the mock it can be cleared with clearInstances on the container.

import { container } from 'tsyringe';

describe('BusinessLogic', () => {
  it('should call action on dependencyA when foo is called', () => {
    // We can mock a class at any level in the dependency tree without touching anything else
    container.registerInstance(DependencyC, mock());

    // dependency A gets a mock version of dependency C during this resolution.
    const underTest = container.resolve(BusinessLogic);

    // We can call this now that we're done testing, and the mock will be removed.
    // When we resolve the instance after this, we get the original dependencies.
    // In practice, we've found it's easy to just place this in your afterEach block.
    container.clearInstances()
  });
});

Here, DependencyC will be replaced with a mock for the duration of this test, and at the end, when clearInstances is called, it will return to its original form.

Tsyringe provides some great utilities we’ve been able to leverage to deal with common dependency problems. For instance, its fairly common to have classes that are singletons, and while managing that manually can be a bit difficult, its virtually painless with tsyringe.

import { singleton } from 'tysringe';

// This class will be a singleton, when container.resolve is called
// All calls will return the same instance.
@singleton()
class MySingleton {

}

Tsyringe also contains some great tools for managing the lifecycle of a given dependency. Dependencies can be scoped in a number of different ways. By default, dependencies have the transient scope, which means that every time you resolve this dependency a new instance is created. This can make sense, but also has some performance and memory implications, especially if you have some classes that are large and expensive to construct that aren’t singletons.

For our dependencies, we found that ResolutionScoped worked in reducing our memory usage. Resolution Scoping means that the same dependency will be reused during a resolution chain, so if you have a class that could need a dependency more than once in its dependency tree, it will only ever be instantiated once.

Potential Issues

There’s a few quirks we’ve learned to navigate with using tsyringe, mostly related to how to register mocks. There are a few ways to register something with the dependency container, the easiest of which is adding the @injectable() decorator, but you can also manually register something with a call to container.register. This can be useful if you need to register something that’s not a class. An additional note that’s useful in this case, is that your injection token can be a string which can also be helpful if you’re not registering a class.

container.register('NonClassDependency', { useValue: nonClassObject });

If you need to resolve something from a string token in a contstructor, there’s an @inject decorator you can use to make sure the dependency is automatically resolved.

import { inject } from 'tsyringe';

class MyClass {
  constructor(@inject('NonClassDependency') nonClassDependency: NonClassDependencyInterface) {

  }
}

One problem with dependencies registered manually can be cleared unintentionally by a call to clearInstances which should be used in-betweeen tests.

To register a dependency that is unclearable without the decorator, it needs to be registered with useFactory. The factory should be a function that returns the item you want injected.

// Application code
container.register(MyDependency, { useFactory: () => myDependencyObject );
container.register(OtherDependency, { useValue: otherDependencyObject );


describe('MyDependency', () => {

  it('should not be mocked', () => {
    container.resolve(MyDependency);
    container.resolve(OtherDependency);

    container.clearInstances();
    // This will fail!
    // OtherDependency is no longer registered with the container.
    container.resolve(OtherDependency)

    // This will be fine, the dependency remains registered after clears
    container.resolve(MyDependency);
  });
});

This allows you to register whatever classes or objects you want managed by the dependency container in your application code, and then selectively replace them with mocks, in a given test, and then revert back to the original when the test is done.

Wrapping Up

Adding tsyringe has definitely made managing our application dependencies and testing code much easier, with a dependency injection framework, we now have a much more manageable solution to dealing with our large dependency tree.

Source: GameChanger

Leave a Reply

Your email address will not be published.


*