dapi
Simple library to create complex systems out of pure functions
Installation
npm install -D @carpasse/dapi
Usage
Creating an DapiWrapper instance
To create an DapiWrapper
you need to create an DapiDefinition
object and pass it to the createDapi
factory function.
import {createDapi} from '@carpasse/';
type Dependencies = {
db: OracleClient,
logger: Logger
}
type UserData = {
name: string,
email: string
}
const insertUser = async ({db, logger}: Dependencies, data: UserData) => {
logger.info(`Inserting user`, {data});
const entity = await db.insert('User', {
...data,
id: randomUUID()
});
return entity;
}
export const createDapiUser = (dependencies: Dependencies) => createDapi({
dependencies,
fns: {
create: insertUser,
},
type: 'Entity'
name: 'User'
}, EventEmitter);
export type DapiUser = ReturnType<typeof createUserApi>;
Using the DapiWrapper instance
import {createDapiUser} from './user';
import config from './config';
import {createDb} from './db';
import {createLogger} from './logger';
const user = createDapiUser({
db: await createDb(config.db),
logger: await createLogger(config.logger)
});
user.create({
name: 'John Doe',
email: 'johndoe@random.company.com'
});
const {create: createUser} = createDapiUser({
db: await createDb(config.db),
logger: await createLogger(config.logger)
});
createUser({
name: 'John Doe',
email: 'johndoe@random.company.com'
});
Why dapi
?
For a system to be maintainable, it needs to be testable. If your system is hard to test, it is hard to maintain and evolve. The problem is that code is hard to test because it is usually designed to be tested. It is designed to be executed.
We are getting a bit philosophical here, but my above statements goes against the obvious fact that we write code to be executed and writing code with execution in mind should be the priority.
So, what is the simplest execution logic you can think of? A function. A function is a piece of code that takes some input and returns some output. It is the simplest form of execution logic. And for a function to be easily testable, it needs to be pure.
A pure function is a function that has no side effects and always returns the same output for the same input.
The problem with pure functions is that they usually have a set of dependencies they rely on to do their job. For example, they may need to access a database, or they may need to send an email. This is where the dependency injection comes in handy.
Dependency injection is a technique whereby you split the responsibility of creating the dependencies from the responsibility of using them. By doing so, you can easily change them as your understanding of the system evolves. It also make the code easier to test because you can easily mock the dependencies.
Proposal
Lets create systems that are composed of pure functions that accept as their first argument all the dependencies they needs to do their job. This way, we can easily mock the dependencies when we test them.
From now on, we will call these functions DapiFns.
Example
import {randomUUID} from 'crypto';
export type Dependencies = {
db: OracleClient,
logger: Logger
}
export type UserData = {
name: string,
email: string
}
export const createUser = async ({db, logger}: Dependencies, data: UserData) => {
logger.info(`Creating user`, {data});
const entity = await db.insert('User', {
...data,
id: randomUUID()
});
return entity;
}
The problem with this approach is that it is very verbose. You need to pass the dependencies to every function you call. createUser
function is going to be called by another function that will also needs other dependencies. This means that you need to pass the dependencies to the caller and the caller needs to pass the dependencies to the callee.
This is not ideal. The caller should not know about the dependencies of the callee. It should only know about the dependencies it needs to do its job. It also makes the code harder to test because you need to mock the dependencies of the caller and the callee.
import {
createUser,
type Dependencies as UserDependencies,
type UserData
} from './createUser';
export const createCustomer = async (
{db, logger, mqBroker}: {mqBroker: RabbitMQ} & UserDependencies,
data: UserData) => {
logger.info(`Creating customer`, {data});
const newUser = await createUser({db, logger, mqBroker}, 'customer', data);
mqBroker.publish('customer.created', {data: newUser});
}
Ideally you should pass a createUser
fn with its dependencies already set to the createCustomer
fn. This way, the createCustomer
fn does not need to know about the dependencies of the createPerson
fn. It only needs to know about the dependencies it needs to do its job.
import {createDapi, DapiFn} from '@carpasse/';
import type {DapiUser, UserData} from './user';
import type {Logger} from './logger';
import type {RabbitMQ} from './mqBroker';
export type CustomerDeps = {
user: DapiUser,
logger: Logger,
mqBroker: RabbitMQ
}
export const createCustomer= async ({user: {create}, logger}: CustomerDeps, data: UserData) => {
logger.info(`Creating customer`, {data});
const newUser = await create('customer', data);
mqBroker.publish('customer.created', {data: newUser});
};
export const createDapiCustomer = (dependencies: CustomerDeps) => createDapi({
dependencies,
fns: {
create: createCustomer
},
type: 'Entity'
name: 'Customer'
}, EventEmitter);
This is where the dapi package comes in handy. The dapi package is a library that allows you to loosely bind the dependencies to the functions by creating an DapiWrapper around a group of functions that share the same dependencies (DapiFns). This way, you can easily pass the DapiWrapper instance as a dependency and the caller does not need to know about the dependencies of the callee.
What does "loosely bind the dependencies" mean?
It means that the dependencies are not bound to the functions. The dapi wrapper will pass them to the function on execution. By doing so you are able to modify dependencies on runtime. Or decorate the dapi fns or add hooks to them.
Following on the previous example, we could do:
import {createLogger} from './logger';
import {createDapiUser} from './user';
import {createDapiCustomer} from './customer';
import {profile} from './profiler';
const start = async (config: Config) => {
const logger = createLogger(config.logger);
const {mqBroker, db} = createServiceClients(config);
const user = createDapiUser({db, logger});
const customer = createDapiCustomer({user, logger, mqBroker});
config.on('db.config.update', async (dbConfig) => {
const newDb = await createDb(dbConfig);
await db.close();
user.updateDependencies({db});
});
if(config.profile.customer) {
customer.decorate('create', (create, deps, ...args) => {
return profile(deps.logger, create(deps, ...args));
});
}
customer.addPreHook('create', (create, deps, ...args) => {
deps.logger.info(`Create customer start` data);
});
customer.addPostHook('create', (createCustomer, deps, ...args) => {
deps.logger.info(`Create customer end` data);
});
}
API
DapiDefinition
Represents an DapiWrapper
definition with dependencies and fns.
Generic types
DEPENDENCIES
- The type of dependencies required by the DAPI
functions dictionary.DAPI
- A dictionary of pure functions the DapiWrapper will be composed of. All functions must accepts as their first argument a DEPENDENCIES
type obj.
Syntax
interface interface DapiDefinition<DEPENDENCIES, DAPI extends DapiFns<DEPENDENCIES>> {
dependencies: DEPENDENCIES;
fns: DAPI;
type: string;
name?: string;
}
Properties
dependencies
- The dependencies required by the DapiFns
.fns
- The pure functions the DAPI wrapper will be composed of.type
- The type of the DapiWrapper
.name
- The name of the DapiWrapper
.
DapiMixin
Mixin fn that creates a DapiWrapper
class that extends the passed SuperClass class and wraps the passed DapiFns dictionary.
Syntax
DapiMixin<T extends DapiDefinition>
(definition: T, SuperClass?: SuperClass): DapiWrapper<T>
Parameters
definition
- An DapiDefinition
object- [
SuperClass
] - An optional SuperClass class the returned DapiWrapper
class will extend from.
Returns
createDapi
Factory function that creates an DapiWrapper instance of the passed the DapiFns
. it accepts a DapiDefinition
object and an optional SuperClass class.
Syntax
createDapi<T extends DapiDefinition>
(definition: T, SuperClass?: SuperClass): DapiWrapper<T>
Parameters
definition
- An DapiDefinition
object- [
SuperClass
] - An optional SuperClass class the returned DapiWrapper
class will extend from.
Returns
Throws
TypeError
- If the definition is not an objectTypeError
- If the definition has no dependencies
propertyTypeError
- If the definition has no fns
propertyTypeError
- If the definition has no type
property
DapiWrapper
Inner class created by createDapi
factory that wraps the DapiDefinition.fns
fns and injects them the passed DapiDefinition.dependencies
at runtime. It also provides methods to modify the dependencies and to hook and decorate the dapi fns.
Every instance of the DapiWrapper class creates a Facade of the DapiDefinition.fns
fns. This means that every instance of the DapiWrapper
class will have the same methods as the DapiDefinition.fns
functions dictionary. The only difference is that the instance methods will omit the first argument of the DapiDefinition.fns
. This is because the DapiWrapper class injects the dependencies at runtime.
The class also allows to decorate the Dapi functions with decorators and hooks.
Generic types
DEPENDENCIES
- The type of dependencies required by the DAPI
fns dictionary.DAPI
- A dictionary of pure functions the DapiWrapper
will be composed of. All functions must accepts as their first argument a DEPENDENCIES
type obj.T
- The constructor type of the class being enhanced.
Properties
type
Readonly - Same as the DapiDefinition.type
property.name
Readonly - Same as the DapiDefinition.name
property.
Methods
getDefinition
DapiDefinition
getter.
Syntax:
getDefinition(): DapiDefinition<DEPENDENCIES, DAPI>
getDependencies
Dependencies getter.
Syntax:
getDependencies(): DEPENDENCIES
setDependencies
Dependencies setter.
Syntax:
setDependencies(newDeps: DEPENDENCIES): void
Parameters:
newDeps
- The new dependencies to be set.
Throws:
TypeError
- If the new dependencies are not defined.
updateDependencies
Partially updates the dependencies of the `DapiWrapper`` instance.
Syntax:
updateDependencies(newDeps: Partial<DEPENDENCIES>): void
Parameters:
newDeps
- A partial of the new dependencies to be updated.
Throws:
TypeError
- If the new dependencies are not defined.
toJSON
Returns a JSON representation of the DapiWrapper
instance.
Syntax:
toJSON(...args: ExtractFirstParam<JSON['stringify'], [Parameters<JSON['stringify']>[0]]>)
Parameters:
replacer
- An array of strings and numbers that acts as an approved list for selecting the object properties that will be stringified.space
- Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
toString
Returns the DapiWrapper
definition stringified for reading.
Syntax:
toString(): string
addDecorator
Adds a decorator to a DapiFn
of the DapiWrapper
instance.
Syntax:
addDecorator<KEY extends keyof DAPI>
(key: KEY, decorator: DecoratorFn<DAPI[KEY], DapiWrapper>): () => void
Parameters:
key
- The key of the DapiFn
function to decorate. Must be a key of the DapiDefinition.fns
fns dictionary.decorator
- The decorator function to be added to the method. Each decorator must accept the following arguments
fn
- The function to be decorated.deps
- The dependencies of the DapiDefinition.fns
.args
- The arguments passed to the method.
Returns:
- Function to remove the decorator that accepts no arguments.
Throws:
TypeError
- If the key is not a property of the Dapi functions dictionary. i.e. Not a key of the DapiDefinition.fns
dictionary.
Example
import {createDapi} from '@carpasse/';
import {profile} from './profiler';
import {createPerson} from './person';
const instance = createDapi({
dependencies: {db, logger},
fns: {
createPerson
},
type: 'Entity'
name: 'Person'
});
instance.addDecorator('createPerson', (createPerson, deps, ...args) => {
return profile(deps.logger, createPerson(deps, ...args));
});
Please note that the decorator function must call the decorated DapiDefinition fn and return the result of its call.
removeDecorator
Removes a decorator from the DapiWrapper
instance.
Syntax:
removeDecorator<KEY extends keyof DAPI>
(key: KEY, decorator: DecoratorFn<DAPI[KEY], DApiWrapper>)
Parameters:
key
- The name of the decorated method. Must be a key of the DapiDefinition.fns
fns dictionary.decorator
- The decorator function to be removed from the method.
decorateAll
Decorates all the DapiDefinition.fns
with the passed decorator.
Syntax:
decorateAll(decorator: DecoratorFn<DAPI[keyof DAPI], DapiWrapper>): () => void
Parameters:
decorator
- The decorator function to be added to the method. Each decorator must accept the following arguments
fn
- The function to be decorated.deps
- The dependencies of the DapiDefinition.fns
.args
- The arguments passed to the method.
Returns:
- Function to remove the all the added that accepts no arguments.
addHook
Adds a hook to a DapiFn
of the DapiWrapper
instance. Hooks are functions that are called before (hookType
= 'pre'
) or after (hookType
= 'post'
) the DapiFn
is called.
Syntax:
addHook<KEY extends keyof API>(
hookType: 'pre' | 'post',
key: KEY,
hook: HookFn<API[KEY], ApiWrapper>
): () => void
Parameters:
hookType
- The type of hook to be added. Can be either pre
or post
.key
- The name of the method to be hooked. Must be a key of the DapiDefinition.fns
fns dictionary.hook
- The hook function to be added to the method. Each hook must accept the following arguments
fn
- The function to be hooked.deps
- The dependencies of the DapiDefinition.fns
.args
- The arguments passed to the method.
Returns
- Function to remove the hook that accepts no arguments.
Throws:
TypeError
- If the key is not a property of the Dapi functions dictionary. i.e. Not a key of the DapiDefinition.fns
dictionary.
Example
const instance = createDapi({
dependencies: {db, logger},
fns: {
createPerson
},
type: 'Entity'
name: 'Person'
});
instance.addHook('pre', 'createPerson', (createPerson, deps, ...args) => {
deps.logger.info(`Create person start` data);
});
instance.addHook('post', 'createPerson', (createPerson, deps, ...args) => {
deps.logger.info(`Create person end` data);
});
removeHook
Removes a decorator from the DapiWrapper
instance.
Syntax
removeHook<KEY extends keyof DAPI>(
hookType: 'pre' | 'post',
key: KEY,
hook: HookFn<DAPI[KEY], DapiWrapper>
): void
Parameters
hookType
- The type of hook to be removed. Can be either pre
or post
.key
- The name of the method to be unhooked. Must be a key of the DapiDefinition.fns
fns dictionary.hook
- The hook function to be removed from the method.
addPreHook
Adds a pre hook to DapiDefinition['api']
function calls. Pre hooks are functions that are called before the decorated function is called.
Syntax:
addPreHook<KEY extends keyof DAPI>(
key: KEY,
hook: HookFn<DAPI[KEY], DapiWrapper>
): () => void
Parameters:
key
- The name of the method to be hooked. Must be a key of the DapiDefinition.fns
fns dictionary.hook
- The hook function to be added to the method. Each hook must accept the following arguments
fn
- The function to be hooked.deps
- The dependencies of the DapiDefinition.fns
.args
- The arguments passed to the method.
Returns
- Function to remove the hook that accepts no arguments.
Throws:
TypeError
- If the key is not a property of the Dapi functions dictionary. i.e. Not a key of the DapiDefinition.fns
dictionary.
removePreHook
Removes a pre hook from a Facade method of the DapiWrapper
instance.
Syntax
removePreHook<KEY extends keyof DAPI>(
key: KEY,
hook: HookFn<DAPI[KEY], DapiWrapper>
): void
Parameters
key
- The name of the method to be unhooked. Must be a key of the DapiDefinition.fns
fns dictionary.hook
- The hook function to be removed from the method.
addPostHook
Adds a post hook to DapiDefinition['api']
function calls. Post hooks are functions that are called after the decorated function is called.
Syntax
addPostHook<KEY extends keyof API>(
key: KEY,
hook: HookFn<API[KEY], ApiWrapper>
): () => void
Parameters:
key
- The name of the method to be hooked. Must be a key of the DapiDefinition.fns
fns dictionary.hook
- The hook function to be added to the method. Each hook must accept the following arguments
fn
- The function to be hooked.deps
- The dependencies of the DapiDefinition.fns
.args
- The arguments passed to the method.
Returns
- Function to remove the hook that accepts no arguments.
Throws:
TypeError
- If the key is not a property of the Dapi functions dictionary. i.e. Not a key of the DapiDefinition.fns
dictionary.
removePostHook
Removes a post hook from the DapiWrapper
instance.
Syntax
removePostHook<KEY extends keyof DAPI>(
key: KEY,
hook: HookFn<DAPI[KEY], DapiWrapper>
): void
Parameters
key
- The name of the method to be unhooked. Must be a key of the DapiDefinition.fns
fns dictionary.hook
- The hook function to be removed from the method.
FACADE methods
Every instance of the DapiWrapper class creates a Facade of the DapiDefinition.fns
fns. This means that every instance of the class will have the same methods as the DapiDefinition.fns
functions dictionary. The only difference is that the instance methods will omit the first argument of the DapiDefinition.fns
. This is because the
DapiWrapper class injects the dependencies at runtime.
Example
const instance = createDapi({
dependencies: {db, logger},
fns: {
createPerson
},
type: 'Entity'
name: 'Person'
});
instance.createPerson({name: 'John Doe'});