Skip to content

Dependency Injection and Mediator for TypeScript

License

Notifications You must be signed in to change notification settings

sjohnsonaz/headwater

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

83 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Headwater

Node.js CI npm version

Dependency Injection and Mediator for TypeScript and JavaScript

Headwater is a simple and fast Inversion of Control and Mediator implementation. These implementations work together or separately.

Example

We can combine our Dependency Injection and Mediator patterns together!

Declare a Types enum.

enum Types {
    Mediator = 'Mediator',
    PostDataAccess = 'PostDataAccess',
}

Create a Mediator.

const mediator = new Mediator();

Create a Request.

interface Post {
    id: string;
    subject: string;
    body: string;
}

class GetPostRequest<Post> {
    constructor(public id: string) {
        super();
    }
}

Add a RequestHandler to the Mediator.

Note the use of inject() anywhere we want to use Dependency Injection.

Assuming we have a PostDataAccess class defined somewhere, we can inject it here!

mediator.add({
    type: GetPostRequest,
    handler: async (
        { id },
        postDataAccess = inject(Types.PostDataAccess)
    ) => {
        const post = await postDataAccess.get(id);
        return post;
    }
});

Bind the values to a Container.

const container = new Container({
    [Types.Mediator]: {
        value: mediator
    },
    [Types.PostDataAccess]: {
        value: PostDataAccess
    }
});

Container.setDefault(container);

type Bindings = typeof container['bindings'];

declare module 'headwater' {
    interface DefaultBindings extends Bindings {}
}

Inject the Mediator, send a Request, and Headwater will do the rest!

async function main(mediator = inject(Types.Mediator)) {
    const post = await mediator.send(new GetPostRequest(1234));
    return post;
}

main();

// returns a Post

Dependency Injection

For Inversion of Control, we need to bind values to a Container, so we can retrieve them later. We can bind three types of values:

  • Value
  • Constructor
  • Factory

Create or use Default Container

We need a Container for binding values. We can either create and manage this container directly, or use the default Container.

We first must import Container.

import { Container } from 'headwater';

Create Container

const container = new Container();

Use Default Container

const container = Container.getDefault();

We can also set the Default Container

const container = new Container();

Container.setDefault(container);

TypeScript Integration

The types for the Default Container can be injected as ambient typings.

Note: It is highly recommended declare ambient typings. This will allow simpler calls to inject() later.

type Bindings = typeof container['bindings'];

declare module 'headwater' {
    interface DefaultBindings extends Bindings {}
}

Binding Values

We can bind any value to a Container. We associate each binding with a unique Type. The Type can be any string, number, or symbol.

Note: It is highly recommended to use TypeScript string enum values:

enum Types {
    UserDataAccess = 'UserDataAccess',
    PostDataAccess = 'PostDataAccess'
}

It is also possible to use const string values:

const USER_DATA_ACCESS = 'UserDataAccess';
const POST_DATA_ACCESS = 'PostDataAccess';

Binding to the Container

Note: It is highly recommended to bind in the constructor. This provides typings automatically.

const container = new Container({
    [Types.UserDataAccess]: {
        value: new UserDataAccess()
    },
    [Types.PostDataAccess]: {
        value: new PostDataAccess()
    }
});

It is also possible to bind later via:

  • Container.prototype.bindValue()
  • Container.prototype.bindConstructor()
  • Container.prototype.bindFactory().

Bind a Value

We can bind a singleton value to a Type.

This can be done in the constructor via:

enum Types {
    Value = 'Value'
}

const container = new Container({
    [Types.Value]: {
        value: 'Some singleton value'
    }
});

It can also be done later via:

container.bindValue(Types.Value, 'Some singleton value');

Bind a Constructor

We can bind a constructor to a Type. This constructor will be called later to create instances.

Note: Constructor parameters should have default values. However, these can be specified upon injection.

class ExampleClass {
    constructor(public value = 0) {
    }
}

enum Types {
    ExampleClass: 'ExampleClass'
}

const container = new Container({
    [Types.ExampleClass]: {
        type: 'constructor',
        value: ExampleClass
    }
});

It can also be done later via:

container.bindConstructor(Types.ExampleClass, ExampleClass);

Bind a Factory

We can also bind a factory to a Type. This factory will be called later.

Note: Factory parameters should have default values. However, these can be specified upon injection.

function ExampleFactory(value = 0) {
    return {
        value
    };
}

enum Types {
    ExampleFactory = 'ExampleFactory';
}

const container = new Container({
    [Types.ExampleFactory]: {
        type: 'factory',
        value: ExampleFactory
    }
});

It can also be done later via:

container.bindFactory(Types.ExampleFactory, factory);

Type Property

The optional type property in the constructor can be specified via string or BindingType. Possible string values are:

  • "value"
  • "constructor"
  • "factory"

If unspecified, it is assumed to be a Value Binding.

const container = new Container({
    [Types.Value]: {
        type: BindingType.Value,
        value: 'Some singleton value'
    },
    [Types.ExampleClass]: {
        type: BindingType.Constructor,
        value: ExampleClass
    },
    [Types.ExampleFactory]: {
        type: BindingType.Factory,
        value: ExampleFactory
    }
});

Retrieving Values

We can get any bound Type with the function inject().

const value = inject(Types.Value);
const example = inject(Types.ExampleClass);
const factory = inject(Types.FactoryExample);

We can also get them directly from a Container.

const value = container.get(Types.Value);
const example = container.get(Types.ExampleClass);
const factory = container.get(Types.FactoryExample);

If a Constructor or Factory use parameters, we may specify them.

function ExampleFactory(value) {
    return value;
}

...

const factory = inject(Types.ExampleFactory, 1);

// result will be 1

Injecting Values

We inject into a function by default parameter values. For any function, we can specify default parameters. If undefined is passed into that parameter, the default value is used instead.

Note: It is highly recommended to inject via default parameter values.

For example:

function ExampleFactory(value = 0) {
    return value;
}

const result = ExampleFactory();

// result will equal 0

In this example, when we call factory with no parameters, value will be 0.

So, we can use a bound Container value for the default value.

function ExampleFactory(value = inject(Types.Value)) {
    return value;
}

const result = ExampleFactory();

// result will be the value bound to Types.Value.

In this example, when factory is called with no parameters, we will use whatever is bound to Types.Value.

Specifying Parameters

If the bound value is a constructor or factory, we can also pass parameters into the Container.get() method.

For exmaple:

function factory(value = container.get('constructor', 1, 2, 3)) {
    return value;
}

Specifying Containers

We can also use inject(), which uses the default Container.

import { inject } from 'headwater';

function factory(value = inject('value')) {
    return value;
}

We can also specify a Container for inject().

function factory(value = inject('value', container)) {
    return value;
}

Mediator

For the Mediator pattern, we bind Handlers to Request types.

Create a Mediator

import { Mediator } from 'headwater';

const mediator = new Mediator();

NOTE: For simplicity, the Mediator can be injected via IOC.

Add Handler

Defining Requests

We must create a new class that extends Request<T>. We specify into the generic <T> the return type of the Request.

import { Request } from 'headwater';

class CreateRequest extends Request<string> {
    data: Data;

    constructor(data: Data) {
        super();
        this.data = Data;
    }
}

NOTE: The super() must be called.

Binding Handlers

The Handler must return a Promise with the type specified in the Request.

mediator.addHandler(async (request: CreateRequest) => {
    // This function is async
    // The return type must match the CreateRequest
    return '';
});

Send

We must now create a new Request object, and pass it into the Mediator. It will match the Request with a Handler and return a Promise with the value.

const request = new CreateRequest({ ... });

const result = await mediator.send(request);