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

Containers, forks and modules ​

Forks ​

fork() creates a child container. The child sees every service of its parent, including ones the parent registers later. What you add to the child stays in the child.

ts
const app = new DIContainer()
  .addInstance('dbUrl', 'postgres://prod')
  .addSingleton('db', Database, ['dbUrl'])
  .addSingleton('users', UserRepository, ['db']);

// A fork sees every service of its parent; what it adds stays in the fork.
const requestScope = app.fork().addInstance('requestId', 'req-1');
requestScope.get('users') === app.get('users'); // true: parent singletons are shared

Open in the playground

A service registered in the parent runs in the parent and is shared by all forks: the database pool is created once, and what a fork adds stays in the fork. Parent services can't depend on what a fork adds.

Scopes ​

A fork created for one unit of work, such as a user session, a request, a transaction or a job, is a scope: it registers the services that belong to that unit of work, uses the root's services, and is disposed when the work is done. The direction is fixed: a scope's services may depend on the root's, the root's never on a scope's.

  • The compiler checks it: a root service can't name a key that only a scope registers.
  • A scope that registers a key the root already has needs replace: true, and the error says which root services keep the root's instance (see below).
  • Each feature with scoped services gives them their own module, applied to the scope in one place: see Scoped state, which also compares scopes with patterns that keep every service in the root.

Overriding a parent's service in a fork ​

To register a key its parent has, a fork needs { replace: true }, like a replacement in the same container. The override reaches only the services resolved in the fork: a service registered in the parent runs in the parent and keeps the parent's instance. Without replace: true the fork throws INJECUTE_ALREADY_REGISTERED, and the message names the parent services that would keep the parent's one:

ts
// `users` (registered in `app`) depends on `db`: an override in a plain fork doesn't reach it.
const overridden = app
  .fork()
  .addSingleton('db', Database, { dependencies: ['dbUrl'], replace: true });
overridden.get('users') === app.get('users'); // true: `users` keeps the parent's `db`
// without { replace: true }: INJECUTE_ALREADY_REGISTERED,
// "users" (registered in a parent) depends on "db" and keeps the parent's "db"

To give a parent service the fork's instance, register that service in the fork too. To replace a dependency for every service, use an isolated fork.

Decorating a root service in a scope ​

A scope can decorate a root service for its own services, with a dependency on the key itself and replace: true. Here the scope's services log with the request id, and root services keep the root's logger:

ts
class PrefixLogger {
  constructor(readonly prefix = '') {}
  child(prefix: string) {
    return new PrefixLogger(`${this.prefix}${prefix} `);
  }
}

const logged = new DIContainer()
  .addSingleton('logger', PrefixLogger)
  .addSingleton('audit', (logger) => ({ logger }), ['logger']); // a root service

// A scope decorates the root's logger for its own services: they log with the request id.
const loggedScope = logged
  .fork()
  .addInstance('requestId', 'req-7')
  .addSingleton('logger', (logger, id) => logger.child(id), {
    dependencies: ['logger', 'requestId'],
    replace: true,
  })
  .addSingleton('handler', (logger) => ({ logger }), ['logger']); // a scope service

loggedScope.get('handler').logger.prefix; // "req-7 ": the scope's services get the decorated logger
loggedScope.get('audit').logger.prefix; // "": root services keep the root's logger

Isolated forks ​

fork({ isolated: true }) runs every service it resolves inside the fork, including services registered in its parents. Overrides then reach the whole graph, and nothing leaks back into the parent.

ts
// An isolated fork runs every service it resolves itself, so overrides reach the whole graph
// and nothing leaks back into the parent.
const testScope = app
  .fork({ isolated: true })
  .addInstance('dbUrl', 'postgres://test', { replace: true });

testScope.get('users').db.url; // "postgres://test"
app.get('users').db.url; // "postgres://prod"

Use isolated forks for tests and for per-tenant variants of an app container. Namespaces are re-created inside the fork, and forks of an isolated fork share its instances.

Modules ​

A module is a plain function that registers services. It receives a ServiceRegistry: it can register, but not add middlewares, fork or dispose. Declare only the services the module needs; extend() checks that the container has them.

ts
// A module declares only the services it needs.
const addBilling = (c: ServiceRegistry<{ db: Database }>) =>
  c.addSingleton('invoices', (db) => ({ db, list: () => [] as string[] }), [
    'db',
  ]);

const withBilling = app.extend(addBilling); // type error if `db` were missing
withBilling.get('invoices');

A module only registers. Work that must run when the app starts (adding routes to another module's router, subscribing to events, starting a consumer) goes in a lifecycle hook, not in the module function. To let other modules contribute to one of yours (routes, commands, health checks), give it an extension point.

Keep each module in a wiring file of its own, next to the feature it wires. See Keep the wiring in files of its own.

If a required service is missing, the error names it:

text
Argument of type '(c: ServiceRegistry<{ db: Database; }>) => …' is not assignable to parameter of type
'{ 'injecute: extension requires services that are not registered': "db"; }'.

Namespaces ​

namespace(name, callback) groups the services a callback registers under a prefix. The callback receives a fork of the container, so it can use every service registered there.

ts
const withNamespace = app.namespace('Reports', (reports) =>
  reports
    .addSingleton('daily', (users) => ({ users, kind: 'daily' }), ['users'])
    .addInstance('timezone', 'UTC'),
);

withNamespace.get('Reports.daily'); // { users, kind: 'daily' }
withNamespace.get('Reports').get('timezone'); // "UTC"

get('Reports') returns a read-only provider of the namespace. In the container's service map (ContainerServices<typeof app>), Reports is a Namespace marker next to the Reports.* keys; NamespaceServices<typeof app, 'Reports'> gives the namespace's services.

Private services ​

A module often has services that only its own services use: a cache, a client, a retry policy. Register them under a symbol key, and apply the module as a namespace. Don't export the symbol:

ts
const cache = Symbol('cache'); // not exported: only this module has the key

const addCatalog = (c: ServiceRegistry<{ db: Database }>) =>
  c
    .addSingleton(cache, () => new Map<string, string>())
    .addSingleton('products', (db, prices) => ({ db, prices }), ['db', cache]);

const shop = new DIContainer()
  .addInstance('dbUrl', 'postgres://prod')
  .addSingleton('db', Database, ['dbUrl'])
  .namespace('Catalog', addCatalog);

shop.get('Catalog.products'); // resolves, with the private cache
shop.keys; // ['dbUrl', 'db', 'Catalog', 'Catalog.products']: no cache
// shop.get(cache); // type error, and INJECUTE_NOT_REGISTERED at runtime
  • What makes them private. A namespace adds its string and number keys to the container as Name.key, but not its symbol keys. Inside the namespace, services depend on the symbol like on any key. Outside it, get() with the symbol is a compile error and fails with INJECUTE_NOT_REGISTERED, and keys doesn't list it.
  • Tests still reach them. Replace a dependency of the module in an isolated fork, and its private services use the replacement too. To replace a private service itself, the test needs the symbol: replace the public dependencies instead, or export the symbol from a file only tests import.
  • Only with namespace(). With extend(), a symbol key is registered in the container itself. Then only the unexported symbol keeps others out, and keys lists it.
  • Not a security boundary. get('Catalog') returns a provider of the namespace for inspection, and its keys include the symbols. Code that enumerates them can resolve them, as code can read symbol properties of an object with Object.getOwnPropertySymbols().

A naming convention such as _cache doesn't do this: keys are names (principle 8), so Catalog._cache is linked and resolved like any other key.

Adapting a module's dependencies ​

A module names the services it needs under its own keys. When the container has them under other keys, or in another shape, register the missing keys before extend(): addAlias() for the same service under another key, a factory for a value derived from another service. Do it in a namespace, so the adapters stay out of the container's root:

ts
interface Logger {
  info(message: string): void;
}

// The module declares the services it needs under its own keys.
const addAudit = (c: ServiceRegistry<{ dbUrl: string; log: Logger }>) =>
  c.addSingleton('audit', (dbUrl, log) => ({ dbUrl, log }), ['dbUrl', 'log']);

// The host has them under other keys, or in another shape.
const host = new DIContainer()
  .addInstance('config', { auditDb: 'postgres://audit' })
  .addInstance('logger', { info: () => {} } as Logger)
  .namespace('Audit', (audit) =>
    audit
      .addSingleton('dbUrl', (config) => config.auditDb, ['config']) // derive a value
      .addAlias('log', 'logger') // the same service under another key
      .extend(addAudit),
  );

host.get('Audit.audit').dbUrl; // "postgres://audit"
host.has('dbUrl'); // false: the adapters stay in the namespace
  • The adapters are services of the namespace like any other: host.get('Audit.dbUrl') resolves them, and fork({ isolated: true }) overrides of config reach them.
  • An adapter has a lifetime too. A singleton keeps the first value it derives; register it with addTransient() when the value must follow a service that changes.
  • extend() on the container itself works without a namespace, but the adapters then become keys of the root, and two modules that need dbUrl with different values can't both have it.

Sealing the composition root ​

seal() ends the registrations of a container: afterwards add*, namespace() and extend() throw INJECUTE_SEALED, and its type has no registration methods. Resolving, middlewares, events, reset() and dispose() keep working, and forks are open, so request scopes and tests fork the sealed root.

ts
// Register everything, then seal the composition root.
const root = new DIContainer()
  .addInstance('dbUrl', 'postgres://prod')
  .addSingleton('db', Database, ['dbUrl'])
  .addSingleton('users', UserRepository, ['db'])
  .seal();

root.get('users'); // resolves as before
// root.addInstance('flag', true); // type error, and INJECUTE_SEALED at runtime

// Per request or per test, fork it: forks are open for registrations.
const perRequest = root.fork().addInstance('requestId', 'req-2');

A sealed container's type is also cheaper for TypeScript: its service map is one object instead of an intersection of every registration, so forks and request scopes built on it typecheck faster and show readable types (see TypeScript).

Which one to use ​

You want to…Use
give services the trace id, tenant or user of the current requesta context accessor
register services for one session, request, transaction or joba scope: a fork with a scope module
choose between a scope and patterns that keep services in the rootScoped state
replace a dependency for everything, without touching the originalfork({ isolated: true })
split registrations by featuremodules with extend()
group services under a name, e.g. Billing.*namespace()
keep a module's helper services out of reach of other modulessymbol keys in a namespace
apply a module that names its dependencies differentlyadapters in a namespace

Released under the MIT License.