Documents injecute 1.x · Migrating from 0.x
Skip to content

Async services ​

There are two ways to work with services that are created asynchronously (connections, loaded config):

  • A few async services in a DIContainer: factories may return promises, and defer() awaits them for the services that depend on them.
  • An AsyncDIContainer, when much of the graph is async: every dependency is awaited before a factory runs, and every get() returns a promise.

Async factories in a DIContainer ​

A factory can return a promise. A singleton caches that promise, so concurrent get() calls share one creation. If the promise rejects, the container drops it and the next get() tries again; the rejection is wrapped in INJECUTE_RESOLUTION_FAILED with the resolution path. dispose() releases the resolved value.

Services that depend on an async service receive the promise. defer() wraps a factory so its promised arguments are awaited first:

ts
const app = new DIContainer()
  .addSingleton('config', async (): Promise<Config> => ({
    dbUrl: 'postgres://localhost',
  }))
  // defer(): awaits the promised arguments, so the factory receives the resolved config
  .addSingleton(
    'db',
    defer((config: Config) => new Database(config.dbUrl)),
    ['config'],
  );

export async function connect(): Promise<Database> {
  return app.get('db'); // Promise<Database>
}

get() of a deferred service returns a promise; await it where you use it, or resolve the async services once at startup:

ts
const [db, cache] = await Promise.all([app.get('db'), app.get('cache')]);

AsyncDIContainer ​

An AsyncDIContainer awaits every dependency before it calls a factory, so factories and classes receive resolved values, without defer(). Its service types are the resolved types: a factory returning Promise<Config> registers a Config. Every resolution returns a promise:

ts
const app = new AsyncDIContainer()
  .addSingleton('config', loadConfig) // Promise<Config> → registers Config
  .addSingleton('pool', (config) => connect(config.dbUrl), ['config']) // receives a Config
  .addSingleton('users', UserRepository, ['pool']); // receives a Pool

const users = await app.get('users'); // UserRepository

It has the same API as DIContainer, with these differences:

  • get(), call(), injecute(), bind() functions and createResolver() functions return promises. A missing key rejects instead of throwing.
  • Singletons behave like async singletons in a DIContainer: created once under concurrent get() calls, retried after a failure, disposed once resolved.
  • Forks of an async container are async too, including fork({ isolated: true }).

Modules and consumers ​

A module for an async container takes an AsyncServiceRegistry, and code that only resolves services takes an AsyncServiceProvider. They mirror ServiceRegistry and ServiceProvider, with promises from get():

ts
// A module for an async container takes an AsyncServiceRegistry
const addReports = (c: AsyncServiceRegistry<{ users: UserRepository }>) =>
  c.addSingleton('reports', (users) => ({ weekly: () => users.findAll() }), [
    'users',
  ]);

// Code that only resolves services takes an AsyncServiceProvider
function createHandler(
  services: AsyncServiceProvider<{
    reports: { weekly: () => Promise<string> };
  }>,
) {
  return async () => (await services.get('reports')).weekly();
}

const withReports = app.extend(addReports);
const handler = createHandler(withReports);

Sync and async types don't mix: a module typed ServiceRegistry can't be applied to an async container, and an async container can't be passed where a ServiceProvider is expected, because their get() returns values, not promises.

Tests ​

Override services in an isolated fork, as with a DIContainer (Testing). A replacement can be a value or a promise:

ts
const test = app
  .fork({ isolated: true })
  .addInstance('config', { dbUrl: 'postgres://test' }, { replace: true });
const testUsers = await test.get('users'); // built with the test config; app is untouched

Startup and shutdown ​

preload() returns a promise that settles once every service is created; await it at startup. To run work at startup (migrations, consumers, listening) and stop it in reverse, use lifecycle hooks: startLifecycle() awaits each hook, including in an async container. dispose() waits for singletons that are still being created, then releases them:

ts
async function start() {
  await preload(withReports); // creates every service now, so wiring errors surface at boot
}

async function shutdown() {
  await test.dispose();
  await app.dispose(); // closes the pool, once it is resolved
}

Middlewares, events and utilities ​

  • A middleware's next() returns a promise for services a factory creates (instances added with addInstance() pass through as they are). A middleware that measures or wraps values should await it:

    ts
    app.use(async (key, next) => {
      const start = performance.now();
      const value = await next();
      console.debug(`${String(key)}: ${performance.now() - start}ms`);
      return value;
    });
  • The value of get and produce events may be a promise.

  • preload() and buildServicesGraph() accept an async container. createProxyAccessor(), createNamedResolvers(), createResolversTuple() and setCacheInstance() are for DIContainer; use createResolver() and isolated forks instead.

Which one to use ​

SituationUse
Mostly sync services, one or two async ones (config, a pool)DIContainer + defer(), or resolve at start
Much of the graph depends on async servicesAsyncDIContainer
Code that must call get() synchronouslyDIContainer

Released under the MIT License.