Minimal Dependency Injection container, written in Typescript.
- No additional npm package requirements.
- No autowiring, you need to build and configure your own application.
- Typescript has your back when you wire dependencies.
- Supports basic overriding / mocking of services.
- Optional extension bundle system: easily create 'feature toggles' for your application.
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.
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
npm run build
- run the Typescript compiler (tsc
).
npm run watch
- runs tsc
in watch mode.
npm test
- uses Bun as test runner.
import { Container } from "@markeasting/dependency-injection";
const container = new Container();
Or import a globally available container instance directly:
import { container } from "@markeasting/dependency-injection";
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, []);
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.
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]);
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.
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]);
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)
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
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.
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]);
}
}
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
});
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' */
}
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) { ... }
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'
}