1.0.1 • Published 6 years ago

ashley v1.0.1

Weekly downloads
10
License
ISC
Repository
github
Last release
6 years ago

Ashley

Ashley is a dependency injection container for JavaScript. Learn more about dependency injection or more generally about inversion of control on Wikipedia.

Installation

npm install ashley

Note that it makes a heavy use of async functions and thus requires a fairly recent version of Node.js (7.x or newer). Depending on the version, you might need to pass the --harmony-async-await flag.

Usage

A new instance of Ashley can be created simply by calling its constructor. Ashley instances do not share any state and there can be any number of them within the same application. If fact, it is sometimes beneficial to have more than one them as they can form hierarchies. More on that later.

const Ashley = require('ashley');
const ashley = new Ashley();

Note that the code samples will use the container directly for obtaining the configured objects. To take advantage of the dependency injection pattern in a real application, the container should be only used explicitly during the application's initialization process to set up the dependencies. Read more in the Recommendations section.

Binding instances

The most basic thing Ashley can bind is an instance of a class. A class in this context is anything that needs to be instantiated with the new operator.

ashley.instance('Logger', ConsoleLogger);
const logger = await ashley.resolve('Logger')

// the same as
const logger = new ConsoleLogger();

The first argument of the instance method is a name. This name can be used when resolving instances or declaring dependencies. The second argument can either be the class itself or a path to a file which defines it. Finally, the third argument is a list of dependencies.

ashley.instance('Logger', ConsoleLogger);
ashley.instance('OrderService', OrderService, ['Logger']);
const orderService = await ashley.resolve('OrderService')

// the same as
ashley.instance('Logger', 'src/console_logger');
ashley.instance('OrderService', 'src/order_service', ['Logger']);
const orderService = await ashley.resolve('OrderService')

// the same as
const logger = new ConsoleLogger();
const orderService = new Orderservice(logger);

Note that when a relative path is provided, Ashley needs to know the root path from which the relative paths should be resolved.

const ashley = new Ashley({
  root: __dirname
});

There are objects within all applications which are meant to be used as singletons but making them actual singletons is problematic.

An alternative is to write regular classes but let Ashley worry about their life time (scope) once they are instantiated. Ashley provides two scopes out of the box - Singleton and Prototype. The Singleton scope is used by default and will make Ashley to always return the same instance each time it's requested. The Prototype scope, on the other hand, will make Ashley to always create new instances when requested.

ashley.instance('DbConnection', 'src/rethink_db_connection', [], {
  scope: 'Singleton' // default
});

ashley.instance('TimePoint', 'src/time_point', [], {
  scope: 'Prototype'
});

For an object such as DbConnection to be useful, it needs to actually establish an connection which will most likely be an asynchronous process. When binding the object, it's possible to specify that an initialization method needs to be called for the object to be fully ready. This is similar to a constructor but allows the method to be asynchronous.

ashley.instance('DbConnection', 'src/rethink_db_connection', [], {
  initialize: true
});

When set to true, Ashley will look for an async method called initialize and will wait for it to finish before proceeding. It's possible to specify a different initialize method by setting initialize to the name.

class RethinkDbConnection {
  async init() {
    this.connection = await r.connect();
  }
}

ashley.instance('DbConnection', RethinkDbConnection, [], {
  initialize: 'init'
});

The initialize method should either succeed or throw an error. It's important to make sure that time outs are set and handled as well otherwise Ashley might wait indefinitely.

The initialization method is the same for all instances of the object. Ashley also provides a way to set up a specific instance by defining an setup function when binding the instance.

ashley.instance('ErrorLogger', ConsoleLogger, [], {
  setup: function(logger) {
    logger.setBold(true);
    logger.setColor(ConsoleLogger.COLOR_RED);
  }
});

ashley.instance('Logger', ConsoleLogger);

The setup function will receive the instantiated object as its only parameter. Note that the function will be called only once if the scope is set to Singleton and may or may not be an async function.

There's also the option to deinitialize instances which works the same way except it's invoked when the container is being shut down. It generally depends on the scope used whether the method is supported or when it's called.

The provided Singleton and Prototype scopes will call the method only when the container is being shutdown. This means that the individual instances need to be kept in memory until that happens. Consider calling the deinitialize method manually on the objects in case they are short lived and keeping them in memory until the container shuts down is problematic.

class RethinkDbConnection {
  async initialize() {
    this.connection = await r.connect();
  }

  async deinitialize() {
    if (this.connection) {
      await this.connection.close();
    }
  }
}

ashley.instance('DbConnection', RethinkDbConnection, [], {
  initialize: true,
  deinitialize: true
});

// ...

await ashley.shutdown();

Note that Ashley does NOT catch errors from these methods. It's up to the developer to handle the failure scenarios themselves. It's especially important for the deinitialize method as throwing within the method will halt de-initialization of the remaining binds.

Binding plain objects

Not everything needs to be wrapped in a class and sometimes it's convenient to bind just plain objects.

ashley.object('Config', { port: 9001 });
ashley.object('Title', 'Zoo');

const title = await ashley.resolve('Title');

By default the bound objects are passed by reference and thus everyone will receive the very same object and can possibly modified it. An alternative is to specify the clone option which will create a deep copy of the object each time it's requested.

ashley.object('FreshConfig', { port: 9001 }, {
  clone: true // uses https://lodash.com/docs/4.15.0#cloneDeep
});

Since some objects require special care when deep copying, it's possible to specify the function that should be used for the purpose.

ashley.object('Config', { port: 9001 }, {
  clone: function(obj) {
    // ...
    return copy;
  }
});

Note that you cannot specify the target using a file path since a string is also a valid target in itself.

ashley.object('Config', 'src/config');
await ashley.resolve('Config'); // => 'src/config'

Binding functions

When integrating with 3rd party frameworks or libraries, it's sometimes necessary to register callbacks which will later be invoked with a given set of parameters. For example when using Koa.

const Koa = require('koa');
const app = new Koa();

const ConsoleLogger = require('src/console_logger');
const logger = new ConsoleLogger();

app.use(async function Index(ctx, next) {
  logger.info(`Serving ${ctx.request.ip}`);
  ctx.body = 'Hello world';
});

// or
app.use(require('src/index'));

The goal here is to invoke the callback with not only the parameters provided by Koa (ctx and next) but also with configured dependencies, in this case an instance of ConsoleLogger. It's possible to take advantage of the function method as follows.

ashley.instance('Logger', 'src/console_logger');
ashley.function('Index', 'src/index', [Ashley._, Ashley._, 'Logger']);

app.use(await ashley.resolve('Index'));

Defining a function and passing it immediately afterwards is a very common pattern and can be shortened to just a single line. It takes advantage of the fact that binding a target returns an async function which resolves to the target.

app.use(await ashley.function('Index', 'src/index', [Ashley._, Ashley._, 'Logger']));

Notice the use of the Ashley._ placeholder. When present, it will be replaced with the parameter the callback was called with by the framework. In addition, Ashley will resolve the dependencies and pass all of it to the user defined function.

// src/index
module.exports = async function Index(ctx, next, logger) {
  logger.info(`Serving ${ctx.request.ip}`);
  ctx.body = 'Hello world';
}

It's possible to use the placeholder multiple times and in any order. If the callback is called with fewer parameters than expected, the remaining placeholders are passed in as undefined, followed by the declared dependencies. When the number of parameters is greater than expected, only those with a placeholder will be passed in. Note that the returned function will always be an async function.

The Koa framework is officially supported. See Integration with existing frameworks and libraries for more information.

Factories

Creating instances of classes or other objects is not always as straightforward as calling its constructor, especially when using 3rd party libraries. Factories give the option to fully control the process. Note that a factory always needs to return a new instance.

// src/console_logger
module.exports = function consoleLoggerFactory(config) {
  const logger = ...
  // ..
  return logger;
};

// Define the factory
ashley.factory('ConsoleLogger', 'src/console_logger_factory', ['Config']);

// Use the factory and specify other details such as the life time
ashley.link('Logger', 'ConsoleLogger', {
  scope: 'Singleton'
});

// Use the `Logger` as a dependency
ashley.instance('Service', 'src/service', ['Logger']);

Notice the use of the link method. It's used to tie the factory with a name and gives the option to specify details such as the life time. There can be any number of links for a particular factory.

Scopes

Scopes define the life time of the objects they manage. As already mentioned, Ashley provides two scopes out of the box - Singleton and Prototype. The Singleton scope will make sure to always return the same instance while Prototype will always create a new instance.

ashley.instance('Logger', 'src/console_logger', [], {
  scope: 'Singleton'
});

It's of course possible to create custom scopes. Internally, a scope is nothing more than a class that gets instantiated with a provider which knowns how to create a new instance of the managed object. Later, the scope is asked for an instance of the object and it's up the implementation to decide whether to create a new one (e.g. Prototype) or always return the same (e.g. Singleton).

Container hierarchies

As applications grow, it's often desirable to split them into independent modules each handling their own agenda and making them communicate via shared means.

To help in this scenario, Ashley containers can form hierarchies. Each module can have its container linked to the same parent container. When set up, each request for an unmet dependency will bubble up the hierarchy until found.

// core
const core = new Ashley();
ashley.instance('MessageBus', 'src/message_bus');

// module A
const moduleA = core.createChild();
moduleA.instance('ServiceA', 'src/service_a', ['MessageBus']);

// module B
const moduleB = core.createChild();
moduleB.instance('ServiceB', 'src/service_b', ['MessageBus']);

The way a parent container is passed to its children various from application to application and there's no universal way. Note however that it's possible to inject the current container as a dependency. In the following sample, ModuleInitializer will receive a reference to the ashley variable.

ashley.instance('ModuleInitializer', ['@containers/self']);

In rare cases, you can even inject the container's parent. Use with caution as it creates often unwanted dependency between the containers.

ashley.instance('ModuleA', ['@containers/parent']);

Hierarchies are also often useful for creating temporary containers. For example, a framework might want to provide a way of having Singleton instances but only for the duration of a web request. To do that, a child container is created for every request with a reference to the main application's container. This way, the main container can resolve dependencies which outlive the request but still have the possibility to have per request dependencies such as an object for holding the current user and others.

// Initialized with the application
const main = new Ashley();
main.instance('Logger', 'src/console_logger');

// ...
// Created for every request and bound to the request's context
const request = main.createChild();
request.function('Index', 'src/middlewares/index', [Ashley._, Ashley._, 'Logger']);

// ...

// Shutdown the container at the end
await request.shutdown();

Integration with existing frameworks and libraries

Integration with existing frameworks or libraries usually requires glue between its components and Ashley itself. The support for these integrations goes into separate packages prefixed with "ashley-".

The Koa web framework is officially supported and extracted into its own package ashley-koa. Head over there for more information and usage examples.

Recommendations

Objects should declare all of the their dependencies

Individual objects in the system should declare all of their dependencies and have them injected. They should not depend on or modify any global state. This makes it easy to work with them in isolation (e.g. in unit tests).

Objects should not depend on the container

Individual objects should not depend on the presence of the container. They should work the same even if the container was completely removed and the dependencies set up manually.

Do not use or inject the container outside of an initialization phase

The container should be explicitly referenced only during an initialization phase to set up the dependencies. As soon as the application is initialized, it should not need the container directly anymore.

If the application is split into modules and it's necessary to pass the container down the chain to register it as a parent, it's possible to inject it as follows.

ashley.instance('ModuleInitializer', ['@containers/self']);

Create new instances directly or with factories

When it's necessary to create a new instance of a class and provide its dependencies, the dependencies should be available / injected in the current scope already. Another approach is to let the container inject a user-defined factory which can create new instances on its own.

It's also possible to use the internally factory for the object and let the container inject it. This is very similar to the factory pattern mentioned before but makes the container do the work.

ashley.instance('Item', 'src/item', ['Dependency1', 'Dependency2']);
ashley.instance('Service', 'src/service', ['@factories/Item']);

// src/item
class Item {
  constructor(dependency1, dependency2) {
    // ...
  }
}

// src/service
class Service {
  constructor(itemFactory) {
    this.itemFactory = itemFactory;
  }

  async action() {
    const item1 = await this.itemFactory.create();
    const item2 = await this.itemFactory.create();
    // ...
  }
}

Use constants as names

When defining a large number of bindings, it's easy to loose track of where a particular dependency is used. One way to alleviate the problem is to use constants instead of string names. Most editors/IDEs will highlight usages of these and it will also allow for precise name refactorings.

const c = {
  Service: 'Service',
  Logger: 'Logger'
};

ashley.instance(c.Logger, 'src/console_logger');
ashley.instance(c.Service, 'src/service', [c.Logger]);

Do not inject null / undefined

Some dependencies are not required in all environments or installations. The preferred way of dealing with such scenario is to bind a dummy implementation which offers the same interface but does not actually do anything. This way the code doesn't have to account for the possibility of the dependency being absent and clutter the code with conditions.

FAQ

Is it possible to set up dependencies using annotations?

No, there's no built in support for doing that. Additionally to the fact that the library would then need to use a transpiler (since annotations are not yet officially supported in the language), there are a few more reasons:

  • Annotations would create a hard dependency on the container itself. Ideally the dependencies should not know about that fact that they're being used with a container.

  • It would be difficult to annotate an object coming from a 3rd party library since these cannot / should not be modified.

  • There would be no single place to see the whole dependency graph in one place. Instead, the graph would be spread thorough the code base making it difficult to make sense of it.

Should I use Ashley for projects of any size?

No, use Ashley or other dependency injection containers only when there's benefit. Don't use a container just for the sake of using a container. For tiny applications, it's often overkill and setting up the dependencies manually is preferable. Note that using dependency injection alone is beneficial for projects of any size.

License

ICS

1.0.1

6 years ago

1.0.0

7 years ago

0.0.2

7 years ago

0.0.1

7 years ago