Skip to content

markeasting/dependency-injection

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

70 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Minimal dependency injection container

Minimal Dependency Injection container, written in Typescript.

Features

Why?

To prevent spaghetti code (i.e. enforce SOLID / reduce tight coupling), without inflating the codebase too much. Some Javascript DI solutions tend to use decorators (which are experimental) or reflect-metadata (extra package), or have other features that I didn't like.

You can achieve the most basic form of DI by using some kind of service layer to construct classes and provide their dependencies.

This module relies on native Typescript / Javascript to enable (manual) DI. It gives full control to build your own application container and wire services as you desire. When keeping your services and bundles small and managable, your code / coupling will be better and easier to maintain.

For a concrete usecase, see the basic example app.

Installation

This package is not available on NPM (just yet). For now, you can clone this repository and link the local package in your own project.

npm link @markeasting/dependency-injection

Development / building

npm run build - run the Typescript compiler (tsc).

npm run watch - runs tsc in watch mode.

Unit testing

npm test - uses Bun as test runner.

Usage guide

Create a container

import { Container } from "@markeasting/dependency-injection";

const container = new Container();

Or import a globally available container instance directly:

import { container } from "@markeasting/dependency-injection";

Registering services

Simply register your class as a service by calling register().

Services are 'shared' by default - you will always receive the same instance when it is injected or queried by the container. See below for more lifetime options.

/* Service with zero dependencies  */
class Foo { 
    getValue() {
        return 42;
    }
}

/* Register the service. [] means that it has 0 dependencies. */
container.register(Foo, []);

Shared and transient services

You can pass the object lifetime to the register() method as the third argument. By default, it will register a shared service.

Some shortcuts for setting the lifetime are singleton() and transient():

container.singleton(...);   // Shared service: each instance is the same.
container.transient(...);   // Transient service: each instance is unique. 

Defining service dependencies

In this example, MyClass is a service that depends upon an instance of Foo:

import { Foo } from './Foo';

class MyClass {

    /* MyClass depends on an instance of `Foo` */
    constructor(public foo: Foo) {}

    myMethod() {
        console.log(this.foo.getValue()); // Will log '42'
    }
}

/** 
 * Register the services. 
 * 1) Register 'Foo'
 * 2) Wire `Foo` to be injected into MyClass
 */
container.register(Foo, []); 
container.register(MyClass, [Foo]); 

Type hinting for wiring

Typescript will yell at you when you pass the wrong dependencies. The container creatively uses Typescript's ConstructorParameters utility type to provide type hinting.

container.register(MyClass, [Foo]); // Everything is OK. 

container.register(MyClass, [Baz, 123]); // Error! MyClass requires Foo.

Passing primitives (non-injectables)

You may also pass things like objects or primitives (which aren't or cannot be registered services). These must be constructed when registering the class:

class SomeConfig {
    myvar = true
}

class SomeClass {

    /* SomeClass depends on primitives */
    constructor(
        public config: SomeConfig,
        public mynumber: number
    ) {}
}

/* Register the service and pass the dependencies as values. */
container.register(SomeClass, [new SomeConfig(), 1234]); 

Getting service instances

You can request a service instance via get(). Only when this is called, the dependencies will be resolved and injected (lazy initialization).

Before using this, you must first compile the container by calling build(). This will initialize the container (i.e. apply service overrides and configure bundles):

container.build(); 

Then you can get() an instance by passing the name of a class:

const instance = container.get(MyClass); 

console.log(instance.foo.getValue()); // Returns '42' (see above)

Overriding / mocking services

You can override the implementation of a service by using override().

Note: Javascript does not support interfaces (i.e. you cannot pass a TS interface by value). Therefore, you should first register() a 'base class' as a default implementation, after which you can override it using override().

/* Since you cannot pass TS interfaces in the JS world, IFoo must be a `class`. */
class IFoo {
    someMethod(): void {}
}

/* First register the default implementation / 'base class' for IFoo. */
container.transient(IFoo, []); 

container.singleton(MyService, [IFoo]); // MyService depends on IFoo

container.transient(ConcreteFoo, []);   // Register an override service
container.override(IFoo, ConcreteFoo);  // ConcreteFoo will be passed to MyService

Container extension bundles

You can add your own extension bundles to the container. You may use this system to add 'feature toggles' in your application. This is loosely based on the way Symfony handles bundles.

Define an extension bundle

import { container } from "@markeasting/dependency-injection";

import type { BundleInterface } from '@markeasting/dependency-injection'

/* Define the bundle configuration class */
export class MyBundleConfig {
    debug: boolean;
    myService: Partial<MyServiceConfig>;
}

/* Create the bundle definition */
export class MyBundle implements BundleInterface<MyBundleConfig> {

    constructor(
        public api: ApiManager,
        public service: MyService,
    ) {}

    /* The configure() method wires the services in this bundle */
    configure(overrides: Partial<MyBundleConfig>): void {

        /* Apply configuration overrides */
        const config = {...new MyBundleConfig(), ...overrides}; 

        /* Get some global parameters (could also be passed via config, depends on the parameter scope) */
        const apiKey = container.getParameter('apiKey');

        /* Wire the services in this bundle */
        container.transient(ApiManager, [apiKey]);
        container.singleton(MyService, [ApiManager, config.myService]);

        /* Then register the bundle itself */
        container.register(MyBundle, [Timer, MyService]);
    }
}

Register an extension bundle

You may use the globally available container instance, since this has extensions enabled by default.

import { container } from "@markeasting/dependency-injection";

Or create one explicitly:

import { ExtendableContainer } from "@markeasting/dependency-injection";

const container = new ExtendableContainer();

Then load / enable your extension bundle. Optionally, you can pass configuration.

container.addExtension(MyBundle, {
    // TS will type-hint this config as `MyBundleConfig`
    debug: true 
});

Get an extension

import { MyBundle } from "."

/* You must call `build` first. */
container.build(); 

const ext = container.getExtension(MyBundle); 

if (ext) {
    const instance1 = ext.api;      /* instanceof 'ApiManager' */
    const instance2 = ext.service;  /* instanceof 'MyService' */
}

Tip: assist tree-shaking

In the example above, MyBundle is always imported. So even if the extension is never required / used in your code, it's still imported, inflating code size.

To assist dead code removal / tree-shaking, you may use import type and pass the (stringified) name of the class to getExtension(). The type argument will ensure correct type hinting.

This way, you can cleanly selectively include or exclude (optional) bundles in your codebase.

/* Note the 'import type' here. These will be stripped from your build. */
import type { MyBundle } from "." 

const ext = container.getExtension<MyBundle>('MyBundle');

// if (ext) { ... }

Putting it all together

You can check out the basic application example here.

/* Logger.ts */

enum LogLevel {
    WARNING = 'WARNING',
    DEBUG = 'DEBUG'
}

class LoggerService {

    constructor(public logLevel: LogLevel) {}

    log(string: string) {
        console.log(`${this.logLevel} - ${string}`);
    }
}

/* Database.ts */

class Database {

    constructor(
        public dbUri: string,
        public logger: LoggerService
    ) {}

    connect() {
        this.logger.log('Success!');
    }
}

/* DbBundle.ts */
import type { BundleInterface } from '../src';

class MyBundleConfig {
    dbUri: string;
}

class DbBundle implements BundleInterface<MyBundleConfig> {

    constructor(public database: Database) {}

    configure(overrides: Partial<MyBundleConfig>): void {

        const config = {...new MyBundleConfig(), ...overrides};

        // Get some container parameter (could also be passed via config)
        const logLevel = container.getParameter('logger.loglevel');
        
        // Wire the services in this bundle 
        container.transient(LoggerService, [logLevel]);
        container.singleton(Database, [config.dbUri, LoggerService]);

        // Register 'self'
        container.register(DbBundle, [Database]);
    }
}

/* BaseApp.ts */
class BaseApp {

    database?: Database;

    constructor(
        /**
         * Empty constructor - use the container as a service locator here.
         * 
         * This allows easier sub-classing / extending of the App class 
         * e.g. only a super() call, without dependencies.
         */
    ) {
        /** 
         * Example of a feature toggle: 
         * we can only use the Database feature from MyBundle if added it. 
         */
        const bundle = container.getExtension<DbBundle>('DbBundle');

        if (bundle) {
            this.database = bundle.database; 
            // Or alternatively, `this.database = container.get(Database)`
        }
    }

    init() {
        this.database?.connect();
    }
}

/** 
 * Your application entrypoint - main.ts / some bootstrap function.
 */
function application_bootstrap() {
    
    container.setParameter('logger.loglevel', LogLevel.DEBUG);

    container.addExtension(DbBundle, {
        dbUri: 'mongodb://...',
    });

    container.singleton(BaseApp, []);
    container.build();

    /* Thats it! */
    
    const app = container.get(BaseApp); // The app instance is now ready. 

    app.init(); // Database will run connect() and log 'Success' 

}