Inyección de Dependencias en TypeScript usando un Service Locator

En un anterior artículo os contaba que para mí, usar un service locator no es un antipatrón siempre, dependen de como lo uses.

En este artículo vamos a ver porque utilizar un service locator en TypeScript, como utilizarlo y como creo yo un service locator en mis proyectos donde trabajo con TypeScript.

¿Por qué usar un service locator en TypeScript?

He trabajado en diferentes tecnologías, lenguajes y en todas existen librerías para gestionar las inyección de dependencias.

Normalmente estas soluciones se engloban dentro de 2 tipos:

  • IOC Containers
  • Service locators

La principal diferencia es que el IOC Container hace de forma más inteligente la inyección de dependencias normalmente utilizando reflexión o algún tipo de generación de código en tiempo de compilación, mientras que service locator es un proceso más manual.

Es un proceso más manual porque la clase o componente de tu aplicación que tiene dependencias es la responsable de solicitar dichas dependencias mientras que con IOC containers, esto no es necesario es un poco más automágico.

El problema es que hay tecnologías donde esta automagia, como en las soluciones que existen para TypeScript, necesita de muchas anotaciones a lo largo de todo tu código.

De esta forma te acoplas fuertemente mediante anotaciones a librería que estes usando.

Si hay algo seguro, es que si estas desarrollando una aplicación que va a tener una larga duración y que vas a tener que mantener con el tiempo, te estas poniendo un problema a futuro.

Para evitar un acoplamiento fuerte en TypeScript a un libreria e IOC Container prefiero utilizar un service locator.

¿Comó utilizar un service locator en TypeScript?

Service locator está considerado como un antipatron pero, en mi opninión, depende de como se use.

Como utilizar un service locator es algo que ya comenté en este artículo, pero creo que es interesante recordar la idea general.

Si todas las partes de nuestro proyecto, desde la capa de presentación hasta la capa de datos o de infraestructura solicitan sus dependencias via service locator, para mí este uso si es un antipatrón.

Sin embargo, el service locator puede ser utilizado exclusivamente para resolver las clases más altas de la jerarquía de dependencias del flujo de ejecución, como es la capa de presentación.

Y en el resto de capas inferiores si pueden recibir sus dependencias inyectadas en el constructor.

Las capas inferiores de nuestra arquitectura, como el dominio o la capa de datos, recibirán sus dependencias via contructor ajenas a si se esta usando un service locator o un IOC container.

En este caso desde mi punto de vista, usándolo así no es un antipatrón.

Responsabilidades de un service locator en TypeScript

Las responsabilidades del service locator va a depender del tipo de service locator que creemos.

Tipos de Service Locator

Cuando creamos un service locator tenemos dos opciones:

Estático

Un service locator estático, no es reutilizable y resuelve las dependencias del proyecto de forma estática, por ejemplo, con métodos como getUsersPresenter y esta función crea el tipo solicitado e inyecta de forma manual las dependencias.

Por lo tanto, un service locator estático no es reutilizable.

Un service locator estático en TypeScript tiene sentido cuando estamos desarrollando una aplicación no muy grande, alislada donde no hay necesidad de reulitizar.

Por ejemplo desarrollamos de forma aislada una aplicación nodejs, una aplicación frontend, un servidor API REST.

Dinámico

Un service locator dinámico, es reutilizable porque no esta acoplado a tu aplicación.

Este tipo de service locator suele tener mecanismos de registro de dependencias de forma agnostica a tu aplicación.

De esta forma cuando usamos un service locator dinámico vamos a necesitar otro componente o componentes, según la envergadura del proyecto que se encargue de registrar las dependencias en el service locator, a este componente le suelo llamar CompositionRoot.

También puede tener mecanismos reutilizable de registrar una dependencia como singleton o de otro tipo.

Existen librerías para gestionar la dependencias donde utilizan un service locator pero vamos a ver como podemos crearnos uno de forma sencilla y suficiente para la mayoría de aplicaciones en TypeScript.

Un service locator dinámico en TypeScript tiene sentido cuando estamos desarrollando una aplicación más grande, donde desarrollamos tanto la parte frontend y además la backend o algún servicio donde es necesario reutilizar la lógica de gestión de dependencias para que sean singleton o tipo factoría que crea las dependencias al momento.

Definiendo el service locator

Vamos a definir un service locator dinámico donde vamos a poder registrar como dependencias clases o interfaces.

Aunque en TypeScript tenemos tipos, las dependencias van a ser registradas y solicitadas en tiempo de ejecución y por lo tanto se va a realizar en Javascript ya transpilado.

Las clases tenemos una forma de reconocerlas para registrarlas y solicitarlas en base a una abstracción Type, con un método constructor, que usaremos depues como key para el registro de clases:

interface Type<T> {
    new (...args: any[]): T;
}

De esta forma usando Type vamos a poder registrar una dependencias que sea una clase y solicitarla por su tipo después, pero las interfaces no porque no existen en Javascript.

Para registrar interfaces necesitamos utilizar como key un string, Symbol, o una clase con una prop string o algo similar.

Vamos a creanos un tipo Token que represente los tipos de key que podemos tener:

type Token<T> = Type<T> | string;

Como podemos queremos tener dependencias de tipo singleton y asi usar la misma instancia a lo largo de vida de la aplicación y dependencias que se crean al momento, vamos a crearnos un tipo Binder que nos indique de que tipo es la dependencia y la función que resuelve la dependencia.

type BinderType = "lazySingleton" | "factory";

type Binder<T> = {
    type: BinderType;
    fn: () => T;
};

Ahora ya tenemos el ServiceLocator que es un singleton, pero podría no serlo según tus necesidades.

Con los métodos para registrar dependencias de tipo singleton y de tipo factoria, así como un método para recuperar la depencencia en base a un token.

También tendremos un método clear muy útil para limpiar el registro de dependencias por ejemplo en los tests.

export class DependencyLocator {
    private factories = new Map<Token<any>, Binder<any>>();
    private lazySingletons = new Map<Token<any>, any>();

    private static instance: DependencyLocator;
    private constructor() {}

    static getInstance(): DependencyLocator {
        if (!DependencyLocator.instance) {
            DependencyLocator.instance = new DependencyLocator();
        }

        return DependencyLocator.instance;
    }

    public get<T>(token: Type<T> | string): T {
            const factory = this.factories.get(token);

            if (!factory) {
                throw new Error(`Dependency ${token} is not registered`);
            }

            if (factory.type === "lazySingleton") {
                const singleton = this.lazySingletons.get(token) || factory.fn();
                this.lazySingletons.set(token, singleton);

                return singleton;
            } else {
                return factory.fn();
            }
    }

    public bindFactory<T>(token: Type<T> | string, fn: () => T) {
        this.factories.set(token, { type: "factory", fn });
    }

    public bindLazySingleton<T>(token: Type<T> | string, fn: () => T) {
        this.factories.set(token, { type: "lazySingleton", fn });
    }

    public clear() {
        this.factories.clear();
        this.lazySingletons.clear();
    }
}

Definiendo el composition root

Cuando tenemos un service locator dinámico, es necesario tener otro componente que sea el que sabe como crear las diferentes clases con sus dependencias y las registra en el service locator.

Este podría ser un ejemplo:

import LoginBloc from "./user/presentation/LoginBloc";
import LoginUseCase from "./user/domain/LoginUseCase";
import UserApiRepository from "./user/data/UserApiRepository";
import axios from "axios";
import { TokenLocalStorage } from "./common/data/TokenLocalStorage";
import GetCurrentUserUseCase from "./user/domain/GetCurrentUserUseCase";
import AppBloc from "./app/AppBloc";
import { DependencyLocator } from "karate-stars-core";
import UserRepository from "./user/domain/Boundaries";

export const names = {
    axiosInstanceAPI: "axiosInstanceAPI",
    axiosInstancePush: "axiosInstancePush",
    userRepository: "userRepository",
    tokenStorage: "tokenStorage",
};

export const di = DependencyLocator.getInstance();

export function init() {
    initApp();
    initUser();
}

export function reset() {
    di.clear();
    init();
}

function initApp() {
    di.bindLazySingleton(names.axiosInstanceAPI, () =>
        axios.create({
            baseURL: "/api/v1/",
        })
    );

    di.bindLazySingleton(names.tokenStorage, () => new TokenLocalStorage());
}

function initUser() {
    di.bindLazySingleton(
        names.userRepository,
        () => new UserApiRepository(di.get(names.axiosInstanceAPI), di.get(names.tokenStorage))
    );
    di.bindLazySingleton(
        GetCurrentUserUseCase,
        () => new GetCurrentUserUseCase(di.get<UserRepository>(names.userRepository))
    );

    di.bindFactory(
        AppBloc,
        () => new AppBloc(di.get(GetCurrentUserUseCase))
    );

    di.bindLazySingleton(LoginUseCase, () => new LoginUseCase(di.get(names.userRepository)));
    di.bindFactory(LoginBloc, () => new LoginBloc(di.get(LoginUseCase)));
}

Ventajas de usar un service locator en TypeScript

Estas son las ventajas de usar un service locator en typescript:

  • Sin acoplamiento de anotaciones en todas las capas de código con un librería de inyección de dependencias.
  • Baja curva de aprendizaje
  • Optimizado para testing, si creamos un service locator dinámico
  • Más fácil y rapido de usar
  • No es un antipatrón si lo utilizamos de forma correcta

Desventajas de usar un service locator en TypeScript

Estas son las desventajas de usar un service locator en typescript:

  • Mantenimiento, cada vez que cambian parámetros de un constructor que tiene dependencias, es necesario actualizar el composition root.
  • Cuando el equipo es grande y la aplicación también, puede haber más conflictos a resolver con el compositión root en los pull request que usando un librería de IOC container. Esto se puede minimizar creando un composition root por feature, en lugar de tener uno solo por aplicacón.

Conclusiones

En este artículo hemos visto como podemos crearnos un service locator sencillo en Typescript.

Como hemos visto tiene sus ventajas y desventajas. En mi caso que soy desarrollador freelance y en la mayoría de mis proyectos o estoy yo solo o en el proyecto colaboran 1 o 2 personas, me compensa más evitar el acoplamiento que los posibles conflictos a resolver en los pull request.

Como hemos visto estos conflictos se minimizan si creamos un composition root por feature, que por otra parte es recomendable.