sbking
sbking

Reputation: 7680

Dependency injection: recommended pattern for injecting NPM modules

I'd like to use Inversify to remove hard dependencies on NPM modules and inject them as constructor arguments instead. This seemed like it would be a lot simpler before I gave it a shot.

It turns out that most DefinitelyTyped modules don't bother to export interfaces, and when they do, they rarely include an interface that represents the entire module. Further, when a class is exported, I still have to manually define a constructor interface for that class.

This means I have to do something like this for pretty much every module:

import * as ConcreteModule from 'module'
import { ContainerModule } from 'inversify'

export interface ModuleInstance {
  // copy a ton of stuff from DefinitelyTyped repo,
  // because they didn't export any interfaces
}

export interface ModuleConstructor {
  new (...args: any[]): ModuleInstance
}

export const ModuleConstructorSymbol = Symbol('ModuleConstructor')

export const ModuleContainer = new ContainerModule((bind) => {
  bind<ModuleConstructor>(ModuleConstructorSymbol).toConstantValue(ConcreteModule)
})

Is there any way to simplify some of this? There's just so much overhead to inject an NPM module, and no guidance whatsoever from the Inversify docs. Managing the names for all the different imports/exports you need (interface, symbol, and container) is a pain and requires coming up with some kind of consistent naming scheme. It seems like without TypeScript supporting some way to automatically create an interface from a module, there's just no way to inject NPM packages in a sane manner.

I suppose I could just use jest's automock feature for modules, but I really don't like designing my code in such a way that it is only unit testable with a specific testing framework.

It seems like this could be at least a bit more achievable if I could just do this:

import * as ConcreteModule from 'module'

export interface TheModule extends ConcreteModule {}

But this only works if the module exports a class (not a factory) and still doesn't really help me with the constructor.

Upvotes: 5

Views: 4162

Answers (1)

Remo H. Jansen
Remo H. Jansen

Reputation: 24941

The following example demonstrates how to inject npm modules (lodash & sequelize) into a class SomeClass.

The directory structure looks as follows:

src/
├── entities
│   └── some_class.ts
├── index.ts
└── ioc
    ├── interfaces.ts
    ├── ioc.ts
    └── types.

/src/ioc/types.ts

const TYPES = {
    Sequelize: Symbol("Sequelize"),
    Lodash: Symbol("Lodash"),
    SomeClass: Symbol("SomeClass")
};

export { TYPES };

/src/ioc/interfaces.ts

import * as sequelize from "sequelize";
import * as _ from "lodash";

export type Sequelize = typeof sequelize;
export type Lodash = typeof _;

export interface SomeClassInterface {
    test(): void;
}

/src/ioc/ioc.ts

import { Container, ContainerModule } from "inversify";
import * as sequelize from "sequelize";
import * as _ from "lodash";
import { TYPES } from "./types";
import { Sequelize, Lodash } from "./interfaces";
import { SomeClass } from "../entities/some_class";

const thirdPartyDependencies = new ContainerModule((bind) => {
    bind<Sequelize>(TYPES.Sequelize).toConstantValue(sequelize);
    bind<Lodash>(TYPES.Lodash).toConstantValue(_);
    // ..
});

const applicationDependencies = new ContainerModule((bind) => {
    bind<SomeClass>(TYPES.SomeClass).to(SomeClass);
    // ..
});

const container = new Container();

container.load(thirdPartyDependencies, applicationDependencies);

export { container };

/src/entitites/some_class.ts

import { Container, injectable, inject } from "inversify";
import { TYPES } from "../ioc/types";
import { Lodash, Sequelize, SomeClassInterface } from "../ioc/interfaces";

@injectable()
class SomeClass implements SomeClassInterface {

    private _lodash: Lodash;
    private _sequelize: Sequelize;

    public constructor(
        @inject(TYPES.Lodash) lodash,
        @inject(TYPES.Sequelize) sequelize,
    ) {
        this._sequelize = sequelize;
        this._lodash = lodash;
    }

    public test() {
        const sequelizeWasInjected = typeof this._sequelize.BIGINT === "function";
        const lodashWasInjected = this._lodash.cloneDeep === "function";
        console.log(sequelizeWasInjected); // true
        console.log(lodashWasInjected); // true
    }

}

export { SomeClass };

/src/index.ts

import "reflect-metadata";
import { container } from "./ioc/ioc";
import { SomeClassInterface } from "./ioc/interfaces";
import { TYPES } from "./ioc/types";

const someClassInstance = container.get<SomeClassInterface>(TYPES.SomeClass);
someClassInstance.test();

/package.json

{
  "name": "test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "inversify": "^4.1.0",
    "lodash": "^4.17.4",
    "reflect-metadata": "^0.1.10",
    "sequelize": "^3.30.4"
  },
  "devDependencies": {
    "@types/lodash": "^4.14.63",
    "@types/sequelize": "^4.0.51"
  }
}

/tsconfig.json

{
    "compilerOptions": {
        "target": "es5",
        "lib": ["es6", "dom"],
        "types": ["reflect-metadata"],
        "module": "commonjs",
        "moduleResolution": "node",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}

This example is now available in the inversify docs.

Upvotes: 9

Related Questions