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:
Vanilla TypeScript
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,
});
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();
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
);
// ...
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,
});
// ...
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);
// ...
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 {}
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);
}
For example for inversify
for every test, I do: Similarly, for nestjs
:
requireUncached("reflect-metadata");
requireUncached("@nestjs/core");
requireUncached("@nestjs/common");
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");
Cold Start + Resolution
The last suite is a combination of both: the cold start
and resolution
.
requireUncached("reflect-metadata");
requireUncached("inversify");
...
createContainer().get("FindUserReviewedProducts");
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
librarytsyringe - a test case for a DI container created by using
tsyringe
libraryinversify - a test case for a DI container created by using
inversify
librarynest - a test case for a DI container created by using
nest.js
librarytsyringe#frozen - a test case for a state object created from a DI container created by using
tsyringe
libraryinversify#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
Top comments (0)