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
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
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
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 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';
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 1
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
.
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);