2.0.0-beta.2 • Published 3 months ago

@vitalyrudenko/dependency-registry v2.0.0-beta.2

Weekly downloads
-
License
MIT
Repository
github
Last release
3 months ago

@vitalyrudenko/dependency-registry

Usage

Installation

npm i @vitalyrudenko/dependency-registry

Initialize the registry

const { DependencyRegistry } = require('@vitalyrudenko/dependency-registry');

const registry = new DependencyRegistry();

Values

registry.registerValue('externalApiKey', config.external.apiKey);

const { externalApiKey } = registry.export();

Instances

Class instances

The name of the instance is automatically generated from the class name.

registry.registerInstance(new House());

const { house } = registry.export();
Named instances
registry.registerInstance('myHouse', new House());
registry.registerInstance('pgPool', pg.createPool());

const { myHouse, pgPool } = registry.export();

Factories

Simple class factories

The name of the factory is automatically generated from the class name.

class MyClass {
    constructor(a, b, c) {
        console.log('test:', a, b, c);
    }
}

registry.registerFactory(MyClass);

const { myClassFactory } = registry.export();

myClassFactory.create(123, 'hello', true); // logs "test: 123 hello true"
Dependent class factories

The name of the factory is automatically generated from the class name.

class Transformer {
    transform(value) {
        return '(' + value + ')';
    }
}

class MyClass {
    constructor(a, b, c, { transformer }) {
        console.log(
            'test:',
            transformer.transform(a),
            transformer.transform(b),
            transformer.transform(c)
        );
    }
}

registry.registerInstance(new Transformer());
registry.registerFactory(MyClass, (deps, ...args) => new MyClass(...args, deps));

const { myClassFactory } = registry.export();

myClassFactory.create(123, 'hello', true); // logs "test: (123) (hello) (true)"
Named factories
registry.registerInstance('translator', {
    translate(key, variables) {
        if (key === 'greeting') {
            return `Hello, ${variables.name}!`;
        }

        throw new Error('Unknown key: ' + key);
    }
})

registry.registerFactory(
    'greetingFactory',
    ({ translator }, name) => translator.translate('greeting', { name })
);

const { greetingFactory } = registry.export();

console.log(greetingFactory.create('John Doe')); // logs "Hello, John Doe!"

Importing registries

registry1.registerValue('appName', 'My App');

registry2.registerFactory('welcomeFactory', ({ appName }) => `Welcome to ${appName}!`);
registry2.import(registry1);

const { welcomeFactory } = registry2.export();

console.log(welcomeFactory.create()); // logs "Welcome to My App!"

Imported factories can use dependencies of the new registry

The problem

When using dependency injection or writing unit tests, you might notice that your dependency hierarchy slowly becomes bloated and hard to maintain.

Let's say we have a simple HouseBuilder class, which creates House instances:

class HouseBuilder {
    constructor({ windowCount, hasDoor }) {
        this._windowCount = windowCount;
        this._hasDoor = hasDoor;
    }

    build(owner) {
        return new House({
            owner,
            windows: Array.from(
                new Array(this._windowCount),
                () => new Window()
            ),
            door: this._hasDoor ? new Door() : null
        });
    }
}

Now let's say that House requires to have its own security system, and it's gonna be an external security system (which "implements" Security interface), which requires an apiKey:

const config = require('../config');

class House {
    constructor({ owner, windows, door }) {
        this._security = new ProvidedSecurity({
            windows,
            door
        }, {
            apiKey: config.apiKey,
        });
    }

    toggleLock() {
        if (this._security.isLocked()) {
            this._security.unlock();
        } else {
            this._security.lock();
        }
    }
}

And on top of that, let's add new dependency to the Window:

class Window {
    constructor() {
        this._windowHandle = new WindowHandle();
    }

    toggle() {
        if (this._windowHandle.isOpened) {
            this._windowHandle.close();
        } else {
            this._windowHandle.open();
        }
    }
}

Finally, let's add a WindowsUtils dependency to the WindowHandle:

class WindowHandle {
    constructor({ windowUtils }) {
        this._windowUtils = windowUtils;
        this._isOpened = false;
    }

    measure() {
        return this._windowUtils.measureDimensions(/* ... */);
    }

    open() {
        this._isOpened = true;
    }

    close() {
        this._isOpened = false;
    }

    get isOpened() {
        return this._isOpened;
    }
}

But wait, how are we going to pass windowUtils to the WindowHandle?

We'll need to pass it to HouseBuilder, then to Window:

class HouseBuilder {
    constructor({ windowCount, hasDoor }, { windowUtils }) {
        this._windowCount = windowCount;
        this._hasDoor = hasDoor;
        this._windowUtils = windowUtils;
    }

    build(owner) {
        return new House({
            owner,
            windows: Array.from(
                new Array(this._windowCount),
                () => new Window({ windowUtils })
            ),
            door: this._hasDoor ? new Door() : null
        });
    }
}
class Window {
    constructor({ windowUtils }) {
        this._windowHandle = new WindowHandle({ windowUtils });
    }

    // ...
}

There are three main problems with the code above:

  • This code is almost impossible to unit test, because it's very hard to mock creation of external dependencies (like new ProvidedSecurity() or new WindowHandle())
  • The code knows about stuff it shouldn't know about (like ProvidedSecurity implementation and config)
  • We need to pass all necessary dependencies from top to bottom of the dependency hierarchy (like with WindowsUtils: HouseBuilder -> Window -> WindowHandle), which means top-level code should know how are low-level instances are created and which dependencies they use. It's also hard to add new dependencies in low-level code, because we now need to pass it to every single "parent".

This tiny dependency registry tries to solve these issues by flattening the dependency tree and creating factories for classes.

In order to do that, we need to specify how to instantiate every class:

// app.js
const registry = new DependencyRegistry();

registry.registerFactory(House, (deps, attributes) => new House(attributes, deps));
registry.registerFactory(
    'securityFactory',
    (deps, attributes) => new ProvidedSecurity(attributes, { apiKey: config.apiKey })
);

registry.registerFactory(Window, (deps, attributes) => new Window(deps));
registry.registerFactory(WindowHandle, (deps, attributes) => new WindowHandle(deps));
registry.registerInstance(new WindowUtils());

registry.registerFactory(Door);

const houseBuilder = new HouseBuilder(
    { windowCount: 2, hasDoor: true },
    registry.export()
);

const house = houseBuilder.build(new Person('John Doe'));

Now we need to update our classes to use the factories to create instances:

class HouseBuilder {
    constructor(
        { windowCount, hasDoor },
        { houseFactory, windowFactory, doorFactory }
    ) {
        this._windowCount = windowCount;
        this._hasDoor = hasDoor;

        this._houseFactory = houseFactory;
        this._windowFactory = windowFactory;
        this._doorFactory = doorFactory;
    }

    build(owner) {
        return this._houseFactory.create({
            owner,
            windows: Array.from(
                new Array(this._windowCount),
                () => this._windowFactory.create()
            ),
            door: this._hasDoor ? this._doorFactory.create() : null
        });
    }
}
class House {
    constructor({ owner, windows, door }, { securityFactory }) {
        this._security = securityFactory.create({
            windows,
            door
        });
    }

    toggleLock() {
        if (this._security.isLocked()) {
            this._security.unlock();
        } else {
            this._security.lock();
        }
    }
}
class Window {
    constructor({ windowHandleFactory }) {
        this._windowHandle = windowHandleFactory.create();
    }

    toggle() {
        if (this._windowHandle.isOpened) {
            this._windowHandle.close();
        } else {
            this._windowHandle.open();
        }
    }
}

As you can see:

  • Code is now perfectly unit testable, because we can create mock factories and completely isolate the module when testing it
  • All classes only know about dependencies they actually use, they don't know about dependencies of the "children" classes (for example, House doesn't know about WindowHandle and Window doesn't know about WindowUtils). Now it's much easier to change dependencies of low-level classes, because we don't need to change anything it in "parent" classes
  • Code doesn't know about stuff it shouldn't know about (for example, House doesn't know about ProvidedSecurity class and config, it's only concerned about Security interface and not the details)

It's important that your application code (like business logic) doesn't know about DependencyRegistry's existence.

You only really need it in main part of your application, which initializes the application, configures routes, creates connections to databases, etc. (like app.js, routers and controllers).

In terms of your business logic, it should behave as if those factories do actually exist in your code.

2.0.0-beta.2

3 months ago

2.0.0-beta.1

3 months ago

1.0.2

3 years ago

1.0.1

3 years ago

1.0.0

3 years ago

1.0.0-pre.2

3 years ago

1.0.0-pre.1

3 years ago