I am not a big fan of large frameworks like NestJS; I've always liked the freedom of building my software the way I want with the structure I decide in a lightweight way. But something I liked when testing NestJS out was the Dependency injection.
Dependency Injection (DI) is a design pattern that allows us to develop loosely coupled code by removing the responsibility of creating and managing dependencies from our classes. This pattern is crucial for writing maintainable, testable, and scalable applications. In the TypeScript ecosystem, TSyringe stands out as a powerful and lightweight dependency injection container that simplifies this process.
TSyringe is a lightweight dependency injection container for TypeScript/JavaScript applications. Maintained by Microsoft on their GitHub (https://github.com/microsoft/tsyringe), it uses decorators to do Constructor injection. Then, it uses an Inversion of Control container to store the dependencies based on a token that you can exchange for an instance or a value.
Before diving into TSyringe, let's briefly explore what dependency injection is and why it's important.
Dependency injection is a technique where an object receives its dependencies from external sources rather than creating them itself. This approach offers several benefits:
First, let's set up TSyringe in your TypeScript project:
npm install tsyringe reflect-metadata
In your tsconfig.json, ensure you have the following options:
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } }
Import reflect-metadata at the entry point of your application:
import "reflect-metadata";
The entry point of your application is, for example, the root layout on Next.js 13+, or it can be the main file in a small Express application.
Let's take the example from the introduction and add the TSyringe sugar:
Let's start with the adapter.
// @/adapters/userAdapter.ts import { injectable } from "tsyringe" @injectable() class UserAdapter { constructor(...) {...} async fetchByUUID(uuid) {...} }
Notice the @injectable() decorator? It's to tell TSyringe that this class can be injected at runtime.
So my Service is using the adapter we just created. Let's inject that Adapter into my Service.
// @/core/user/user.service.ts import { injectable, inject } from "tsyringe" ... @injectable() class UserService { constructor(@inject('UserAdapter') private readonly userAdapter: UserAdapter) {} async fetchByUUID(uuid: string) { ... const { data, error } = await this.userAdapter.fetchByUUID(uuid); ... } }
Here I also used the @injectable decorator because the Service is going to be injected into my command class, but I also added the @inject decorator in the constructor params. This decorator tells TSyringe to give the instance or the value it has for the token UserAdapter for the userAdapter property at runtime.
And last but not least, the root of my Core: the command class (often wrongly called usecase).
// @/core/user/user.commands.ts import { inject } from "tsyringe" ... @injectable() class UserCommands { constructor(@inject('UserService') private readonly userService: UserService) {} async fetchByUUID(uuid) { ... const { data, error } = this.userService.fetchByUUID(uuid); ... } }
At this point, we've told TSyringe what is going to be injected and what to inject in the constructor. But we still haven't made our containers to store the dependencies. We can do that in two ways:
We can create a file with our dependency injection registry:
// @/core/user/user.dependencies.ts import { container } from "tsyringe" ... container.register("UserService", {useClass: UserService}) // associate the UserService with the token "UserService" container.register("UserAdapter", {useClass: UserAdapter}) // associate the UserAdapter with the token "UserAdapter" export { container }
But we can also use the @registry decorator.
// @/core/user/user.commands.ts import { inject, registry, injectable } from "tsyringe" ... @injectable() @registry([ { token: 'UserService', useClass: UserService }, { token: 'UserAdapter', useClass: UserAdapter }, ]) export class UserCommands { constructor(@inject('UserService') private readonly userService: UserService) {} async fetchByUUID(uuid) { ... const { data, error } = this.userService.fetchByUUID(uuid); ... } } container.register("UserCommands", { useClass: UserCommands}) export { container }
Both methods have pros and cons, but at the end of the day, it's a matter of taste.
Now that our container is filled with our dependencies, we can get them from the container as needed by using the resolve method of the container.
import { container, UserCommands } from "@/core/user/user.commands" ... const userCommands = container.resolve<UserCommands>("UserCommands") await userCommands.fetchByUUID(uuid) ...
This example is pretty simple as each class only depends on another, but our services could depend on many, and dependency injection would really help keep everything tidy.
But wait! Don't leave me like that! How about the tests?
Our injections can also help us test our code by sending mock objects straight into our dependencies. Let's see a code example:
import { container, UserCommands } from "@/core/user/user.commands" describe("test ftw", () => { let userAdapterMock: UserAdapterMock let userCommands: UserCommands beforeEach(() => { userAdapterMock = new UserAdapter() container.registerInstance<UserAdapter>("UserAdapter", userAdapter) userCommands = container.resolve<UserCommands>("UserCommands") }); ... });
Now the UserAdapter token contains a mock that will be injected into the dependent classes.
Use tokens for naming: Instead of using string literals for injection tokens, create constant tokens:
export const USER_REPOSITORY_TOKEN = Symbol("UserRepository");
Scoped containers: Use scoped containers for request-scoped dependencies in web applications.
Don't overuse DI: Not everything needs to be injected. Use DI for cross-cutting concerns and configurable dependencies.
If you've come this far, I want to say thank you for reading. I hope you found this article instructive. Remember to always consider the specific needs of your project when implementing dependency injection and architectural patterns.
Likes and comment feedback are the best ways to improve.
Happy coding!
The above is the detailed content of TSyringe and Dependency Injection in TypeScript. For more information, please follow other related articles on the PHP Chinese website!