Dependency Injection
Proto
In domain-driven development, we generally place logic in Services. In Egg.js, this is implemented through Proto.
Proto provides configurable information:
- Instantiation method: instantiate per request / global singleton
- Access level: whether accessible outside the
Module - Instantiation name
Instantiation Method
Includes two forms: ContextProto and SingletonProto. For specific details, please refer to the documentation below.
Instantiation Name
This is crucial as it determines which instance should be injected with @Inject. By default, the first letter of the Proto class is converted to lowercase, e.g., UserAdapter becomes userAdapter. If it doesn't meet expectations, you can manually specify it, for example:
// The instance name of MISTAdapter is mistAdapter
@SingletonProto({ name: 'mistAdapter' })
class MISTAdapter {}Access Level
All prototypes within a Module can be depended on (@Inject) by other prototypes in the same Module. Only prototypes with accessLevel: AccessLevel.PUBLIC can be accessed by other Modules. The default access level is AccessLevel.PRIVATE
root dir
└── app
└── module
├── fooModule
│ ├── Private.ts
│ ├── Public.ts
│ └── Access.ts // Can Inject Private/Public
└── barModule
└── Access.ts // Can only Inject PublicWARNING
Logic within a Module should be as cohesive as possible, exposing only necessary interfaces. Once exposed, dependencies are created, and you must consider backward compatibility when changing interface code.
SingletonProto
Definition
Similar to ContextProto, only one SingletonProto will be instantiated during the entire application lifecycle.
It's recommended to use SingletonProto by default, as it can improve application performance, and you can inject ContextProto objects within SingletonProto.
@SingletonProto({
// The instantiation name of the prototype, optional
name?: string;
// Whether the object is accessible within the module or globally
// Default value is AccessLevel.PRIVATE
accessLevel?: AccessLevel;
})Example
// biz/HelloService.ts
import { SingletonProto } from 'egg';
@SingletonProto()
export class HelloService {
async hello(): Promise<string> {
return 'hello';
}
}
@SingletonProto({
name: 'worldInterface',
})
export class WorldService {
async world(): Promise<string> {
return 'world!';
}
}ContextProto
Definition
A ContextProto will be instantiated for each request.
INFO
Most Service classes are stateless and don't store request context. In such cases, it's recommended to use SingletonProto instead. This is because only one object needs to be initialized globally, rather than initializing an object for each request (which would degrade application performance). For scenarios that need to store request context information and share it across multiple Service classes, you can use ContextProto to ensure objects obtained by different requests are isolated.
enum AccessLevel {
// Only accessible within module
PRIVATE = 'PRIVATE',
// Globally accessible
PUBLIC = 'PUBLIC',
}
@ContextProto({
// The instantiation name of the prototype, optional
name?: string;
// Whether the object is accessible within the module or globally
// Default value is AccessLevel.PRIVATE
accessLevel?: AccessLevel;
})Example
// service.ts
import { ContextProto } from 'egg';
@ContextProto()
export class HelloService {
async hello(): Promise<string> {
return 'hello';
}
}
@ContextProto({
name: 'worldInterface',
})
export class WorldService {
async world(): Promise<string> {
return 'world!';
}
}How to inject and use it:
import { Inject, ContextProto } from 'egg';
import { HelloService, WorldService } from './service.ts';
@ContextProto()
export class UseProtoDemo {
@Inject()
helloService: HelloService;
@Inject()
worldInterface: WorldService;
async say(): Promise<string> {
return this.helloService.hello() + ',' + this.worldInterface.world();
}
}Inject
Definition
Prototypes can depend on other prototypes or objects in Egg. Dependency injection is implemented through the @Inject decorator.
@Inject(param?: {
// Name of the injected object, in some cases a prototype may have multiple instances
// For example, egg's logger
// Defaults to property name
name?: string;
// Name of the injected prototype
// In some cases you don't want the injected prototype to use the same name as the property
// Defaults to property name
proto?: string;
})Example
import { Inject, SingletonProto, Logger } from 'egg';
@SingletonProto()
export class HelloService {
@Inject()
fooService: FooService; // Inject other prototype instances
@Inject()
logger: Logger; // Inject egg objects
async hello(user: User): Promise<string> {
this.logger.info(`[HelloService] hello ${this.fooService.hello()}`);
}
}Usage Notes
There are several points to note when using Inject:
- Circular dependencies are not allowed between prototypes, e.g., Proto A - inject -> Proto B - inject-> Proto A
- Similarly, circular dependencies are not allowed between
Modules - A
Modulecannot have prototypes with the same instantiation method and name
The Role of Inject name
It allows the injected instance name to be different from the prototype instantiation, which is useful when using aliases.
/*** Define prototypes ***/
@SingletonProto()
export class HelloService {
async hello(): Promise<string> {
return 'hello';
}
}
@SingletonProto({
name: 'worldInterface',
})
export class WorldService {
async world(): Promise<string> {
return 'world!';
}
}
/*** Inject prototypes ***/
@SingletonProto()
class Foo {
@Inject()
helloService: HelloService;
@Inject({ name: 'helloService' })
aliasHelloService: HelloService; // Equivalent to helloService above
@Inject({ name: 'worldInterface' })
worldService: WorldService;
}The Role of Inject Type
Injection depends on the proto name, not the type, so the following code still works:
import { Inject, SingletonProto } from 'egg';
@SingletonProto()
class Foo {
@Inject()
redis: any; // Type defined as any can still inject redis from Egg Context
}The role of the type here is only for TypeScript type hints (e.g., setting it to any just means missing Redis SDK API hints).
Egg Compatibility
Module automatically traverses the Context/Application objects to get all their properties. All properties can be seamlessly injected, as in the common examples below:
Inject Egg Configuration
import { Inject, SingletonProto, EggAppConfig } from 'egg';
@SingletonProto()
class Foo {
@Inject()
config: EggAppConfig;
bar() {
console.log('current env is %s', this.config.env);
}
}Inject logger
Optimized specifically for logger, you can directly inject custom loggers:
// config/config.default.ts
export default {
customLogger: {
fooLogger: {
file: 'foo.log',
},
},
};You can directly inject in the code:
import { Inject, SingletonProto, Logger } from 'egg';
@SingletonProto()
class FooService {
// Inject ${appname}-web.log
@Inject()
logger: Logger;
// Inject egg-web.log
@Inject()
coreLogger: Logger;
// Inject customLogger named fooLogger
@Inject()
fooLogger: Logger;
}Inject Service
WARNING
It is strongly recommended to re-encapsulate Egg Service code through Proto before injecting. For existing Service patterns, you can introduce them as follows:
import { EggLogger, Service, Inject, SingletonProto } from 'egg';
@SingletonProto()
class FooService {
// Inject the entire ctx.service, then get the corresponding xxxService
@Inject()
service: Service;
get xxxService() {
return this.service.xxxService;
}
}Inject HttpClient
import { Inject, SingletonProto, HttpClient } from 'egg';
@SingletonProto()
class Foo {
@Inject()
httpClient: HttpClient;
async bar() {
await this.httpClient.request('https://alipay.com');
}
}Inject Egg Methods
Since Module injection can only inject objects, not methods, if you need to use existing Egg methods, you need to encapsulate the methods.
For example: Suppose there's a method getHeader on Context. To use this method in Module, you need to encapsulate it as follows.
// extend/context.ts
export default {
getHeader() {
return '23333';
},
};First, encapsulate the method as an object.
// HeaderHelper.ts
class HeaderHelper {
constructor(ctx) {
this.ctx = ctx;
}
getHeader(): string {
return this.ctx.getHeader();
}
}Then put the object on the Context extension.
// extend/context.ts
const HEADER_HELPER = Symbol('context#headerHelper');
export default {
get headerHelper() {
if (!this[HEADER_HELPER]) {
this[HEADER_HELPER] = new HeaderHelper(this);
}
return this[HEADER_HELPER];
},
};Prototype Name Conflicts Within Module
Definition
Within a Module, there are two prototypes with the same name but different instantiation methods. Direct Inject won't work because the Module cannot determine which object is needed. In this case, you need to tell the Module which instantiation method the injected object should use.
@InitTypeQualifier(initType: ObjectInitType)Example
import {
Logger,
Inject,
InitTypeQualifier,
ObjectInitType,
SingletonProto,
} from 'egg';
@SingletonProto()
export class HelloService {
@Inject()
// Explicitly specify logger with instantiation method CONTEXT
@InitTypeQualifier(ObjectInitType.CONTEXT)
logger: Logger;
}Prototype Name Conflicts Between Modules
Definition
Multiple Modules may implement a prototype named HelloService. You need to explicitly tell the Module which Module the injected prototype comes from.
@ModuleQualifier(moduleName: string)Example
import { Inject, InitTypeQualifier, ObjectInitType, Logger } from 'egg';
@SingletonProto()
export class HelloService {
@Inject()
// Explicitly specify HelloAdapter from the foo `Module`
@ModuleQualifier('foo')
helloAdapter: HelloAdapter;
}Qualifier Dynamic Injection
Use Cases
We often have different implementations for different scenarios in our code. A simple approach is to use if/else or switch at the point of use. However, this presents a problem: every time we need to extend a type, we need to modify at least two places - one is to add the implementation, and the other is to add a code branch where it's used. This often leads to omissions, causing issues in our code. We want changes to be converged, so that implementations are dynamically available once implemented. Therefore, dynamic injection was introduced to solve this problem.
Usage
- Define an abstract class and a type enum.
export enum HelloType {
FOO = 'FOO',
BAR = 'BAR',
}
// AbstractHello.ts
export abstract class AbstractHello {
abstract hello(): string;
}- Define a custom enum.
DANGER
Notes:
- Don't duplicate ATTRIBUTE, as it may cause implementations to be overwritten
- Don't specify the wrong abstract class, as it may cause implementations to be overwritten
import { ImplDecorator, QualifierImplDecoratorUtil } from 'egg';
import { HelloType } from '../HelloType.ts';
import { AbstractHello } from '../AbstractHello.ts';
export const HELLO_ATTRIBUTE = Symbol('HELLO_ATTRIBUTE');
// This utility class can implement type checking
// 1. With this annotation, you must implement the abstract class
// 2. The annotation parameter must be an enum value
export const Hello: ImplDecorator<AbstractHello, typeof HelloType> =
QualifierImplDecoratorUtil.generatorDecorator(AbstractHello, HELLO_ATTRIBUTE);- Implement the abstract class.
import { SingletonProto } from 'egg';
import { Hello } from '../decorator/Hello.ts';
import { HelloType } from '../HelloType.ts';
import { AbstractHello } from '../AbstractHello.ts';
@SingletonProto()
@Hello(HelloType.BAR)
export class BarHello extends AbstractHello {
hello(): string {
return 'hello, bar';
}
}- Dynamically get the implementation.
import { EggObjectFactory, SingletonProto, Inject } from 'egg';
import { HelloType } from './HelloType.ts';
import { AbstractHello } from './AbstractHello.ts';
@SingletonProto()
export class HelloService {
@Inject()
private eggObjectFactory: EggObjectFactory;
async hello(): Promise<string> {
const helloImpl = await this.eggObjectFactory.getEggObject(
AbstractHello,
HelloType.BAR,
);
return helloImpl.hello();
}
}Real-World Example
cnpmcore/app/common/adapter/binary/AbstractBinary.ts
FAQ
- What if I don't have an enum and the type is infinitely extensible?
// Use a record to masquerade as an enum
type AnyEnum = Record<string, string>;
export const Convertor: ImplDecorator<AbstractFoo, AnyEnum> =
QualifierImplDecoratorUtil.generatorDecorator(AbstractFoo, FOO_ATTRIBUTE);