Understanding Dependency Identifiers
In dependency injection (DI), identifiers play a crucial role in determining how dependencies are provided and resolved. This guide explores the different types of identifiers and explains how to use them effectively with Suites.
What You'll Find in This Section
- Class-based Injection - Using TypeScript classes as identifiers
- Token-based Injection - Working with string and symbol tokens
- Handling Metadata - Adding context to your identifiers
- Framework-specific Examples - Code samples for NestJS and InversifyJS
The usage and support for different types of identifiers or metadata may vary between adapters. Please refer to the specific adapter's documentation for detailed information.
Before diving in, ensure you've installed the appropriate adapter for your DI framework:
- NestJS
- InversifyJS
$ npm i -D @suites/di.nestjs
$ npm i -D @suites/di.inversify
See the full installation guide here
Class-based Injection 💡
Class-based injection is a fundamental concept in Dependency Injection frameworks. It uses TypeScript classes both as a blueprint for creating instances and as an identifier for resolving dependencies.
- NestJS
- InversifyJS
import { Injectable, Inject } from '@nestjs/common';
@Injectable()
class UserService {
constructor(private apiService: ApiService) {}
}
import { injectable, inject } from 'inversify';
@injectable()
class UserService {
constructor(private apiService: ApiService) {}
}
To resolve or mock a class-based dependency with Suites, simply use the class itself:
const { unitRef } = await TestBed.solitary(UserService).compile();
const userApiService = unitRef.get(ApiService);
const { unit, unitRef } = await TestBed.solitary(UserService)
.mock(ApiService)
.using({ ... })
.compile();
Token-based Injection 💡
Tokens (strings or symbols) serve as unique keys to fetch specific dependencies from the container. They're especially useful for distinguishing multiple instances of the same type or for interface-based dependencies.
The concept of token-based injection is consistent across DI frameworks, but the syntax varies by implementation.
Class as Token
Sometimes, classes are explicitly used as injection tokens:
- NestJS
- InversifyJS
import { Injectable, Inject } from '@nestjs/common';
@Injectable()
class UserService {
constructor(@Inject(ApiService) private apiService: ApiService) {}
}
import { injectable, inject } from 'inversify';
@injectable()
class UserService {
constructor(@inject(ApiService) private apiService: ApiService) {}
}
With Suites, you can resolve or mock these dependencies using the class itself:
const { unitRef } = await TestBed.solitary(UserService).compile();
const userApiService = unitRef.get(ApiService);
const { unit, unitRef } = await TestBed.solitary(UserService)
.mock(ApiService)
.using({ ... })
.compile();
String-Based / Symbol-Based Tokens
Tokens can be strings or symbols, each serving as a unique identifier for a dependency.
Consider the following Logger
interface as an example:
interface Logger {
log(message: string): void;
}
And a token constant:
- String-Based Token
- Symbol-Based Token
export const LOGGER_TOKEN = 'LOGGER_TOKEN'
export const LOGGER_TOKEN = Symbol.for('LOGGER_TOKEN')
You would register this token with your DI framework to associate it with a specific implementation of the Logger interface:
- NestJS
- InversifyJS
import { Injectable, Inject } from '@nestjs/common';
@Injectable()
class UserService {
constructor(@Inject(LOGGER_TOKEN) private logger: Logger) {}
}
import { injectable, inject } from 'inversify';
@injectable()
class UserService {
constructor(@inject(LOGGER_TOKEN) private logger: Logger) {}
}
With Suites, you resolve or mock token-based dependencies using the token itself:
const { unitRef } = await TestBed.solitary(UserService).compile();
const logger = unitRef.get<Logger>(LOGGER_TOKEN);
const { unit, unitRef } = await TestBed.solitary(UserService)
.mock<Logger>(LOGGER_TOKEN)
.using({ ... })
.compile();
Circular Dependencies and Lazy Loading
Circular dependencies occur when two classes depend on each other. While best avoided, some scenarios require them. DI frameworks provide mechanisms like lazy loading to handle such cases:
- NestJS
- InversifyJS
import { Injectable, Inject } from '@nestjs/common';
@Injectable()
class UserService {
constructor(@Inject(forwardRef(() => ApiService)) private apiService: ApiService) {}
}
import { injectable, inject, LazyServiceIdentifer } from 'inversify';
@injectable()
class UserService {
constructor(@inject(new LazyServiceIdentifer(() => ApiService)) private apiService: ApiService) {}
}
With Suites, you can resolve or mock circular dependencies using the base identifier without the circular dependency wrapper:
// Example for NestJS
const apiService = unitRef.get(ApiService); // Not forwardRef(() => ApiService)
// Example for InversifyJS
const apiService = unitRef.get(ApiService); // Not new LazyServiceIdentifer(() => ApiService)
Identifiers with Metadata 💡
Suites allows for metadata inclusion alongside the primary identifier. Metadata provides additional context or qualifiers for identifiers, enabling more precise control over dependency resolution. For instance, a service might have multiple instances, each distinguished by specific metadata.
Currently, only the InversifyJS adapter supports metadata. For more information, visit the InversifyJS documentation page.
Next Steps
- InversifyJS Guide - Learn about advanced identifier patterns in InversifyJS
- Unit Testing Fundamentals - Explore the core principles of unit testing with Suites
- Test Doubles - Understand how to work with mocks, stubs, and other test doubles