Decouple modules and assemble them back with ease
A declarative micro-framework for Dependency Injection in TypeScript and JavaScript.
npm:
$ npm install --save @integrated/core
yarn:
$ yarn add @integrated/core
- Integrated
- Usage
- API
Let's make an example express application which fetches some stuff from a database.
To do this in Integrated we'll first need a module:
// We'll make a simple express server that fetches some stuff
function ServerModule(config: {dbConnection: DBConnection, port: number}) {
const db = config.dbConnection;
const app = express();
app.get('/stuff', function (req, res) {
res.send(db.queryStuff());
});
const expressServer = app.listen(config.port, () => console.log(`Server is listening on port ${config.port}`));
return expressServer;
}
Modules in Integrated are just normal functions with an optional first argument for configuration. Here, we'll take the dbConnection and port, these are the dependencies of our ServerModule.
This definition of a module doesn't allow for the server to be stopped yet, don't worry we'll get to that later.
We need to tell Integrated about the modules that we have, we do that by creating a new context:
import { createContext } from '@integrated/core';
const serverContext = createContext({
db: PostgresDBConnectionModule,
server: ExpressModule,
});
Context is a collection of modules.
createContext
takes a context definition and returns a new context.
The definition is a plain JS object where values are your modules and keys just give your modules a name in this particular context.
As you see, we didn't tell Integrated how to configure the dependencies between different modules. Lets do that!
Side note: you can have as many contexts as you want, they do not have global state.
Remember in the ServerModule
definition that it had a config argument which took a dbConnection
and a port
? This is where we tell Integrated what to put in that config:
const server = serverContext.configure(wire => ({
server: {
config: {
// db is the module name we gave to PostgresDBConnectionModule when
// creating the context
dbConnection: wire.from('db'),
/**
* You can mix normal values and wire.from in any way you want.
* Integrated will automatically find all references to other modules.
*
* That also works for nested structures like objects, arrays and Maps
*/
port: 3000,
},
},
}));
wire.from
allows us to refer to other modules in a context by their name. Did I mention this is all type-safe? It is!
- Referring to a non-existant module will result in a type error.
- Referring to a module that doesn't match the type required in a config will also result in a type error.
You are not losing out on type safety when you use Integrated.
server();
// prints: Server is listening on port 3000
Now we've got a server running that fetches some stuff for us, very useful!
Let's see what Integrated did for you there:
- Analyzed the config to find any dependencies between your modules
- Figured out an order in which to start modules
- Started each module one by one, wiring in the dependencies that you specified.
In the resulting code the ServerModule
module never explicitly refers to PostgresDBConnectionModule
, which means that they're decoupled.
If tomorrow you decide that you want to use MongoDB, you will just implement a new module and change the context config, without touching any of the code in ServerModule, this is the power of Dependency Injection!
const serverInstance = server();
serverInstance.stop();
Now Integrated will go through each initialized module in reverse order and stop it.
But wait, how is it going to stop the express server?
Let's go back and revise our ServerModule definition a bit:
import { createModule } from '@integrated/core';
function ServerModule(config: {dbConnection: DBConnection, port: number}) {
const db = config.dbConnection;
const app = express();
app.get('/stuff', function (req, res) {
res.send(db.queryStuff());
});
const expressServer = app.listen(config.port, () => console.log(`Server is listening on port ${config.port}`));
// This is new
return createModule(expressServer)
.withDestructor(() => expressServer.close())
.build();
}
Updated server module with destructor
Now instead of returning the expressServer
as we did before, we wrap it in a
createModule
call, which allows us to specify a destructor.
Now Integrated can properly stop the server when the context stops.
When we called serverContext.configure
, we got back a function that initializes that context, so why not try this?
const appContext = createSystem({
// System inside a system, wat
server: server,
// Some other modules
jobRunner: jobRunner,
});
Modules in Integrated are just plain functions, that a configured context is also a module.
This makes your code even more composable! You can now compose arbitrarily complex modules into larger systems without writing much glue code.
Creates a context based on the definition.
Argument | Type | Description |
---|---|---|
definition | T extends {} | The definition object for creating the context. |
definition
is a plain JS object. Each key is an arbitrary string that can contain these values:
-
Functions:
Will be executed when context starts. Functions that have only one object argument will be configurable by Integrated. Return value will be used as the initialized value
-
Sockets:
Allow to
inject
your modules into them, reversing the dependency. -
Other values:
No special treatment, will be stored as is.
Returns
A Context
instance.
Example
const context = createContext({
stringConstant: 'simple string constant',
objConstant: {you: 'can put any values here'},
module: () => {
console.log('module init logic goes here');
// ...
return moduleInstance;
}
strings: createArraySocket<string>(), // we'll get to that later
});
Methods
Configures the context. This is where you can specify dependencies between modules in a system.
Argument | Type | Description |
---|---|---|
configClosure | (wire: WireFactory) => ContextConfig |
A function which does the configuration. |
- Arguments
-
configClosure(wire: WireFactory): ContextConfig
-
Arguments
wire
(WireFactory): An object that allows to wire dependencies
-
Returns:
{ [keyFromDefinition]: ModuleSettings }
. Where ModuleSettings is an object with keys:-
config
: if your module is configurable (a function module with one object argument) thenconfig
is required.This value must match the structure of your module's config, but instead of providing concrete values you can provide references to other modules, which will be substituted with values of those modules.
-
inject
: this object has an optionalself
key, and other injection keys provided by the function module. Values of this modules are references to sockets obtained fromwire.into
. SeecreateArraySocket
. -
disabled?: boolean
: should this module be disabled? Optional, false by default. If you depend on a disabled module, you will get an error when context starts. If you want to optionally depend on a module, then usewire.from('...').optional
, it will resolve to undefined if module is disabled.
-
-
- Returns A function which starts the context, and also has some additional properties for inspection.
Example
const context = createContext({
constant: 'constant',
moduleWithConfig: (config: {keyFromModuleConfig: string}) => 'module instance',
strings: createArraySocket<string>(),
});
const configuredContext = context.configure(wire => {
return {
moduleWithConfig: {
config: {
keyFromModuleConfig: wire.from('constant'),
// if module doesn't have additional injectable things, inject key is also optional
inject: { self: wire.into('strings') }
},
// providing a config for constants is optional
constant: {
inject: { self: wire.into('strings') }
},
}
};
});
console.log(configuredContext().instance)
// {
// constant: 'constant',
// moduleWithConfig: 'module instance',
// strings: ['constant', 'module instance']
// }
Allows you to specify dependencies between modules when configuring the context. When a module has a config, instead of passing values directly, you can pass the result of this function.
Argument | Type | Description |
---|---|---|
contextKey | string | A module name from the context definition |
Returns
An instance of InputWire
, which is a reference to a module in context.
Example
const context = createContext({
computedWelcome: () => {
// Imagine some dynamic string creation here
return 'Welcome to my server!';
},
server: (config: {port: number, welcomeMsg: string}) => {
// ... do setup using config
},
});
context.configure((wire /* here's our WireFactory */) => {
return {
server: {
// This has the same type as the first argument of server module,
// except that you can replace any normal values like number and string with
// InputWire<number> and InputWire<string>
config: {
// Pass directly
port: 3000,
// Reference from context
welcomeMsg: wire.from('computedWelcome'),
}
}
};
});
Allows you to inject a module into a Socket
.
Argument | Type | Description |
---|---|---|
contextKey | string | Socket name from context definition |
config | SocketConfig | Config for the socket, it's different for every socket, please look at their docs |
Returns
An instance of OutputWire
, which is a reference to a socket in context.
Example
const context = createContext({
module1: () => 'string1',
module2: () => 'string2',
module3: () => 'string3',
strings: createArraySocket<string>(),
consumer: (config: {strings: string[]}) => console.log(strings.join(', ')),
});
const configuredContext = context.configure(wire => {
return {
module1: { inject: { self: wire.into('strings') } },
module2: { inject: { self: wire.into('strings', {after: 'module1', before: 'module3'} /* config is specific to ArraySocket */) } },
module3: { inject: { self: wire.into('strings') } },
consumer: {
config: {
strings: wire.from('strings'),
},
},
};
});
configuredContext(); // prints "string1, string2, string3"
A reference to another module in context. You can create it only from WireFactory.from
.
InputWire is resolved to the actual instance of a module at context start time. Having an InputWire
in the config of a module
creates a dependency to that module, which changes order of initialization.
Lets you optionally depend on a module. If the module you depend on gets disabled
context will not crash on startup if you only optionally depend on it. in that case you will receive undefined
instead of the module instance.
Returns
For InputWire<T>
it will return InputWire<T | undefined>
Allows you to transform dependencies to make them fit without implementing additional modules in context that just do transformations.
Very useful when trying to bridge slightly incompatible modules together.
Argument | Type | Description |
---|---|---|
mapper | Function | Function that takes the type of value referred by InputWire and transorms it into a new value |
Returns
A new InputWire
, which takes the result of the base InputWire
and transorms it using the mapper
.
Example
const configuredContext = createContext({
constant: 'constant',
repeatPrint: (config: {value: string, repeatCount: number}) => {
console.log('Repeating!');
for (let i = 0; i < config.repeatCount; i++) {
console.log(config.value);
}
}
}).configure(wire => {
return {
repeatPrint: {
config: {
value: wire.from('constant'),
repeatCount: wire.from('constant').map(str => str.length) // this is now an InputWire<number>
}
}
};
});
configuredContext(); // prints 'constant' 8 times
Module can be return from a function in a context to specify a destructor or an inject for your module.
Argument | Type | Description |
---|---|---|
instance | any | Instance of your module. You would normally just return that from your function, but with createModule you can augment it. |
Returns
A ModuleBuilder
instance.
A helper class for creating a Module
Provide a custom destructor for your module instance.
Argument | Type | Description |
---|---|---|
destructorFn | function | A function which destroys your module |
Returns
A ModuleBuilder
with a destructor. Calling withDestructor
again will overwrite it.
withInjects<NewInjects extends {[key: string]: unkown}>(injectFn: () => NewInjects): ModuleBuilder<Instance, NewInjects>
Provide an additional injection for your module. This is useful for implementing plugins.
Argument | Type | Description |
---|---|---|
injectFn | function | A function which should return an object where keys give name to injects and values specify what's being injected. |
Returns
A ModuleBuilder
with new injects. Calling withInjects
again will overwrite them.
Example
// modules/AuthModule.ts
function AuthModule(config {...}) {
const authInstance = // do some setup here...
return createModule(authInstance)
.withInjects(() => {
return {
// Let's inject server middleware which adds auth logic here
middleware: (req, res, next) => {
if (authInstance.isAuthorised(req)) {
next();
} else {
res.send('NOT AUTHORISED');
}
}
};
})
.build();
}
// modules/ServerModule.ts
function ServerModule(config: {middleware: Array<(req, res, next) => void>}) {
const app = express();
config.middleware.forEach(middleware => app.use(middleware));
app.listen(3000);
return createModule(app)
.withDestructor(() => app.close())
.build();
}
// App.ts
const context = createContext({
server: ServerModule,
middleware: createArraySocket<(req, res, next) => void>(), // create an injection point for all modules which have middleware
auth: AuthModule,
}).configure(wire => {
return {
server: {
config: {
middleware: wire.from('middleware'), // resolve all injected middleware
},
},
auth: {
// this should match the object that was returned in the injector function passed to .withInjects
inject: {
middleware: wire.into('middleware'),
},
},
};
});
context(); // server initialized with auth middleware
Used when you need to provide a destructor or additional injects except for self
.
Instance value of a module.
Destroys the module. Implementation of the destructor is provided in ModuleBuilder.withDestructor
Creates a map of "injects", some values that you can inject into sockets in context. You probably will never use this directly, as it's used internally by Integrated
Creates an array socket, that can be put in a context. Array sockets let you inject values into them, essentially reversing the dependency and making a sort of "plug-in" way of assempling modules.
Array sockets take values of the type Value
(generic) via WireFactory.into
.
When you depend on array sockets via WireFactory.from
, it will resolve to an array of all injected values into it.
Sockets, and array sockets in particular, have a concept of config. You can specify that config per value when injecting it using WireFactory.into
.
For array sockets the config has type:
Property | Type | Description |
---|---|---|
before | string | Name of the module that should appear before the current one in that socket. |
after | string | Name of the module that should appear after the current one in that socket. |
// ... context init, etc
context.configure(wire => {
return {
otherModule: { inject: self: wire.into('socket') },
firstModule: { inject: self: wire.into('socket') }
someModule: { inject: { self: wire.into('socket', { before: 'otherModule', after: 'firstModule' } /* config */) } }
dependentModule: {
config: {
modules: wire.from('socket'),
}
}
};
});
// Dependent module receives: firstModule => someModule => otherModule
Very big sources of inspiration:
Copyright © 2020 Sergey Poznyak
Released under the MIT license.