DI Benchmark: Vanilla, RegistryComposer, typed-inject, tsyringe, inversify, nest.js

vad3x

Vadim Orekhov

Posted on February 26, 2023

DI Benchmark: Vanilla, RegistryComposer, typed-inject, tsyringe, inversify, nest.js

In the previous articles, I've introduced a framework-less and clean way to perform Dependency Injection in TypeScript.

The approach has multiple pros such as type-safety, zero-cost overhead, and others.

However, the one missing piece I had was - benchmarking.

In this article I'm going to compare the performance of the following DI approaches/libs:

Application

For the test I'm defining the following application dependency tree:

The implementation of the functions and services does not really matter for our tests, since we only test the dependency resolution performance. So the most important thing here is the depth of the graph.

In the application to resolve findUserReviewedProducts the server needs to resolve all findUserByUsername, findUserReviews, findProductById, and also their dependencies: UserStore, ProductStore, and UserReviewStore, etc.

So, to resolve findUserReviewedProducts - 9 other dependencies need to be resolved.

NOTE: For fare comparison, all dependencies' lifetimes are Singleton, so once one is resolved, it can be cached for the whole application lifetime.

Competitors

Vanilla TypeScript

Typesafe implicit allocation of the dependency graph.

So the code looks like this:

// ...
const productStore = new MysqlProductStore(getMysqlClient);
const userStore = new RedisUserStore(getRedisClient);
const userReviewStore = new DynamodbUserReviewStore(getDynamodbClient);

const { findProductById } = new ImplFindProductById(productStore);
const { findUserByUsername } = new ImplFindUserByUsername(userStore);
const { findUserReviews } = new ImplFindUserReviews(userReviewStore);

const { findUserReviewedProducts } = new ImplFindUserReviewedProducts(
  findUserByUsername,
  findUserReviews,
  findProductById
);

return Object.freeze({
  getMysqlClient,
  getRedisClient,
  getDynamodbClient,
  findProductById,
  productStore,
  userStore,
  userReviewStore,
  findUserByUsername,
  findUserReviews,
  findUserReviewedProducts,
});

Enter fullscreen mode Exit fullscreen mode

NOTE: I'm using Vanilla TypeScript as a baseline for all the benchmarking since it's the most performant solution.

Registry Composer

Typesafe dependency injection approach, that does not require to use of any libraries. Very similar to vanilla TS, but the main benefit - it is helping to organize the registration complexity.

new RegistryComposer()
  .add(implGetMysqlClient())
  .add(implGetRedisClient())
  .add(implGetDynamodbClient())
  .add(mysqlProductStore())
  .add(redisUserStore())
  .add(dynamodbUserReviewStore())
  .add(implFindProductById())
  .add(implFindUserByUsername())
  .add(implFindUserReviews())
  .add(implFindUserReviewedProducts())
  .compose();

Enter fullscreen mode Exit fullscreen mode

typed-inject

Typesafe dependency injection framework for TypeScript. Conceptually it's very similar to Registry Composer except, it requires adding specific public static inject field to every class. Registration of functions is also not very pretty.

createInjector()
  // ...
  .provideClass("ProductStore", DecoratedMysqlProductStore)
  .provideClass("UserReviewStore", DecoratedDynamodbUserReviewStore)
  .provideClass("UserStore", DecoratedRedisUserStore)
  .provideClass("DecoratedImplFindProductById", DecoratedImplFindProductById)
  .provideFactory(
    "FindProductById",
    provideFn(["DecoratedImplFindProductById"], "findProductById")
  )
  .provideClass(
    "DecoratedImplFindUserByUsername",
    DecoratedImplFindUserByUsername
  );
// ...

Enter fullscreen mode Exit fullscreen mode

tsyringe

Lightweight dependency injection container for TypeScript. The container is decorator-driven and not typesafe.

// ...

container.register("ProductStore", {
  useClass: DecoratedMysqlProductStore,
});

container.register("UserReviewStore", {
  useClass: DecoratedDynamodbUserReviewStore,
});

container.register("UserStore", {
  useClass: DecoratedRedisUserStore,
});

// ...

Enter fullscreen mode Exit fullscreen mode

inversify

Another popular decorator-driven and not typesafe dependency injection container for TypeScript.

const container = new Container({
  defaultScope: "Singleton",
});

// ...
container.bind("ProductStore").to(DecoratedMysqlProductStore);
container.bind("UserReviewStore").to(DecoratedDynamodbUserReviewStore);
container.bind("UserStore").to(DecoratedRedisUserStore);
// ...

Enter fullscreen mode Exit fullscreen mode

nest.js

Unlike others, nest.js is not just a DI library, it's a complete framework for building backend services. The DI system is very similar to Angular Modules.

@Module({
  providers: [
    DecoratedImplFindProductById,
    DecoratedImplFindUserByUsername,
    DecoratedImplFindUserReviewedProducts,
    DecoratedImplFindUserReviews,
    // ...
    {
      provide: "FindUserReviews",
      useFactory: ({ findUserReviews }: DecoratedImplFindUserReviews) => {
        return findUserReviews;
      },
      inject: [DecoratedImplFindUserReviews],
    },
    {
      provide: "ProductStore",
      useClass: DecoratedMysqlProductStore,
    },
    {
      provide: "UserReviewStore",
      useClass: DecoratedDynamodbUserReviewStore,
    },
    {
      provide: "UserStore",
      useClass: DecoratedRedisUserStore,
    },
    // ...
  ],
})
export class RootModule {}

Enter fullscreen mode Exit fullscreen mode

NOTE: nest.js is a little out of scope since it doesn't expose it's DI container explicitly. But I've decided to test it as well since it's a very popular solution.

Benchmark Suites

For the benchmarking, I'm using the benchmark.js library with the default config.

There are 3 different benchmarks I'm performing:

Cold Start

The Cold Start suite covers the case when you just start an application. To do so, we need to skip caching when we start each test. It's achieved by using a helper function requireUncached.

function requireUncached(module: string) {
  delete require.cache[require.resolve(module)];
  return require(module);
}

Enter fullscreen mode Exit fullscreen mode

For example for inversify for every test, I do: Similarly, for nestjs:

requireUncached("reflect-metadata");
requireUncached("@nestjs/core");
requireUncached("@nestjs/common");

Enter fullscreen mode Exit fullscreen mode

Resolution

This suite verifies the performance of service resolution only. Meaning, the cold start happens on the test startup and does not affect the metrics.

For example, the code for the tsyringe looks like this:

container.resolve("FindUserReviewedProducts");

Enter fullscreen mode Exit fullscreen mode

Cold Start + Resolution

The last suite is a combination of both: the cold start and resolution.

requireUncached("reflect-metadata");
requireUncached("inversify");

...

createContainer().get("FindUserReviewedProducts");

Enter fullscreen mode Exit fullscreen mode

Scenario Test Cases

Each scenario contains the following test cases:

  • vanilla - a test case for a state object created by implicit allocation

  • registry-composer - a test case for a state object created by using RegistryComposer

  • typed-inject - a test case for a DI container created by using typed-inject library

  • tsyringe - a test case for a DI container created by using tsyringe library

  • inversify - a test case for a DI container created by using inversify library

  • nest - a test case for a DI container created by using nest.js library

  • tsyringe#frozen - a test case for a state object created from a DI container created by using tsyringe library

  • inversify#frozen - a test case for a state object created from a DI container created by using inversify library

Benchmarking

Benchmarking / Cold Start

What we can see from the chart: the transpile-time solutions are 3x faster than the runtime containers.

NOTE: even transpile-time solutions are 3x times faster, it does not mean your app will start 3x times faster, since we only compare DI framework timing, excluding any extra dependency your app might have.

Benchmarking / Resolution

Since benchmarking all transpile-time and frozen solutions is accessing a field by name, it's very fast. We can see that all the solutions can perform ~950M op/sec. While runtime container solutions perform in the range of 1M-6M op/sec, which is 150x times slower.

Benchmarking / Cold Start + Resolution

Similarly to just the Cold Start test, the transpile-time solutions are 3x faster than the runtime containers.

Conclusion

The results I got are pretty obvious. The runtime container adds overhead, compared to transpile-time solutions. It's especially clear for Resolution tests since the runtime is 150x times different. The runtime containers are less performant and not typesafe, but provide an easier way to manage services lifetimes. While the transpile-time solutions are typesafe, very fast, and make the application code framework agnostic, but require more care at the code design stage (especially for the scope lifetime dependencies). At the end of the day, it's up to you what solution to use, since each has its pros and cons.

A full example source code can be found here

💖 💪 🙅 🚩
vad3x
Vadim Orekhov

Posted on February 26, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related