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, anddefer()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 everyget()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:
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:
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:
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'); // UserRepositoryIt has the same API as DIContainer, with these differences:
get(),call(),injecute(),bind()functions andcreateResolver()functions return promises. A missing key rejects instead of throwing.- Singletons behave like async singletons in a
DIContainer: created once under concurrentget()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():
// 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:
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 untouchedStartup 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:
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 withaddInstance()pass through as they are). A middleware that measures or wraps values should await it:tsapp.use(async (key, next) => { const start = performance.now(); const value = await next(); console.debug(`${String(key)}: ${performance.now() - start}ms`); return value; });The
valueofgetandproduceevents may be a promise.preload()andbuildServicesGraph()accept an async container.createProxyAccessor(),createNamedResolvers(),createResolversTuple()andsetCacheInstance()are forDIContainer; usecreateResolver()and isolated forks instead.
Which one to use
| Situation | Use |
|---|---|
| Mostly sync services, one or two async ones (config, a pool) | DIContainer + defer(), or resolve at start |
| Much of the graph depends on async services | AsyncDIContainer |
Code that must call get() synchronously | DIContainer |