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.
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
PostDataAccessclass 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 PostFor 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
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';const container = new Container();const container = Container.getDefault();We can also set the Default Container
const container = new Container();
Container.setDefault(container);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 {}
}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 enumvalues:
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';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().
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');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);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);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
}
});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 1We 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 0In 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.
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;
}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;
}For the Mediator pattern, we bind Handlers to Request types.
import { Mediator } from 'headwater';
const mediator = new Mediator();NOTE: For simplicity, the Mediator can be injected via IOC.
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.
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 '';
});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);