At work, we encountered a fairly common architectural problem in our project. We have a set of services that depend on each other. Initially, the relationships between the services were described using DI through the constructor.
interface Options {
service1: Service1;
service2: Service2;
serviceN: ServiceN;
}
class ServiceNew {
constructor(private options: Options) {}
}
In most cases, this approach is quite effective. However, sometimes there are situations, for example, when two services depend on each other and you have to inject the dependency through getter functions.
class Sevice1 {
constructor(
private getService2(): Service2,
private serviceN: ServiceN
) {}
}
class Sevice2 {
constructor(
private getService1(): Service1,
private serviceN: ServiceN
) {}
}
In a situation where there are, for example, 20 such services, we want a more uniform approach. For example, we can turn to the experience of the Java language and save all the services in one object and pass a reference to it to each service. And for better code isolation, you can limit the list of used services at the type level. The main condition for this approach is that you cannot access such an object in the constructor, because at this point the object is still being filled. At the same time, the declarativeness in the description of dependencies remains and the uniformity of the approach is preserved. Also, if in some service we suddenly forget about the declaration of the ctx
field, the type-checker will remind us about it.
interface IServiceCtx {
service1: Service1;
service2: Service2;
// .....
serviceN: ServiceN;
}
class Service1 {
ctx!: Pick<IServiceCtx, 'service2' | 'serviceN'>
}
class Service2 {
ctx!: Pick<IServiceCtx, 'service1' | 'serviceN'>
}
const ctx: IServiceCtx = {
service1: new Service1(),
service2: new Service2(),
// ....
serviceN: new ServiceN(),
}
Object.values(ctx).forEach(instance => {
instance.ctx = ctx;
});
For greater unification, you can use the following code snippet.
export function injector<T>(
init: { [K in keyof T]: () => T[K]; },
inject: (instance: T[keyof T], val: T) => void,
): T {
const result = {} as Record<string, unknown>;
Object.entries(init).forEach(([key, fn]) => {
result[key] = (fn as () => unknown)();
});
Object.values(result).forEach(ref => {
inject(ref as T[keyof T], result as T);
});
return result as T;
}
const ctx = injector<IServiceCtx>({
service1: new Service1(),
service2: new Service2(),
// ....
serviceN: new ServiceN(),
}, (instance, ctx) => {
instance.inject = ctx;
// here you can do some additional actions
});