Adrián Ferrera

tsyringe

Introduccción

Hoy en día, la gran mayoría de las personas que trabajan en la industria del software buscan los recursos necesarios para mejorar la calidad de su código y poder crear herramientas de calidad, bien sea para uso personal, empresarial o de carácter altruista.

Cuando buscamos referencias a cómo conseguir desarrollar un mejor Software, lo primero con lo que nos topamos son los principios SOLID, definidos por Robert C. Martin (Uncle Bob), a comienzos de los años 2000. SOLID no es otro que el acrónimo de cinco principios básicos en la programación orientada a objetos (POO), con los cuales conseguir que el código que desarrollamos sea más mantenible.

No es el propósito de este artículo explicar todos y cada uno de los principios, sin embargo, si que nos centraremos en el último de ellos representado por la letra D (Dependency Inversion Principle).

Dependency Inversion Principle

El principio de Inversión de dependencias define que los módulos de alto nivel, no deberían depender de los módulos de bajo nivel, ambos deberían depender de interfaces y por tanto trabajar con la abstracción. Esto nos permite reducir el acoplamiento entre las distintas capas del Software.

Ahora bien, para verle sentido a este principio tan abstracto, debemos partir de la base de que la aplicación o herramienta a desarrollar, estará dividido en capas, de no ser así e intentar aplicarlo, acabaríamos incurriendo en malos patrones de diseño.

Por otra parte el uso de interfaces estará fuertemente ligado al lenguaje de programación que se utilice. Por ejemplo: En JavaScript no existen las interfaces, pero si se puede aplicar este principio. Recordemos que: "Una clase que no implementa una interfaz de forma explicita, se implementa a si misma".

¿Qué nos permite?

Una vez hemos entendido el principio, toca explicar que nos aporta el poder trabajar con las abstracciones de los módulos en lugar de utilizar los módulos reales.

Cuando trabajamos con interfaces nos olvidamos de los detalles de implementación que existan por debajo. Solamente conoceremos que el objeto/clase que deseamos consumir expone unos métodos con una firma en concreto que siempre van a estar disponibles en el momento de su uso.

Ahora bien, estos módulos pueden ser sustituidos por otros en el momento en que definamos cual deseamos usar.

Supongamos que deseamos aplicar el patrón repository, podemos tener distintas implementaciones como puede ser una para PostrgreSQL y otra para MongoDB, las cuales podran ser usadas de forma indiscriminada, sin tener que modificar el código de quien las consume.

Incluso en fases tempranas del desarrollo, donde estos factores de implementación no son relevantes y las decisiones pueden ser postergadas, podemos trabajar haciendo uso de las interfaces y trabajar con una implementación sólo para pruebas.

¿Cómo lo hacemos?

Generalmente este proceso de instanciar el módulo que nos interese en una clase, se realiza haciendo uso de los constructores. Dado que tsyringe es una librería para TypeScript, el código que veremos a continuación será en cómo podríamos hacerla sin esta librería:

class Service {
  constructor(private repository: Repository = new MongoRepository()) {}

	find () {
    return this.repository.find()
  }
}

Con el siguiente ejemplo vemos como para construir nuestro servicio podemos pasarle de forma opcional un repository. En caso de no hacerlo, aplicará el valor por defecto de utilizar MongoRepository.

En test podríamos tener el siguiente código para inyectar un repository de prueba:

it('calls to find', () => {
  const repository: Repository = { find: jest.fn() }
  const service = new Service(repository)

	service.find()

	expect(repository.find).toHaveBeenCalled()
})

Ahora bien, si quisiéramos hacer esto mismo pero desde una capa de mayor nivel al servicio, por ejemplo el controller, deberíamos dejar reflejado en este toda la cadena de llamadas a los constructores para inyectar el repository deseado, lo cual no reduce en absoluto el acoplamiento y hace nuestro código más frágil.

const repository: Repository = { find: jest.fn() }
const controller = new Controller(new Service(repository))

Por su parte tsyringe nos permitirá, mediante el uso de anotaciones, simplificar todo este proceso y reducir el acoplamiento de estas dependencias entre módulos.

¿Qué es tsyringe?

Ahora que entendemos el principio de Dependency Inversion y hemos visto como usarlo en TypeScript, podemos entender que nos aporta el uso de la librería, frente al uso clásico de la misma.

La librería de tsyringe, desarrollada por Microsoft, al igual que TypeScript está pensada para ser utilizada en paradigmas de POO. Con ella podemos anotar las clases que queramos inyectar y el paradigma que se usará a la hora de resolver la dependencia.

@injectable()
class MyDependency {}

Por otro lado, podremos definir en el constructor de la clase sobre la que inyectamos el cómo debe definir la dependencia:

class MyService {
  constructor(@inject(MyDependency) private dependency: MyDependency) {}
}

Por supuesto, podemos trabajar con interfaces tal y como indica el principio:

interface Dependency { foo: () => void }

@injectable()
class MyDependency implements Dependency { /* ... */ }

@injectable()
class MyService {
  constructor(@inject("Dependency") private dependency: Dependency) {}
}

container.registerInstance("Dependency", new MyDependency());
const service = container.resolve(MyService)

Esto nos permitirá prototipar nuestro Software rápidamente, a la vez que podemos probar de forma unitaria elementos de nuestro software que tienen dependencias del sistema, falseando la llamada a los métodos de estos módulos inyectados.

Con la salvedad de que, en caso de usar interfaces, estas no son instanciables de forma automática, y deberemos registrar nosotros la implementación de forma manual.

¿Cómo instalarlo?

En su versión actual JavaScript no tiene soporte para anotaciones, aunque se prevé que para próximas versiones si lo tenga contemplado.

Recordemos que cuando transpilamos código TypeScript a JavaScript, todas las entidades que no están nativas en la definición del lenguaje y no pueden ser encapsuladas en funciones desaparecen.

Es por ello que para dar soporte a esta funcionalidad deberemos, no solo instalar tsyringe, sino también la librería reflect-metadata.

npm i tsyringe reflect-metadata

Una vez hecho esto debemos hacer dos cosas:

  • Habilitar las anotaciones en TypeScript
  • Importar de forma estática reflect-metadata en el entry-point de la aplicación y los test.

Para habilitar las anotaciones en la configuración únicamente tendremos que añadir las siguientes líneas a nuestro fichero tsconfig.json:

"compilerOptions": {
//...
  "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
  "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
//...
}

A continuación, tendremos que importar la librería reflect-metadata en el punto de entrada de nuestra aplicación, con la finalidad de que la librería esté cargada previo al uso de las anotaciones.

Esto podría realizarse en otros puntos de la aplicación, sin embargo, considero más intuitivo el hacerlo en este punto, ya que habilita la posibilidad de ser usado en cualquier punto. Si este no es tu caso y quieres limitar el uso de las anotaciones, entonces deberás evaluar tu caso más a medida.

 //server.ts
import 'reflect-metadata'
// ...

runServer()

De la misma forma, nuestros test (salvo en el caso de los e2e), rara vez van a pasar por este fichero de inicialización, incluso en la salvedad de los e2e, lo más probable es que quieras hacer uso de las anotaciones previo a su arranque, por lo que deberás añadir este import en dicho scope.

Para ello puedes hacerlo de forma individual en cada test, o de forma global en algún fichero de configuración como por ejemplo en el caso de Jest, el setupTests.ts.

Injectable, Singleton e Inject

Como ya hemos comentado anteriormente tsyringe funciona haciendo uso de una instancia global llamada container, en la cual irá registrando todas nuestras clases en la medida que sean llamadas en código (inyectadas en otros componentes o registradas en el propio container).

De esta forma, cuando una dependencia sea solicitada la librería será capaz de resolver la dependencia en función de como haya sido anotada previamente.

Para registrar una clase las dos anotaciones más utilizadas son injectable y singleton. Mientras que la primera registra una nueva instancia en el container cada vez que es llamada, la segunda instancia únicamente una entidad, tal y como funciona el patrón de diseño singleton.

@injectable()
class PostgresRepository implements Repository() {
  constructor() {}

  find () { /* ... */ }
}

Una vez anotada la clase, podemos hacer uso de la misma, inyectándola en el constructor de quien vaya a usar dicha clase:

@injectable()
class Service {
  constructor(@inject('Repository') private repository: Repository) {}
}

En el momento en que se llame al constructor de Service, se creará una instancia dentro de container a la que podremos acceder desde cualquier punto de nuestra aplicación. Al tratarse de un injectable pueden existir distintas instancias de la misma anotación.

Sin embargo, dado que en el constructor de Service se referencia a una interfaz, debemos de indicarle de forma explícita como debe resolverlo, tal y como veremos a continuación.

Resolve y Register

Ahora bien, cuando se utiliza la propia clase al utilizar la anotación inject, la librería sabe que valor tiene que definir en la instancia de container. Cuando utilizamos una interfaz, la cual asociamos a un string, esta se debe registrar de forma manual previo a llamar al container:

container.registerInstance("Repository", new PostgresRepository());

De la misma forma que las instancias de los módulos inyectados se registran en la entidad container, podemos recuperar a través de la misma la instancia deseada de la siguiente manera utilizando el método resolve:

import { container } from 'tsyringe'

const service = container.resolve('Service')

Esto puede resultar útil a la hora de sobrescribir métodos en nuestros test:

import { container } from 'tsyringe'

const repository = container.resolve('Repository')
repository.find = jest.fn(() => 'irrelevant')

Por otra parte lo que podemos hacer es previo al arranque de la aplicación, registrar nuestras propias instancias. Por ejemplo para definir el tipo de repository a usar de forma global:

import { container } from 'tsyringe'

const runServer = () => {
  container.registerInstance('Repository', new PostgresRepository())
}
const repository = container.resolve('Repository')
repository.find = jest.fn(() => 'irrelevant')

Conclusión

Gracias al uso de herramientas podemos hacer el código de nuestras aplicaciones mucho más mantenibles, pudiendo diseccionar las distintas capas de la aplicación de una forma mucho más elegante y realizar test aún más dinámicos y sencillos de mantener.

Como contraparte es cierto que nos estaremos acoplando a la tecnología, pero esto es un precio que se debe valorar y que, considero, es bastante asumible sobre todo teniendo en cuenta quien ha sido el desarrollador de la herramienta y del lenguaje.

Espero que este post haya sido de ayuda, bien por explicar el concepto de Inversión de dependencia, o por el descubrimiento de la herramienta en si. Si así ha sido, no dudes en compartirlo.

Como siempre, dejo un enlace al repositorio de Github, donde podrás experimentar con mayor profundidad en los ejemplos mostrados.