--- url: https://masyaka.github.io/injecute/guide/getting-started.md description: >- Install injecute, register services with explicit dependency keys, and resolve them with full types. --- # Getting started injecute is a dependency injection container for TypeScript without decorators. You register each service with a **key**, a **factory** (a function or a class) and the **keys it depends on**. The container resolves everything in the right order, and its type grows with every registration. ## Install ::: code-group ```sh [npm] npm install injecute ``` ```sh [pnpm] pnpm add injecute ``` ```sh [bun] bun add injecute ``` ```sh [deno] deno add npm:injecute ``` ::: injecute is an ES module for Node.js ≥ 22.12, Deno, Bun and browsers, with no runtime dependencies. It also works [without a build step](./no-build.md). ## Your first container ```ts import { DIContainer } from 'injecute'; class Database { constructor(readonly url: string) {} query(sql: string): string[] { return [`${this.url}: ${sql}`]; } } class UserRepository { constructor(private readonly db: Database) {} findAll(): string[] { return this.db.query('select * from users'); } } const app = new DIContainer() .addInstance('dbUrl', 'postgres://localhost/app') .addSingleton('db', Database, ['dbUrl']) .addSingleton('users', UserRepository, ['db']); const users = app.get('users'); // typed as UserRepository users.findAll(); ``` [Open in the playground](../playground?example=getting-started) What happened: * `addInstance('dbUrl', …)` registers an existing value. * `addSingleton('db', Database, ['dbUrl'])` registers a class. The container calls `new Database(dbUrl)` the first time `db` is needed, then reuses that instance. * `app.get('users')` resolves `users` and everything it depends on. Its type is `UserRepository`, inferred from the registration. Factory parameters and results are inferred from the registrations: ```ts twoslash import { DIContainer } from 'injecute'; const app = new DIContainer() .addInstance('port', 8080) // port: number .addSingleton('address', (port) => `http://localhost:${port}`, ['port']); const address = app.get('address'); // string // ^? ``` Mistakes are compile errors. A typo in a dependency key is reported on the key: ```ts app.addSingleton('server', createServer, ['adress']); // Type '"adress"' is not assignable to type 'Dependency<…>'. Did you mean '"address"'? ``` ## Next steps * [Registering services](./registration.md): lifetimes, optional dependencies, aliases, decoration. * [Containers, forks and modules](./containers.md): forks, isolated forks for tests, modules. * [Request context](./request-context.md): the trace id, tenant and user of the current request in every service. * [Scoped state](./request-state.md): scopes for a session, a request or a transaction, and the patterns that keep every service in the root. * [Lifecycle and dispose](./lifecycle.md): releasing connections with `dispose()` and `await using`. * [Frameworks](../frameworks/index.md): Express, Fastify, Hono, Next.js and GraphQL; React, Vue and Solid; coming from NestJS. * [Roles](../concepts/roles.md): which part of your code gets which view of the container. --- --- url: https://masyaka.github.io/injecute/guide/registration.md description: >- Lifetimes (singleton, transient, instance), dependency keys, optional dependencies, aliases, decoration and classes. --- # Registering services Every registration has a **key**, a **factory** and the **keys it depends on**. Dependencies are passed to the factory in the same order. ## Lifetimes ```ts const lifetimes = new DIContainer() // created once, on first use, then cached .addSingleton('clock', Clock) // created again on every resolution .addTransient('requestId', () => crypto.randomUUID()) // an existing value .addInstance('config', { port: 8080 }); lifetimes.get('clock') === lifetimes.get('clock'); // true lifetimes.get('requestId') === lifetimes.get('requestId'); // false ``` [Open in the playground](../playground?example=registration) | Method | Created | Cached | Disposed by `dispose()` | | ---------------------------------- | ------------------- | --------------- | ------------------------------------- | | `addSingleton(key, factory, deps)` | on first use | yes | yes (see [Lifecycle](./lifecycle.md)) | | `addTransient(key, factory, deps)` | on every resolution | no | no | | `addInstance(key, value)` | by you | it is the value | only with `{ dispose: true }` | A factory is a function or a class. Classes are detected and called with `new`. ## Dependencies ```ts const withDependencies = new DIContainer() .addInstance('config', { port: 8080, debug: false }) // keys are passed to the factory in the same order .addSingleton('address', (config) => `http://localhost:${config.port}`, [ 'config', ]) // optional(key): the service when it is registered, undefined otherwise // a function dependency: its result, computed on each resolution .addSingleton( 'banner', (address, transport, startedAt) => `${address} via ${transport ?? 'console'} since ${startedAt}`, ['address', optional('transport'), () => new Date(0).toISOString()], ); ``` A dependency is one of: * a **key**: the factory receives that service; * **`optional(key)`**: the service when it is registered, `undefined` otherwise; * **`collect(tag)`**: every service registered under a tag, as an array (see [Extension points with tags](./tags.md)); * a **function**: called on every resolution, the factory receives its result. When you need options, pass an object instead of the array: ```ts app.addSingleton('mailer', createMailer, { dependencies: ['config'], replace: true, // replace an existing registration of 'mailer' in this container dispose: (mailer) => mailer.close(), // how dispose() releases it }); ``` ## Aliases ```ts const logging = new DIContainer() .addInstance('jsonLogger', { format: 'json' }) .addInstance('prettyLogger', { format: 'pretty' }) .addAlias( 'logger', process.env.NODE_ENV === 'production' ? 'jsonLogger' : 'prettyLogger', ); logging.get('logger'); // the pretty logger outside production ``` ## Decorating a service A dependency on the key being registered receives its **previous definition**: the one it replaces in the same container, or the parent's one in a [fork](./containers.md#forks). Both need `replace: true`. ```ts const decorated = new DIContainer() .addInstance('greeting', 'hello') // a dependency on the key being registered receives its previous definition .addSingleton('greeting', (greeting) => greeting.toUpperCase(), { replace: true, dependencies: ['greeting'], }); decorated.get('greeting'); // "HELLO" ``` With `replace: true` the service's type becomes the new one, also when it changes (a `string` replaced by a `number` is a `number` afterwards), in a fork too: ```ts const scope = app .fork({ isolated: true }) .addInstance('port', 0, { replace: true }); // port: number ``` When a module replaces a service of the container it is applied to, the old and the new type are intersected; that is fine while the type stays the same (the usual decorator). In a plain fork, a decoration (like any override) reaches only the services resolved in the fork: see [Overriding a parent's service in a fork](./containers.md#overriding-a-parent-s-service-in-a-fork). ## Classes that cannot be detected The container recognises native classes (including subclasses, proxied classes and built-ins like `Map`). Two kinds of classes look like plain functions and cannot be detected: * classes **compiled to ES5**: Babel for old browsers, TypeScript with `target: es5` (removed in TypeScript 7), or prebuilt ES5 libraries; * **bound** classes (`MyClass.bind(null)`). Register them with `construct()`, which wraps the class in a factory that calls `new`: ```ts // Classes compiled to ES5 (or bound classes) look like plain functions: // register them with construct() so they are called with `new`. function LegacyClient(this: { url: string }, url: string) { this.url = url; } LegacyClient.prototype.ping = function () { return 'pong'; }; const legacy = new DIContainer() .addInstance('url', 'https://api.example.com') .addSingleton( 'client', construct(LegacyClient as unknown as new (url: string) => { url: string }), ['url'], ); ``` What happens if you forget `construct()`: | Class | Without `construct()` | | ------------------------------------------------------ | ------------------------------------------------------------------------- | | Babel-compiled (`_classCallCheck`) | `INJECUTE_CLASS_NOT_CONSTRUCTED` | | TypeScript ES5 output that assigns `this.x` | `INJECUTE_CLASS_NOT_CONSTRUCTED` | | ES5 class with prototype methods that returns nothing | `INJECUTE_CLASS_NOT_CONSTRUCTED` | | Bound class | `INJECUTE_CLASS_NOT_CONSTRUCTED` (on V8, JavaScriptCore and SpiderMonkey) | | ES5 constructor with no methods that never uses `this` | resolves to `undefined` (cannot be detected) | ## Keys Keys are strings, numbers or symbols. Use symbols for services that should stay private to a module: in a namespace, they can't be resolved from outside it (see [Private services](./containers.md#private-services)). Registering the same key twice in one container throws [`INJECUTE_ALREADY_REGISTERED`](../errors/already-registered.md) unless you pass `replace: true`. The other way round, `replace: true` for a key that no container has throws [`INJECUTE_NOTHING_TO_REPLACE`](../errors/nothing-to-replace.md): a replacement without an original usually means a registration is missing, such as a module that wasn't applied. ### Reserved key forms Two forms of string keys have a meaning. Build them with the helpers instead of typing them: | Form | Meaning | Built by | | ---------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | `Ns.key` | the service `key` of the [namespace](./containers.md#namespaces) `Ns` | `namespace('Ns', …)` | | `name:tag` | a service under a [tag](./tags.md); `name:start` is a lifecycle hook | `tag('name')`, `lifecycle.start('name')` ([lifecycle](./startup-shutdown.md)) | Other keys can contain `.` and `:` (`'db:primary'`, `'config.db'`), but avoid ending a key with `:` and the name of a tag or stage you use: `collect()` would collect it, and `startLifecycle()` would treat it as a hook (and reject it if it isn't a singleton). The two forms combine: `Orders.list:route` is the `list:route` service of the namespace `Orders`. --- --- url: https://masyaka.github.io/injecute/guide/containers.md description: Forks, isolated forks for tests, modules with extend(), and namespaces. --- # 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](../playground?example=containers) 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](./request-state.md#scopes), 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`](../concepts/roles.md): 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](./startup-shutdown.md), not in the module function. To let other modules contribute to one of yours (routes, commands, health checks), give it an [extension point](./tags.md). 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](./app-structure.md#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`), `Reports` is a `Namespace` marker next to the `Reports.*` keys; `NamespaceServices` 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()) .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`](../errors/not-registered.md), and `keys` doesn't list it. * **Tests still reach them.** Replace a dependency of the module in an [isolated fork](#isolated-forks), 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](../concepts/principles.md#_8-keys-are-names)), 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](./typescript.md#seal-the-composition-root)). ## Which one to use | You want to… | Use | | ------------------------------------------------------------------ | ------------------------------------------------------------ | | give services the trace id, tenant or user of the current request | a [context accessor](./request-context.md) | | register services for one session, request, transaction or job | a [scope](#scopes): a fork with a scope module | | choose between a scope and patterns that keep services in the root | [Scoped state](./request-state.md) | | replace a dependency for everything, without touching the original | `fork({ isolated: true })` | | split registrations by feature | modules with `extend()` | | group services under a name, e.g. `Billing.*` | `namespace()` | | keep a module's helper services out of reach of other modules | [symbol keys in a namespace](#private-services) | | apply a module that names its dependencies differently | [adapters in a namespace](#adapting-a-module-s-dependencies) | --- --- url: https://masyaka.github.io/injecute/guide/app-structure.md description: >- Keep the web framework out of the application core, and keep the container wiring in files of its own. What each gives you, what it costs, and when to do otherwise. --- # Structuring an app Two recommendations for an app that grows beyond one file. Both are defaults, not rules: each section says when to do otherwise. ## Keep the web framework out of the core Build the container around the **application core**: configuration, connections, repositories, domain services. Keep the **web bindings** (routes, middleware, the server) in a layer of their own that receives the core and resolves what it needs from it: ```ts // core/container.ts: no web framework imports export const createCore = () => new DIContainer() .addInstance('config', loadConfig()) .extend(addDatabase) .namespace('Orders', addOrders) .seal(); export type Core = ReturnType; ``` ```ts // web/orders.ts: the web layer gets services through a provider export const ordersRouter = ( core: ServiceProvider<{ 'Orders.orders': OrderService }>, ) => { const orders = core.get('Orders.orders'); const router = Router(); router.get('/', async (_req, res) => { res.json(await orders.list()); }); return router; }; // web/server.ts export const createHttpApp = (core: Core) => { const http = express(); http.use(express.json()); http.use('/orders', ordersRouter(core)); return http; }; ``` The entry point connects them, and stops the server before the core releases its connections: ```ts // main.ts const core = createCore(); const running = await startLifecycle(core); const server = createHttpApp(core).listen(core.get('config').port); process.once('SIGTERM', () => server.close(() => running.stop().catch((error) => console.error(error))), ); ``` The dependency points one way: the web layer imports the core, and the core never imports the web layer. ### What you get * **One core for every entry point.** The HTTP server, a queue worker, a cron job and a CLI build the same core and add their own bindings. None of them carries another's framework. * **The framework is replaceable.** Upgrading Express or moving to Fastify or Hono changes the web layer only. The core, and its tests, don't change. * **Tests at the right level.** Core tests use an isolated fork and send no HTTP requests. Web tests build the web layer on a core fork with fakes. * **The framework works its own way.** Plugin encapsulation, route-level typing (Hono's RPC client, Fastify's type providers) and the framework's test helpers are used as their docs describe, without passing through the container. * **A readable container.** The services graph shows the domain, not routers and middleware. The container type stays smaller, so it is faster to typecheck. ### What it costs * **A seam to maintain.** The web layer resolves services at its edge, through a `ServiceProvider` typed with what it needs. That is service location, kept to one layer: below it, everything is injected. * **Routes are listed by hand.** A feature module can't contribute its routes on its own: the web layer mounts each feature's router. That list is explicit, but it is one more place to touch when you add a feature. * **You order the shutdown.** The server isn't a lifecycle hook, so the entry point stops it before `running.stop()`. ### When to put the framework in the container It works: the [framework pages](../frameworks/index.md) show it. Prefer it when: * features are plugins that bring their own routes, and adding one must not touch the web layer (an [extension point](./tags.md) for routes); * the server is one of several things the lifecycle starts and stops in order (consumers, schedulers), and you want it as a hook like the others; * the app is small, and one container is simpler than two layers. ## Keep the wiring in files of its own Put each module's registrations in a file that only wires, and the composition root in one more: ```text src/ orders/ orders-service.ts plain classes: no injecute imports orders-repository.ts orders.container.ts addOrders: registrations only billing/ billing.container.ts container.ts the composition root: createApp() main.ts the entry point: start, signals ``` ```ts // orders/orders.container.ts import type { ServiceRegistry } from 'injecute'; import { OrdersRepository } from './orders-repository.ts'; import { OrdersService } from './orders-service.ts'; export const addOrders = (c: ServiceRegistry<{ db: Database }>) => c .addSingleton('repo', OrdersRepository, ['db']) .addSingleton('orders', OrdersService, ['repo']); ``` ```ts // container.ts export const createApp = () => new DIContainer() .addInstance('config', loadConfig()) .extend(addDatabase) .namespace('Orders', addOrders) .namespace('Billing', addBilling) .seal(); ``` A feature with services that belong to a scope (a session, a request, a transaction) exports a second module for the scope; see [Scope modules](./request-state.md#scope-modules). What belongs in a wiring file: imports of classes and factories, registrations, and small adapters (`(config) => config.port`). What doesn't: business logic, classes, side effects on import, and built containers. Export a module function or `createApp()`, never a container created at the top of the file. ### What you get * **Plain classes.** Services don't import injecute or the container. They are tested with `new`, reused without the container, and unchanged if you replace it. * **The wiring in one place.** A module's dependencies are readable in one file, and a change to them is a change to that file in review. * **No import cycles.** Wiring files import services; services never import wiring. * **Safe to import.** A wiring file has no side effects, so tests, scripts and tools (`buildServicesGraph()`) load it freely, and each `createApp()` call is a new container. A test never shares the app's instances by accident. * **Easy to adopt and to remove.** Introducing injecute adds wiring files; removing it deletes them. ### What it costs * **More files.** A small feature has a service file and a wiring file of a few lines. * **The dependency list is away from the constructor.** You read two files to see a class's dependencies. The compiler checks that the list matches the constructor, so a mismatch is a compile error, not a bug. * **A naming convention to agree on**, such as `*.container.ts` or `module.ts` per feature. ### When one file is enough A script or a small service with a handful of registrations: keep them in one `container.ts`. Split when a feature's wiring grows, or when two features change it in the same week. --- --- url: https://masyaka.github.io/injecute/guide/tags.md description: >- Let modules contribute services to an extension point (routes, commands, health checks, plugins) with createTag(), and give the owner all of them as a typed array with collect(). --- # Extension points with tags Some services are built from contributions of many modules: a router from every module's routes, a CLI from every module's commands, a health endpoint from every module's checks. The owner shouldn't have to know its contributors, and contributors shouldn't have to know each other. A **tag** names such an extension point. Modules register services under it, and the owner depends on `collect(tag)`: every service under the tag, as a typed array. ## Defining a tag and collecting it ```ts interface Route { readonly method: 'GET' | 'POST'; readonly path: string; handle(): string; } /** The extension point: modules register routes under it, the router collects them. */ export const route = createTag('route').of(); class Router { constructor(private readonly routes: Route[]) {} handle(method: string, path: string): string { const match = this.routes.find( (r) => r.method === method && r.path === path, ); return match ? match.handle() : '404'; } list(): string[] { return this.routes.map((r) => `${r.method} ${r.path}`); } } // The owner depends on every route, whoever registers them. const addHttp = (c: ServiceRegistry) => c.addSingleton('router', Router, [collect(route)]); // Router receives Route[] ``` [Open in the playground](../playground?example=tags) `createTag('route')` creates the tag; `.of()` gives it the type of its services, so `collect(route)` resolves to `Route[]`. Export the tag from the module that owns the extension point. ## Contributing Calling the tag builds a key: `route('list')` is `'list:route'`. Register a contribution under it like any service, with its own dependencies: ```ts class OrderService { list(): string { return 'orders: 2'; } } // A module contributes under the tag: route('list') is the key 'list:route'. const addOrders = (c: ServiceRegistry) => c.addSingleton('service', OrderService).addSingleton( route('list'), (orders): Route => ({ method: 'GET', path: '/orders', handle: () => orders.list(), }), ['service'], ); const addHealth = (c: ServiceRegistry) => c.addSingleton(route('health'), (): Route => ({ method: 'GET', path: '/health', handle: () => 'ok', })); export const app = new DIContainer() .extend(addHttp) .namespace('Orders', addOrders) // its route is 'Orders.list:route' .extend(addHealth) .seal(); export const routes = app.get('router').list(); // ['GET /orders', 'GET /health'] export const response = app.get('router').handle('GET', '/orders'); ``` * **Namespaces are included.** In the namespace `Orders`, the key is `Orders.list:route`, and it is collected like the others. Each service is collected once, also by a collector inside a namespace (which sees both `list:route` and the root's `Orders.list:route`), and a replacement or decoration of `Orders.list:route` in the root is what gets collected. * **Registration order.** Contributions come in the order they were registered, the root's first. Contributions registered after the collector are included too: they are collected when the collector is created. * **Contributions are type-checked.** A key built by the tag carries the tag's type, so `addSingleton`, `addTransient` and `addInstance` check the service against it: a factory that doesn't produce a `Route` is a compile error at the factory, and its parameters stay typed. An alias under a tag (`addAlias(route('start'), route('home'))`) must point to a service of the tag's type. Keys typed by hand (`'list:route'`) aren't checked: build them with the tag. * **The key works for lookups too.** `app.get(route('list'))`, `[route('list')]` in a dependency list and `optional(route('list'))` resolve the contribution like its plain key `'list:route'`. * **Each contribution is a service.** A singleton contribution is created once and shared with anything else that resolves it; middlewares, the services graph and error paths (`router → Orders.list:route`) see it like any dependency. ## Tests Contributions are collected in the container that creates the collector. An isolated fork collects its own replacements and additions; the app is untouched: ```ts // An isolated fork collects its own replacements and additions. export const testRoutes = app .fork({ isolated: true }) .addSingleton( route('health'), (): Route => ({ method: 'GET', path: '/health', handle: () => 'fake' }), { replace: true }, ) .addSingleton(route('debug'), (): Route => ({ method: 'GET', path: '/debug', handle: () => 'debug', })) .get('router') .list(); // ['GET /orders', 'GET /health', 'GET /debug'] ``` ## Details * **Lifetime.** A singleton collector keeps the array it received. A contribution registered after it was created (for example in a fork that doesn't re-create it) is not added. An isolated fork re-creates it. * **Async.** In an `AsyncDIContainer`, a contribution may be async: the collector receives the resolved services. In a `DIContainer`, an async contribution would arrive as a promise, so the check rejects it; type the tag accordingly (`.of>()`) if you want promises. * **Cycles.** A contribution that depends on its collector is a circular dependency, reported with the path when the collector is resolved. * **Introspection.** `getRegistration('router')` lists the dependency as `{ type: 'collect', tag: 'route' }`, and `buildServicesGraph()` draws an edge to every contribution. * **Names.** Tag names are non-empty and contain no `:` or `.`. Keys that don't end with `:`, like `'user:repo'`, are never collected. ## Lifecycle stages are tags The [lifecycle](./startup-shutdown.md) stages are tags of lifecycle hooks: `lifecycle.start('server')` is the key `'server:start'`, and `collect(lifecycle.start)` would resolve every start hook. Don't collect stages yourself: `startLifecycle()` runs the hooks in order and undoes them. ## Tags or a push in `init`? Use a tag and `collect(tag)`: it is the default for extension points. Two exceptions: | The owner… | Use instead | | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | keeps a registry that changes at runtime, with an undo (an event bus, a scheduler) | an `init` [lifecycle hook](./startup-shutdown.md) that calls its API and returns the undo | | has two or three fixed contributors it knows | depend on their keys directly | --- --- url: https://masyaka.github.io/injecute/guide/lifecycle.md description: >- Release connections and other resources with dispose() and await using, in reverse creation order. --- # Lifecycle and dispose `await container.dispose()` releases every instance the container **owns**, dependents first, then marks the container as disposed. ```ts const app = new DIContainer() .addInstance('url', 'postgres://localhost/app') .addSingleton('pool', Pool, ['url']) // disposed with [Symbol.asyncDispose] .addSingleton('cache', Cache) // disposed with [Symbol.dispose] .addSingleton('mailer', () => ({ close: () => log.push('close mailer') }), { dependencies: [], dispose: (mailer) => mailer.close(), // custom disposer }); export async function shutdown(): Promise { app.get('pool'); app.get('cache'); app.get('mailer'); // dependents first: reverse creation order await app.dispose(); } ``` [Open in the playground](../playground?example=lifecycle) ## What the container owns * **Singletons it created.** Each is disposed with its registration's `dispose` function, or with `[Symbol.asyncDispose]` / `[Symbol.dispose]` when the instance has one. `dispose: false` opts out. When a factory returns a promise, the resolved value is disposed (a singleton still being created is awaited first); one whose promise rejected is skipped. * **`addInstance` values registered with `{ dispose: true }` (or a function).** Values you add are yours by default. * **Namespace containers**, with their services. Transients are never tracked: the caller owns them. A `dispose` option on a transient throws [`INJECUTE_INVALID_OPTION`](../errors/invalid-option.md). ## Order and failures Instances are disposed in **reverse creation order** across the container and its namespaces, so a service is released before the services it depends on. If some disposers fail, the others still run, and `dispose()` rejects with [`INJECUTE_DISPOSE_FAILED`](../errors/dispose-failed.md) (the failures are in `cause`, an `AggregateError`). Calling `dispose()` again returns the same promise. ## Scopes with `await using` Containers implement `[Symbol.asyncDispose]`, so a fork can be disposed at the end of a block: ```ts export async function handleRequest(requestId: string): Promise { await using scope = app.fork().addSingleton('trace', () => ({ id: requestId, [Symbol.dispose]: () => log.push(`end ${requestId}`), })); return scope.get('trace').id; // `trace` is disposed here; the app's singletons stay alive } ``` A parent does not dispose its forks: dispose each fork you create (`await using` does that for you). ## Stopping a running app To stop servers, consumers and schedulers before the connections they use are released, start the app with lifecycle hooks and stop it with `running.stop()`: it undoes the hooks in reverse, then calls `dispose()`. See [Startup and shutdown](./startup-shutdown.md). ## `reset()` vs `dispose()` `reset()` clears cached instances so they are created again on the next resolution. It does not dispose them; `dispose()` still releases every instance the container created, including ones `reset()` or `replace` dropped. --- --- url: https://masyaka.github.io/injecute/guide/startup-shutdown.md description: >- Lifecycle hooks with createLifecycle() and startLifecycle(): modules start work in stages at startup (consumers, servers, subscriptions) and stop gracefully in reverse. --- # Startup and shutdown Modules often need to run code when the app starts, without the composition root knowing each of them: start a queue consumer, listen for requests, subscribe to another module's events, mark the app as ready. And when it stops, undo all of that in reverse before the connections close. A **lifecycle hook** does that. It is a singleton registered under a **stage key**; `startLifecycle()` runs the hooks stage by stage and returns a handle that stops them. ## A service that starts and stops Most hooks start one service and stop it. Register the service with `startable()`: it registers the service and its hook in one call. This is the default. ```ts const addPayments = (c: ServiceRegistry<{ bus: EventBus }>) => c.extend( startable('consumer', PaymentConsumer, ['bus'], { start: (consumer) => consumer.start(), stop: (consumer) => consumer.stop(), }), ); ``` It registers `consumer` and the hook `consumer:start` (in a namespace, `Payments.consumer:start`), in a `DIContainer` or an `AsyncDIContainer`. `start` receives the service and the [start signal](#timeouts-and-cancellation), and may return a promise; pass `stage: lifecycle.init` to start in another stage. The services it needs come from the factory's parameter types (annotate them on a function factory), and `extend()` checks that the container has them. ## Writing a hook For anything else (subscribing to events, marking the app ready, running migrations), write the hook yourself. `lifecycle.start('server')` is the key `'server:start'`. Register the hook under it with `addSingleton`: the factory is the hook, its dependencies are what it needs, and it may return a function that **undoes** it (or a promise of it). A factory that returns anything else is a compile error at the factory. ```ts class EventBus { private readonly handlers = new Map void)[]>(); /** Subscribes; returns the function that unsubscribes. */ on(type: string, handler: (event: unknown) => void): () => void { this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]); return () => this.handlers.set( type, (this.handlers.get(type) ?? []).filter((h) => h !== handler), ); } emit(type: string, event: unknown): void { for (const handler of this.handlers.get(type) ?? []) handler(event); } } interface Route { readonly path: string; handle(): string; } /** The router's extension point: modules register routes under it (see Extension points with tags). */ export const route = createTag('route').of(); class Router { readonly routes: Map string>; constructor(routes: Route[]) { this.routes = new Map(routes.map((r) => [r.path, r.handle])); } } class HttpServer { constructor(private readonly router: Router) {} listen(port: number): void { log.push( `listening on ${port}: ${[...this.router.routes.keys()].join(', ')}`, ); } async close(): Promise { log.push('server closed'); } } class Readiness { ready = false; } // The platform owns the bus and the router; it starts the server when the app starts. const addPlatform = (c: ServiceRegistry<{ port: number }>) => c .addSingleton('bus', EventBus) .addSingleton('router', Router, [collect(route)]) // every module's routes .addSingleton('server', HttpServer, ['router']) .addSingleton('readiness', Readiness) .addSingleton( lifecycle.start('server'), // the key 'server:start' (server, port) => { server.listen(port); return () => server.close(); // undo: runs when the app stops }, ['server', 'port'], ) .addSingleton( lifecycle.ready('readiness'), (readiness) => { readiness.ready = true; return () => { readiness.ready = false; // the first thing to undo: stop getting traffic }; }, ['readiness'], ); ``` [Open in the playground](../playground?example=startup-shutdown) Hooks are ordinary registrations: their dependencies are typed, they show up in the services graph, and an error names the hook (`Failed to create "server:start": …`). In a namespace the key is prefixed like any other. Each stage is a [tag](./tags.md) of lifecycle hooks, and the hooks of a stage are the services under it. ## Stages `startLifecycle()` runs the stages in order. Each stage runs its hooks one by one, in registration order. | Stage | What runs | Undone | | ------- | ------------------------------------------------------------------------------ | ------ | | `init` | wire runtime registries (subscribe to events, register jobs), migrate, warm up | last | | `start` | begin accepting work: listen, start consumers, schedulers, pollers | second | | `ready` | announce: readiness probe on, register in service discovery, log "started" | first | Stopping undoes them in reverse: readiness off first (no new traffic), then the server closes and the consumers stop, then the subscriptions are removed. Then the container is disposed and releases the connections. ## Contributing to another module By default, a module that accepts contributions (routes, commands, health checks) declares an [extension point with a tag](./tags.md), and collects them with `collect(tag)`. No hook is needed. Use an `init` hook only for a registry that changes at runtime, where adding has an undo (an event bus's `on()` returns the unsubscribe). Hooks of `init` finish before `start` begins accepting work: ```ts class OrderService { readonly paid: string[] = []; list(): string { return `orders: ${this.paid.join(', ')}`; } } // Orders doesn't change the platform. It contributes a route under the router's tag, and subscribes to // the bus (a registry that changes at runtime) in an `init` hook that returns the unsubscribe. const addOrders = (c: ServiceRegistry<{ bus: EventBus }>) => c .addSingleton('service', OrderService) .addSingleton( route('list'), (service): Route => ({ path: '/orders', handle: () => service.list() }), ['service'], ) .addSingleton( lifecycle.init('subscriptions'), (bus, service) => bus.on('payment.succeeded', (orderId) => service.paid.push(String(orderId)), ), // returns the unsubscribe function: the undo ['bus', 'service'], ); class PaymentConsumer { constructor(private readonly bus: EventBus) {} start(): void { log.push('consumer started'); this.bus.emit('payment.succeeded', 'order-1'); } async stop(): Promise { log.push('consumer stopped'); } } // A service that only starts and stops: startable() registers it and its `start` hook. const addPayments = (c: ServiceRegistry<{ bus: EventBus }>) => c.extend( startable('consumer', PaymentConsumer, ['bus'], { start: (consumer) => consumer.start(), stop: (consumer) => consumer.stop(), }), ); ``` The owner doesn't know its contributors, and contributors don't know about each other. ## Starting and stopping the app ```ts export const createApp = () => new DIContainer() .addInstance('port', 8080) .extend(addPlatform) .namespace('Orders', addOrders) .namespace('Payments', addPayments) .seal(); export async function main(): Promise { const app = createApp(); // init → start → ready; in Node.js: process.once('SIGTERM', () => running.stop().catch(console.error)) const running = await startLifecycle(app); log.push(`started: ${running.hooks.join(', ')}`); log.push(app.get('router').routes.get('/orders')!()); await running.stop(); // undo ready → start → init, then app.dispose() } ``` In a Node.js server, stop on `SIGTERM` (a complete entry point is in [Graceful shutdown in Node.js](#graceful-shutdown-in-node-js)): ```ts const running = await startLifecycle(app); process.once('SIGTERM', () => running.stop().catch((error) => console.error(error)), ); ``` `running.hooks` lists the hooks that ran, in order: log it at startup to see what your modules contributed. `running.state` is `started`, `stopping` or `stopped`. ### Why `stop()` and not just `dispose()` `dispose()` releases instances in reverse **creation** order. Services created while serving requests are created after the server, so `dispose()` alone would release them while the server still accepts requests. `stop()` first runs the undo functions (the server closes and drains), then disposes. ### Disposing the container `running.stop()` disposes the container after the undo functions, and so does a failed start: * **The order is guaranteed.** Undo first, release second, in one call. * **A failed start leaves no handle to stop.** Without disposal, the connections opened in `init` stay open, and keep Node.js from exiting. * **A stopped app shouldn't be reused.** Its consumers are stopped and its server is closed; disposing turns accidental use into a clear `INJECUTE_DISPOSED`. * **Tests clean up with one line**: `await using running = await startLifecycle(test)`. Pass `dispose: false` when something else owns the container (a host framework, a shared test fixture), or when you need the services after stopping: ```ts const running = await startLifecycle(app, { dispose: false }); // ... await running.stop(); // undo ready → start → init; the services are still alive await flushMetrics(app.get('metrics')); await app.dispose(); ``` A lifecycle runs once per container: the hooks are cached singletons. Calling `startLifecycle()` again returns the first call's promise (its options are ignored), also when that start failed, and after `stop()` it rejects with `INJECUTE_LIFECYCLE_STOPPED`. To start again, create a new container or an isolated fork. ## When a hook fails The hooks that ran are undone in reverse, the container is disposed (unless `dispose: false`), and `startLifecycle()` rejects with the hook's error: `INJECUTE_RESOLUTION_FAILED`, with the hook's key in `path` and the original error in `cause`. ```ts export async function failingStart(): Promise { const broken = new DIContainer() .addSingleton(lifecycle.init('cache'), () => { log.push('cache warmed'); return () => log.push('cache cleared'); }) .addSingleton(lifecycle.start('server'), () => { throw new Error('port 8080 is in use'); }); try { await startLifecycle(broken); return 'started'; } catch (error) { // what ran is undone, the container is disposed, the error names the hook return error instanceof Error ? error.message.split('\n')[0]! : String(error); } } ``` Invalid hooks and options are different: `startLifecycle()` checks them first and rejects before any hook runs, leaving the container as it is (see [Rules](#rules)). Other failures during the same start (an undo function that fails during the rollback, `dispose()`) don't replace the first one. Pass `onError` to log them: ```ts const running = await startLifecycle(app, { onError: (error, { key, during }) => logger.warn({ error, key, during }), }); ``` ## Logging and timing hooks `onHook` is called after each hook runs and after each undo function, with the time it took: ```ts const running = await startLifecycle(app, { onHook: ({ key, action, ms, error }) => logger.info({ key, action, ms: Math.round(ms), failed: error !== undefined, }), }); ``` Use it for startup logs, metrics, and to find the hook that makes the start slow. ## Concurrent stages By default hooks run one by one, so the order and the logs are the same on every start. Hooks of a stage that don't depend on each other's effects can run at once: ```ts await startLifecycle(app, { concurrent: ['init'] }); // or `true` for every stage ``` A concurrent stage waits for all its hooks, also when one fails, so every undo function is known before the rollback. Its undo functions also run concurrently; stages are still undone in reverse order. ## Timeouts and cancellation Pass an `AbortSignal` to bound the start or the stop: * **Hooks get the signal.** Depend on `startSignal` to receive it, and pass it on to the work the hook waits for, so that work stops too: ```ts c.addSingleton( lifecycle.init('config'), async (client, signal) => { await client.load({ signal }); // cancelled when the start times out }, ['configClient', startSignal], ); ``` `startable()` passes it to `start` as the second argument. Without a `signal` option it never aborts. * **Start.** When the signal aborts, `startLifecycle()` stops waiting for the pending hooks and rolls back: the undo functions of what ran receive the aborted signal (their cue to be quick) and are waited for, then the container is disposed, and the promise rejects with an error that names the hook. A hook that finishes later is undone when it does; while it runs, `dispose()` (which waits for it) continues in the background. * **Stop.** Each undo function receives the signal. Once it aborts, the runner stops waiting: each remaining step (the undo functions, called with the aborted signal as their cue to take the fast path, then `dispose()`) gets one turn of the event loop. `stop()` rejects with `INJECUTE_DISPOSE_FAILED`, which lists what failed or didn't finish in time; those steps keep running in the background. An undo function that drains, then forces when the signal aborts: ```ts c.addSingleton( lifecycle.start('server'), (server) => { server.listen(8080); return (signal: AbortSignal) => new Promise((resolve) => { const force = () => server.closeAllConnections(); if (signal.aborted) force(); // already aborted: e.g. a second SIGTERM, or a timed-out start else signal.addEventListener('abort', force, { once: true }); server.close(() => resolve()); }); }, ['server'], ); ``` `dispose()` itself takes no signal, and it waits for singletons that are still being created. A disposer or a hook that never settles keeps it from finishing; with a signal, `startLifecycle()` and `stop()` still return in time. ## Graceful shutdown in Node.js A complete entry point: bound the start, log every hook, stop on `SIGTERM` or `SIGINT` within the grace period, and stop at once on a second signal. ```ts // main.ts import { startLifecycle } from 'injecute'; import { app } from './app.ts'; let running; try { running = await startLifecycle(app, { signal: AbortSignal.timeout(60_000), // a broker that never answers fails the start onHook: ({ key, action, ms, error }) => logger.info({ key, action, ms, error }), onError: (error, context) => logger.warn({ error, ...context }), }); } catch (error) { logger.error(error); // what ran is undone and the container is disposed process.exit(1); } const now = new AbortController(); let stopping = false; const shutdown = (signal: string) => { if (stopping) return now.abort(); // a second signal: stop now stopping = true; logger.info(`${signal}: stopping`); // Kubernetes sends SIGKILL 30 s after SIGTERM: leave time for the last log lines. running .stop({ signal: AbortSignal.any([now.signal, AbortSignal.timeout(25_000)]), }) .then( () => process.exit(0), (error) => { logger.error(error); // INJECUTE_DISPOSE_FAILED lists what failed or didn't finish process.exit(1); }, ); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); ``` `process.exit()` after `stop()` ends the process even when a step that didn't finish in time is still running in the background. ## Tests Run the lifecycle on an isolated fork. Hooks run inside the fork with its replacements, and the app is untouched. A lifecycle with only `init` wires the modules without listening: ```ts // In tests: run only `init` on an isolated fork, with replacements; nothing listens. export async function wiringInTests(): Promise { const test = createApp() .fork({ isolated: true }) .addInstance('port', 0, { replace: true }); await using running = await startLifecycle(test, { lifecycle: createLifecycle(['init']), }); return `${running.hooks.join(', ')} → ${[...test.get('router').routes.keys()].join(', ')}`; } ``` ## Your own stages An app can define its own stages and give that object to its modules: ```ts // An app with its own stages gives its modules this object instead of `lifecycle`. export const stages = createLifecycle(['migrate', 'init', 'start']); export async function migrateThenStart(): Promise { const steps: string[] = []; const cli = new DIContainer() .addSingleton(stages.start('jobs'), () => { steps.push('jobs'); }) .addSingleton(stages.migrate('schema'), () => { steps.push('schema'); }); const running = await startLifecycle(cli, { lifecycle: stages }); await running.stop(); return steps; } ``` Stage names are typed: `stages.strat('x')` and `concurrent: ['strat']` are compile errors. Reusable modules (packages) use the default `lifecycle`: their keys are plain strings, so they run in any app whose stages include `init`, `start` and `ready`. ## Rules 1. **A resource is a service; a side effect is a hook.** A connection pool is an async singleton. Running the migrations with it is a hook that depends on it. 2. **Hooks depend on services, never on other hooks.** Order them with stages. `startLifecycle()` rejects a hook that depends on a hook, directly or through a service, with `INJECUTE_INVALID_HOOK`. 3. **Return the undo.** A hook that starts something returns the function that stops it. 4. **Register hooks with `addSingleton` under a key made by `lifecycle.(name)`.** Don't type the keys by hand: a typo in the stage silently never runs. A key that ends with `:` but isn't a singleton is rejected with `INJECUTE_INVALID_HOOK`; keys like `'user:repo'` are not affected. 5. **Don't do work in module functions.** A module only registers. Calling `bus.on()` in the module body runs once, on the root, and never in a test fork; a hook runs in whichever container runs the lifecycle. 6. **Run the lifecycle on the composition root**, and in tests on an isolated fork. Never on a request fork. ## Without the helper The stage keys work with `preload()` too, which is enough for a script: ```ts const stage = (name: string) => (key: ServiceKey) => typeof key === 'string' && key.endsWith(`:${name}`); await preload(app, stage('init')); await preload(app, stage('start')); ``` `preload()` runs a stage's async hooks at once, doesn't call undo functions and doesn't roll back. --- --- url: https://masyaka.github.io/injecute/guide/testing.md description: >- Replace dependencies in tests with isolated forks, clean up with await using, and assert error codes. --- # Testing ## Replace a dependency everywhere: isolated forks An isolated fork builds every service it resolves itself. Replace the dependency in the fork and everything resolved through it uses the replacement; the app container is untouched. ```ts const app = createApp(); // Everything the test resolves is built inside the fork, with the fake database. await using testContainer = app .fork({ isolated: true }) .addInstance('db', new Database('memory://'), { replace: true }); testContainer.get('users').db.url; // "memory://" app.get('users').db.url; // "postgres://prod": the app is untouched ``` [Open in the playground](../playground?example=testing) `await using` disposes whatever the test created, even when an assertion throws. Forks made from the isolated fork, such as a scope per request, get the replacement too. To replace a service that a scope registers, see [Testing scoped services](./request-state.md#testing-scoped-services). ## Check the wiring One test can check that every service is wired: `checkWiring()` reports every dependency key that nothing registers, and with `resolve: true` every service whose factory throws, all at once, instead of the first request finding one of them: ```ts // Checks that everything the services need is registered, then resolves every service once, in an // isolated fork with fakes for what reaches the outside world. Every missing registration and every // factory that throws is reported at once, instead of the first request finding one of them. await using wiring = createApp() .fork({ isolated: true }) .addInstance('db', new Database('memory://'), { replace: true }); await checkWiring(wiring, { resolve: true }); ``` * Without `resolve`, nothing runs: it only reads the registrations, so it needs no fakes. With `resolve: true` it creates every service once: use an isolated fork with fakes for services that reach the outside world (databases, queues, HTTP clients). * A failing service is reported once, with the services that need it, and its factory runs once: services that depend on it are skipped, not reported again. * It rejects with a `WiringError` (`INJECUTE_WIRING_FAILED`) whose message lists every problem, so a failing test shows them all; `error.problems` has them as data. * Services the compiler can't see, such as ones found in files or registered by decorators, can be declared without being registered: pass their keys, see [Checking the wiring](./custom-registration.md#checking-the-wiring). * To create services at startup, use [`preload()`](./recipes.md#checking-the-wiring-at-startup) instead: it stops at the first failure, which is what an app that is starting needs. ## Override one cached value: `setCacheInstance` ```ts // Or override one cached value on the container itself, until reset(): const other = createApp(); setCacheInstance(other, 'db', new Database('memory://')); other.get('users').db.url; // "memory://" other.reset(); ``` This changes the container you pass until the next `reset()`, so prefer isolated forks when tests share a container. ## Temporary doubles with a middleware A middleware can answer for some keys and be removed with `unuse()`. See [Middlewares](./middleware-events.md#test-doubles). ## Asserting errors Branch on the stable `code`, not on the message: ```ts import { InjecuteError } from 'injecute'; import { expect, it } from 'vitest'; it('requires a database', () => { const app = createApp(); expect(() => app.get('users')).toThrow( expect.objectContaining({ code: 'INJECUTE_NOT_REGISTERED' }), ); }); ``` --- --- url: https://masyaka.github.io/injecute/guide/middleware-events.md description: >- Wrap resolution with middlewares (tracing, fallbacks, deprecations, guards, test doubles) and observe containers with events. --- # Middlewares and events A **middleware** wraps every resolution: it can time it, change it, or answer for keys that are not registered. An **event listener** only observes. Reach for events first. ## Rules * `use(middleware)` adds a middleware; `unuse(middleware)` removes it. The last added runs first. * A middleware receives `(key, next, context)`. Call `next()` to continue, `next(otherKey)` to resolve another key. `context` has `container` (read-only), `path` (keys being resolved) and `depth`. * The chain runs **once per `get()`**, including for nested dependencies (with a higher `depth`). * Forks **inherit** their ancestors' middlewares live: one added to the parent later applies to existing forks. `fork({ middlewares: false })` opts out. * Dependencies of a service registered in a parent are resolved **in the parent**, through the parent's middlewares (the parent owns that instance). In an [isolated fork](./containers.md#isolated-forks) they are resolved in the fork. ## Tracing and timing ```ts const tracing: Middleware = (key, next, { depth }) => { const start = performance.now(); const value = next(); const ms = (performance.now() - start).toFixed(1); lines.push(`${' '.repeat(depth)}${String(key)} ${ms}ms`); return value; }; const app = new DIContainer() .use(tracing) .addInstance('config', { url: 'postgres://localhost' }) .addSingleton('db', (config) => ({ config }), ['config']); app.get('db'); // lines: [" config 0.0ms", "db 0.1ms"] (dependencies are nested) ``` [Open in the playground](../playground?example=middleware/tracing) ## Fallbacks and dynamic keys ```ts // Resolve `env.*` keys from the environment when nothing is registered under them. const env: Record = { PORT: '8080' }; const app = new DIContainer().use((key, next) => { const value = next(); if ( value === undefined && typeof key === 'string' && key.startsWith('env.') ) { return env[key.slice('env.'.length)]; } return value; }); // Keys a middleware provides are not in the container's type; declare them where you use them: const port = app.get('env.PORT' as never) as string; // "8080" ``` Keys a middleware provides are not in the container's type. When the values are known up front, a registration is simpler and typed. To register services found at startup (files in a directory, decorated classes), see [Custom registration](./custom-registration.md). ## Keeping renamed keys working ```ts // Keep an old key working while callers migrate to the new one. const renamed: Record = { mailer: 'emailSender' }; const app = new DIContainer() .addSingleton('emailSender', () => ({ send: (to: string) => `sent to ${to}`, })) .use((key, next) => { const newKey = typeof key === 'string' ? renamed[key] : undefined; if (newKey === undefined) return next(); warnings.push(`"${String(key)}" is deprecated, use "${newKey}"`); return next(newKey); }); const mailer = app.get('mailer' as never) as { send(to: string): string }; mailer.send('ada@example.com'); // works, and records a deprecation warning ``` ## Guards ```ts // Request-scoped services must never be resolved from the root container. const root = new DIContainer() .addSingleton('config', () => ({ region: 'eu' })) .use((key, next, { container }) => { if ( typeof key === 'string' && key.startsWith('request.') && container === root ) { throw new Error( `"${key}" is request-scoped: resolve it from a request fork`, ); } return next(); }); const request = root.fork().addInstance('request.user', { id: 42 }); request.get('request.user'); // ok // root.get('request.user' as never) would throw ``` ## Test doubles ```ts const app = new DIContainer().addSingleton('clock', () => ({ now: () => Date.now(), })); // A temporary override, removed after the test. const fakes = new Map([['clock', { now: () => 0 }]]); const withFakes: Middleware = (key, next) => fakes.has(key) ? fakes.get(key) : next(); app.use(withFakes); const frozen = app.get('clock').now(); // 0 app.unuse(withFakes); const real = app.get('clock').now(); // the real clock again ``` To replace a dependency for a whole graph, an [isolated fork](./testing.md) is usually clearer. ## When not to use a middleware * **Decorating instances** (wrapping a service in a proxy): a middleware runs on every `get()`, so it would return a new wrapper each time and break singleton identity. Decorate with a [self dependency](./registration.md#decorating-a-service) instead. * **Observing**: use events. ## Events ```ts const app = new DIContainer() .addEventListener('add', ({ key }) => events.push(`added ${String(key)}`)) .addEventListener('produce', ({ key }) => events.push(`created ${String(key)}`), ) .addEventListener('dispose', () => events.push('disposed')) .addSingleton('db', () => ({ connected: true })); app.get('db'); // "created db" app.get('db'); // cached: no event ``` | Event | When | Payload | | --------- | --------------------------------------------- | ----------------------------------------------------- | | `add` | a registration is added or replaced | `key`, `replace`, `kind`, `container` | | `replace` | a registration is replaced | `key`, `previous` (a `RegistrationInfo`), `container` | | `reset` | `reset()` cleared cached instances | `keys`, `resetParent`, `container` | | `get` | a service is resolved (also for dependencies) | `key`, `value`, `container` | | `produce` | a factory created an instance | `key`, `value`, `container` | | `dispose` | `dispose()` finished | `container` | `container` is a read-only view of the container that emitted the event. --- --- url: https://masyaka.github.io/injecute/guide/custom-registration.md description: >- Build your own way to register services (files in a directory, wiring generated from files, standard decorators) as a module applied with extend(), what the compiler still checks, and when a middleware fits instead. --- # Custom registration injecute registers services with explicit `add*` calls. When you want services to be found instead (files in a directory, classes with decorators, a manifest, a list of plugins), build that on top of the public API. There are two places to plug it in: | You want to | Use | Types | | -------------------------------------------------- | -------------------------------- | --------------------------------------------------------- | | register services you can find when the app starts | a module applied with `extend()` | declared once by the module, then inferred like any other | | answer for keys nobody can list in advance | a middleware added with `use()` | not in the container's type: every `get()` needs a cast | **Use a module by default.** It finds what to register and calls `addSingleton()` or `addTransient()`. From then on these are ordinary registrations: they are created lazily, cached, released by `dispose()`, shared with forks and replaced in [isolated forks](./testing.md), and `has()`, `preload()` and `buildServicesGraph()` see them. The compiler can't see what is found at runtime, so the module declares what it adds: it returns a `ServiceRegistry`. How much of that declaration the compiler can check depends on the approach. The examples below show both ends, and how generating the wiring from files gets the compiler's full checks back. ## Services from files Each file in `services/` is a service: the file name is the key, the default export is the factory and `dependencies` lists the keys the factory receives, in order. ::: code-group ```ts \[services/clock.ts] // One service per file: the file name is the key, the default export the factory. export default function createClock() { return { now: () => new Date(0) }; } ``` ```ts \[services/greeter.ts] import type createClock from './clock.ts'; // Keys of the services the factory receives, in order. export const dependencies = ['config', 'clock']; export default function createGreeter( config: { greeting: string }, clock: ReturnType, ) { return { greet: (name: string) => `${config.greeting}, ${name} (${clock.now().toISOString()})`, }; } ``` ::: A loader imports the files, and a module registers them: ```ts /** A file in the services directory: the file name is the key, the default export the factory. */ interface ServiceFile { default: (...args: any[]) => unknown; dependencies?: string[]; } type ServiceOf = ReturnType; /** Imports every service file in `directory`. */ async function loadServiceFiles(directory: URL) { const names = (await readdir(directory)).filter( (name) => /\.[jt]s$/.test(name) && !name.endsWith('.d.ts'), ); return Promise.all( names.map(async (name) => ({ key: name.replace(/\.[jt]s$/, ''), file: (await import(new URL(name, directory).href)) as ServiceFile, })), ); } /** * A module that registers the loaded files as singletons. The compiler can't see the directory, so * `Services` declares what the files provide. */ function addServiceFiles( files: { key: string; file: ServiceFile }[], ) { return ( registry: ServiceRegistry, ): ServiceRegistry => { const untyped = registry as unknown as ServiceRegistry; for (const { key, file } of files) { untyped.addSingleton(key, file.default, file.dependencies ?? []); } return registry as unknown as ServiceRegistry; }; } ``` The app declares the services from the files' own types and applies the module: ```ts // Taken from the files' own types: a changed factory changes the service type here. type Services = { clock: ServiceOf; greeter: ServiceOf; }; const files = await loadServiceFiles(new URL('./services/', import.meta.url)); const app = new DIContainer() .addInstance('config', { greeting: 'Hello' }) .extend(addServiceFiles(files)); app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)" ``` * **Checked:** the type of each service (taken from its file, so a changed factory changes it), and that the container has what the files need (`Requires`) when you call `extend()`. * **Not checked:** that a file exists for every declared key, and that `dependencies` match the factory's parameters. A missing file fails with `INJECUTE_NOT_REGISTERED` when something resolves it. [Check the wiring](#checking-the-wiring) in a test to catch it before deploying, or [generate the wiring](#generating-the-wiring-from-files) and the compiler checks both. * `extend()` is synchronous: load the files first (`await`), then apply the module. * Bundled apps and browsers can't read a directory. With Vite, `import.meta.glob('./services/*.ts', { eager: true })` returns the same modules at build time. ## Generating the wiring from files Instead of loading the files when the app starts, a script can write the wiring from them: plain `addSingleton()` calls, as you would write them by hand. The compiler then checks the generated file like any other wiring. **For files in your project, generate the wiring.** Load files at runtime only when they aren't known before the app starts, such as plugins installed later. The script reads the same files as the loader above: the file name is the key, the default export the factory, and `dependencies` the keys it receives. It orders the registrations so each service comes after its dependencies, and keys that no file provides become the module's requirements: ```ts /** A file in the services directory: the file name is the key, the default export the factory. */ interface ServiceFile { default: (...args: any[]) => unknown; dependencies?: readonly string[]; } /** * Writes a wiring file with one `addSingleton()` per file in `directory`, dependencies first. Keys * that no file provides become the module's requirements, typed from the factory parameters that * receive them. */ export async function generateWiring( directory: URL, output: URL, ): Promise { const names = (await readdir(directory)) .filter((name) => /\.[jt]s$/.test(name) && !name.endsWith('.d.ts')) .sort(); const services = new Map(); for (const name of names) { const url = new URL(name, directory); const file = (await import(url.href)) as ServiceFile; let path = relative(dirname(fileURLToPath(output)), fileURLToPath(url)); if (!path.startsWith('.')) path = `./${path}`; const key = name.replace(/\.[jt]s$/, ''); services.set(key, { path, dependencies: [...(file.dependencies ?? [])] }); } // A service is registered after the services it depends on, so each line type-checks. const order: string[] = []; const visiting = new Set(); const visit = (key: string, path: string[]) => { if (order.includes(key) || !services.has(key)) return; if (visiting.has(key)) throw new Error(`Circular dependency: ${[...path, key].join(' -> ')}`); visiting.add(key); for (const dependency of services.get(key)!.dependencies) visit(dependency, [...path, key]); order.push(key); }; for (const key of services.keys()) visit(key, []); // Keys no file provides: the container must have them, typed as the factory expects. const requires = new Map(); for (const key of order) services.get(key)!.dependencies.forEach((dependency, index) => { if (!services.has(dependency) && !requires.has(dependency)) requires.set(dependency, `Parameters[${index}]`); }); const source = [ `// Generated from ${relative(dirname(fileURLToPath(output)), fileURLToPath(directory))}/ by generate-wiring.ts. Don't edit: run the generator.`, `import type { ServiceRegistry } from 'injecute';`, ...order.map( (key) => `import * as ${key} from '${services.get(key)!.path}';`, ), ``, `export interface Requires {`, ...[...requires].map(([key, type]) => ` ${key}: ${type};`), `}`, ``, `export const addServices = (registry: ServiceRegistry) =>`, ` registry`, ...order.map((key) => { const dependencies = services.get(key)!.dependencies; const list = dependencies.map((d) => `'${d}'`).join(', '); return ` .addSingleton('${key}', ${key}.default${list ? `, [${list}]` : ''})`; }), ]; return `${source.join('\n')};\n`; } ``` For the two files above, it writes: ```ts // Generated from ../services/ by generate-wiring.ts. Don't edit: run the generator. import type { ServiceRegistry } from 'injecute'; import * as clock from '../services/clock.ts'; import * as greeter from '../services/greeter.ts'; export interface Requires { config: Parameters[0]; } export const addServices = (registry: ServiceRegistry) => registry .addSingleton('clock', clock.default) .addSingleton('greeter', greeter.default, ['config', 'clock']); ``` The app applies it like any module, without declaring `Services`: ```ts import { DIContainer } from 'injecute'; import { addServices } from './services.generated.ts'; const app = new DIContainer() .addInstance('config', { greeting: 'Hello' }) .extend(addServices); app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)" ``` * **Checked:** everything the compiler checks in hand-written wiring. `dependencies` that don't match the factory's parameters are an error at that service's `addSingleton()`. A dependency that no file and no registration provides is an error at `extend()`, and so is `get()` with a key that has no file. * **Not checked:** that the wiring file is up to date with the files. Run the script before type checking, or compare its output with the committed file in a test: ```ts it('the wiring file is up to date', async () => { const output = new URL('./services.generated.ts', import.meta.url); const source = await generateWiring( new URL('./services/', import.meta.url), output, ); expect(source).toBe(await readFile(output, 'utf8')); }); ``` * The script imports the files to read `dependencies`: run it with a runtime that runs TypeScript, such as Node.js 22.18 or later, Deno or Bun. File names must be valid identifiers. * The wiring is ordinary imports, so bundlers and browsers need nothing extra. Mistakes in the files are compile errors. Dependencies in the wrong order: ```ts export const dependencies = ['clock', 'config']; // the factory takes (config, clock) ``` ```text services.generated.ts: Argument of type '(config: { greeting: string; }, clock: { now: () => Date; }) => …' is not assignable to parameter of type 'Factory<["clock", "config"], …>'. Types of parameters 'config' and 'args_0' are incompatible. ``` A dependency that no file provides and the app doesn't register: ```ts export const dependencies = ['config', 'clock', 'mailer']; // there is no services/mailer.ts ``` ```text app.ts: Argument of type '(registry: ServiceRegistry) => …' is not assignable to parameter of type '{ 'injecute: extension requires services that are not registered': "mailer"; }'. ``` ## Services from decorators Standard decorators (TC39, TypeScript 5 and later without `experimentalDecorators`) don't emit the types of constructor parameters, so a decorator can't infer what a class needs. Each decorator names its key and its dependency keys, as `addSingleton()` does, and the compiler checks them against the class. `createDecorators()` is about 50 lines. Copy it into your project and adapt it: ```ts type Key = Extract; type Resolved = { [I in keyof D]: S[D[I] & keyof S] }; type Lifetime = 'addSingleton' | 'addTransient'; // The keys are checked by the decorators; registering them needs no types. type UntypedRegistry = Record< Lifetime, (key: string, target: unknown, dependencies: string[]) => unknown >; /** * Standard (TC39) class decorators that register classes in a list; the returned function is a * module that adds them to a container with `extend()`. * * `Services` is what the decorated classes provide, `Requires` what they need from the container. */ export function createDecorators< Services extends object, Requires extends object = {}, >() { type All = Services & Requires; const registrations: ((registry: UntypedRegistry) => void)[] = []; const decorator = (lifetime: Lifetime) => , const D extends Key[] = []>( key: K, dependencies?: D, ) => // The class must produce `Services[K]` from the services listed in `dependencies`. ( target: new (...args: Resolved) => Services[K], _context: ClassDecoratorContext, ) => { registrations.push((registry) => registry[lifetime](key, target, dependencies ?? []), ); }; const addDecorated = ( registry: ServiceRegistry, ): ServiceRegistry => { for (const register of registrations) register(registry as unknown as UntypedRegistry); return registry as unknown as ServiceRegistry; }; return Object.assign(addDecorated, { singleton: decorator('addSingleton'), transient: decorator('addTransient'), }); } ``` Declare the services once, decorate the classes and apply the decorators as a module: ```ts // The services the decorated classes provide, and what they need from the container. type Services = { clock: Clock; greeter: Greeter }; type Requires = { config: Config }; const services = createDecorators(); @services.singleton('clock') class Clock { now() { return new Date(0); } } @services.singleton('greeter', ['config', 'clock']) class Greeter { constructor( private config: Config, private clock: Clock, ) {} greet(name: string) { return `${this.config.greeting}, ${name} (${this.clock.now().toISOString()})`; } } const app = new DIContainer() .addInstance('config', { greeting: 'Hello' }) .extend(services); // DIContainer<{ config: Config } & Services> app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)" ``` [Open in the playground](../playground?example=custom-registration/decorators): the example is one file, with `createDecorators()` at the top. * **Checked:** the key is one of `Services`. Each dependency key is in `Services` or `Requires`. The constructor accepts those services in that order, and its instances are the declared service. The container has `Requires` when you call `extend()`, and has `Services` afterwards. * **Not checked:** that every key of `Services` has a decorated class. A decorator runs when its file is evaluated, so a class whose file nobody imports is never registered, and resolving it fails with `INJECUTE_NOT_REGISTERED`. Import every decorated file from the wiring file that calls `extend()`, and [check the wiring](#checking-the-wiring) in a test. * Two classes under the same key fail with `INJECUTE_ALREADY_REGISTERED` at `extend()`. * The decorators only collect registrations. Instances live in the container that applies them, so every container, fork and test gets its own. Mistakes are compile errors at the decorator: ```ts @services.singleton('mailer') // not a key of Services class Mailer {} @services.singleton('greeter', ['clock']) // the constructor needs (Config, Clock) class Greeter { constructor(config: Config, clock: Clock) {} } ``` ```text Argument of type '"mailer"' is not assignable to parameter of type 'keyof Services'. Unable to resolve signature of class decorator when called as an expression. Argument of type 'typeof Greeter' is not assignable to parameter of type 'new (args_0: Clock) => Greeter'. ``` What this costs: * The wiring moves from one file into the classes, and the classes import the decorators. You lose "readable in one place" ([principles 1 and 2](../concepts/principles.md)). The classes themselves stay plain: a test still creates them with `new Greeter(config, clock)`. * Standard decorators must be compiled. `tsc` compiles them. Node.js (including its type stripping) doesn't run them yet, and Vite 8 and Vitest 5 compile only legacy decorators. In Vite or Vitest, compile files that use decorators with TypeScript first: ```ts import ts from 'typescript'; import { defineConfig, type Plugin } from 'vitest/config'; // Vite's transform (Oxc) only lowers legacy decorators, and Node.js doesn't run standard ones yet: // files with standard decorators go through TypeScript first. const standardDecorators: Plugin = { name: 'standard-decorators', enforce: 'pre', transform(code, id) { if (!id.endsWith('.ts') || !/^\s*@\w/m.test(code)) return; const { outputText, sourceMapText } = ts.transpileModule(code, { fileName: id, compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, sourceMap: true, }, }); return { code: outputText, map: sourceMapText }; }, }; ``` ## Testing Services registered by a module are ordinary registrations, however the module found them. Tests work as described in [Testing](./testing.md): replace a dependency in an isolated fork, and everything resolved through the fork uses the replacement. ```ts // The decorated classes are ordinary registrations: an isolated fork replaces the clock for the test. await using testContainer = app .fork({ isolated: true }) .addInstance( 'clock', { now: () => new Date('2030-01-01') }, { replace: true }, ); const greeting = testContainer.get('greeter').greet('Ada'); // "Hello, Ada (2030-01-01T00:00:00.000Z)" app.get('greeter').greet('Ada'); // "Hello, Ada (1970-01-01T00:00:00.000Z)": the app is untouched ``` The same works for services registered from files. ### Checking the wiring The compiler checks what you declare in `Services`, but not that every declared service is actually registered. List the keys in an object that `satisfies Record`: the compiler then checks the list against `Services`, so a declared service can't be left out. Pass the list to [`checkWiring()`](./testing.md#check-the-wiring) in a test: ```ts // The compiler can't see the directory, but it checks this list against `Services`: a key missing // here, or one that `Services` doesn't have, is a compile error. const declared = { clock: true, greeter: true } satisfies Record< keyof Services, true >; /** Reports every declared service without a file, and every dependency key that nothing registers. */ async function checkServiceFiles(container: ServiceProvider) { await checkWiring(container, { keys: Object.keys(declared) as (keyof Services)[], }); } await checkServiceFiles(app); ``` * **A declared service without a file or decorated class** is reported as not registered. * **A dependency key that nothing registers** (a typo in `dependencies`) is reported with every service that needs it. Neither check runs a factory. * **Dependencies in the wrong order** are caught only if the factory fails with them: pass `resolve: true` to run every factory (on an [isolated fork](./testing.md) with fakes). With files, nothing compares `dependencies` with the factory's parameters; with the decorators, the compiler does. * The same check works for decorated classes: list the keys of the `Services` you passed to `createDecorators()`. ## Keys nobody can list: a middleware When the keys come from outside and can't be listed at startup, such as environment variables or remote settings looked up by name, a middleware can answer for them: ```ts // Resolve `env.*` keys from the environment when nothing is registered under them. const env: Record = { PORT: '8080' }; const app = new DIContainer().use((key, next) => { const value = next(); if ( value === undefined && typeof key === 'string' && key.startsWith('env.') ) { return env[key.slice('env.'.length)]; } return value; }); // Keys a middleware provides are not in the container's type; declare them where you use them: const port = app.get('env.PORT' as never) as string; // "8080" ``` A middleware is the right tool only when the keys really are unknown up front: * The keys aren't in the container's type, so every `get()` needs a cast. Wrap the lookup in a typed function instead of casting at every call site. * It runs on every `get()` and caches nothing. Cache values yourself if they are expensive. * The values aren't registrations: `has()`, `preload()` and `buildServicesGraph()` don't know them, and `dispose()` doesn't release them. If you can list the keys when the app starts, even by computing them, register them in a module instead. For example, read the environment once and call `addInstance()` for every variable. See [Middlewares and events](./middleware-events.md) for what else middlewares do. --- --- url: https://masyaka.github.io/injecute/guide/sdk.md description: >- A platform SDK that other teams install, built on a hidden container. Consumers get a plain object with lazy services, start and stop, plugins that replace, decorate or extend its services, and a test kit. --- # Building an SDK A platform team ships an SDK that product teams install: an API client, feature flags, telemetry, auth. Inside, the SDK is a graph of services with shared clients, background work and resources to release. injecute wires that graph, and consumers use it through a plain object: ```ts import { createPlatform } from 'platform-sdk'; await using platform = createPlatform({ service: 'checkout', apiUrl, token }); await platform.start(); platform.flags.isEnabled('one-click'); ``` What the SDK gets from the container: * **Lazy services.** A consumer that only calls the API never creates the telemetry pipeline. * **Start and stop.** Background work (flag refresh, telemetry flush) starts in stages and stops in reverse, and everything is released with one call. * **Plugins.** Consumers replace, decorate or extend the services the SDK opens to them, with typed modules, and the change reaches every service that uses them. * **A test kit.** A fake transport reaches the whole graph, so consumers test their code against the real SDK without a network. * **Modules for the teams behind the SDK.** Each feature is a module with typed requirements and services private to it. What the consumers get: an object typed by the SDK's own interface. A consumer that doesn't write plugins never deals with injecute, can use another container or none, and doesn't break when the SDK changes its wiring. ## The design | Part | What it is | Public | | -------------------- | --------------------------------------------------------------- | ------ | | The contract | interfaces of the SDK object, its options and its services | yes | | The plugin API | the services plugins can use, the plugin type, extension points | yes | | The modules | one module per feature, applied as a namespace | no | | The core | the SDK's composition root: a new sealed container per call | no | | `createPlatform()` | builds a core and returns the SDK object | yes | | `createTestPlatform` | the SDK with fakes, in a testing entry (`platform-sdk/testing`) | yes | The whole example is in `examples/sdk/` of the repository: the SDK in [`platform-sdk.ts`](https://github.com/Masyaka/injecute/blob/main/examples/sdk/platform-sdk.ts) and a consumer in [`consumer.ts`](https://github.com/Masyaka/injecute/blob/main/examples/sdk/consumer.ts). ## The contract Write the public types first, as interfaces the SDK owns: ```ts /** What the consumer passes to the SDK. */ export interface PlatformOptions { /** The consuming service, sent with every request. */ service: string; apiUrl: string; token: string; logger?: Logger; fetch?: typeof fetch; /** Applied in order, after the SDK's own registrations. */ plugins?: readonly PlatformPlugin[]; } export interface Logger { warn(message: string): void; } export interface ApiClient { get(path: string): Promise; } export interface FeatureFlags { isEnabled(flag: string): boolean; } export interface Telemetry { track(event: string, data?: Record): void; } /** The SDK. Consumers see this interface, never the container. */ export interface Platform extends AsyncDisposable { readonly api: ApiClient; readonly flags: FeatureFlags; readonly telemetry: Telemetry; /** Loads the flags and starts background work: flag refresh, telemetry flush. */ start(): Promise; /** Stops background work, flushes telemetry and releases everything. Safe to call twice. */ stop(): Promise; } ``` * **The SDK object is not a container.** It isn't a `DIContainer`, a `ServiceProvider` or a `ProxyAccessor`, so consumers can't resolve or replace services through it. What plugins can do is defined by the [plugin API](#plugins). * **Expose interfaces, not classes.** `flags` is a `FeatureFlags`, not `RemoteFlags`: the SDK can add methods to its classes (`refresh()`) without making them public. * **Options for settings, plugins for services.** A logger or a `fetch` is an option. Replacing or wrapping how the SDK works is a plugin. ## The modules and the core Each feature is a [module](./containers.md#modules) that declares what it needs, applied as a [namespace](./containers.md#namespaces). Services only the module uses get symbol keys, so they are [private](./containers.md#private-services) to it: ```ts // Each feature is a module, applied as a namespace. Its symbol keys stay inside the namespace: other // modules and the SDK object can't resolve them. const addFlags = ( c: ServiceRegistry<{ transport: Transport; logger: Logger }>, ) => c.addSingleton('flags', RemoteFlags, ['transport', 'logger']).addSingleton( lifecycle.start('refresh'), async (flags) => { await flags.refresh(); const timer = setInterval(() => void flags.refresh(), 60_000); return () => clearInterval(timer); }, ['flags'], ); const buffer = Symbol('buffer'); // private to the Telemetry module const addTelemetry = ( c: ServiceRegistry<{ transport: Transport; options: PlatformOptions; logger: Logger; }>, ) => c .addSingleton( buffer, (transport, options, sinks) => new EventBuffer(transport, options.service, sinks), ['transport', 'options', collect(telemetrySink)], // every plugin's sink ) .addSingleton( 'telemetry', (events): Telemetry => ({ track: (name, data = {}) => events.push({ name, data }), }), [buffer], ) .addSingleton( lifecycle.start('flush'), (events, logger) => { const timer = setInterval(() => { events.flush().catch((error) => logger.warn(String(error))); }, 10_000); return async () => { clearInterval(timer); await events.flush(); // the last events go out before the SDK stops }; }, [buffer, 'logger'], ); ``` The core is the SDK's composition root: ```ts // The SDK's composition root. A new container per call: two SDK instances share nothing. function createCore(options: PlatformOptions) { const logger: Logger = options.logger ?? console; const core = new DIContainer() .addInstance('options', options) .addInstance('logger', logger) .addSingleton('transport', FetchTransport, ['options']) .addSingleton('api', PlatformApi, ['transport']) .namespace('Flags', addFlags) .namespace('Telemetry', addTelemetry); // Plugins come last: a replacement or decoration of `transport` reaches everything that uses it. for (const plugin of options.plugins ?? []) core.extend(plugin); return core.seal(); } ``` * **A new container per call.** Two SDK instances (two tenants, two tests) share nothing. Don't create a container at the top of a module. * **Shared internals at the root.** `transport` and `logger` are used by several modules, so they are registered in the root, before the modules that need them. `extend()` and `namespace()` check that they are there. * **Plugins last, then sealed.** Plugins see everything the SDK registered. Nothing registers after them, and the sealed type is a single flat object, which is cheaper to typecheck. See [Seal the composition root](./typescript.md#seal-the-composition-root). ## The SDK object `createPlatform()` builds a core and wraps it in the SDK object: ```ts /** The services the SDK object exposes, and what it needs to start and stop. */ type Core = ServiceProvider<{ api: ApiClient; 'Flags.flags': FeatureFlags; 'Telemetry.telemetry': Telemetry; }> & { dispose(): Promise }; /** Creates the SDK. Nothing runs until a service is used or `start()` is called. */ export function createPlatform(options: PlatformOptions): Platform { return toPlatform(createCore(options)); } function toPlatform(core: Core): Platform { let running: Promise | undefined; let stopped: Promise | undefined; const stop = () => (stopped ??= (async () => { // a failed start already undid its hooks and disposed the container const run = await running?.catch(() => undefined); await (run ? run.stop() : running ? undefined : core.dispose()); })()); return { get api() { return core.get('api'); }, get flags() { return core.get('Flags.flags'); }, get telemetry() { return core.get('Telemetry.telemetry'); }, async start() { running ??= startLifecycle(core); await running; }, stop, [Symbol.asyncDispose]: stop, }; } ``` * **One getter per exposed service.** The object lists what consumers get, with the SDK's types and a place for TSDoc. A getter resolves its service on first access, and then returns the cached singleton. * **`start()` runs the [lifecycle](./startup-shutdown.md).** The flags load and the timers start. Services work without it: a consumer that only uses `api` never calls `start()`. * **`stop()` undoes the hooks in reverse, then disposes.** The last telemetry is flushed before the transport is released. Without `start()`, it only disposes. A second call returns the same promise, so `await using` and an app's `dispose` can both call it. * **After `stop()`, services are gone.** A getter throws [`INJECUTE_DISPOSED`](../errors/disposed.md) instead of returning a closed client. ### Why not a proxy accessor [`createProxyAccessor()`](./recipes.md#exposing-a-container-as-a-plain-object) exposes a container as a plain object without writing getters. It suits an internal object of an app. For an SDK, write the getters: * The SDK object also needs `start()`, `stop()` and `[Symbol.asyncDispose]`, and a proxy accessor only resolves services. * The accessor's type is a `ProxyAccessor<…>` from injecute. Without an annotation with your own interface, it ends up in the SDK's `.d.ts`. * Getters show up in the debugger, in `console.log()` and in "go to definition" as ordinary properties. If the SDK exposes dozens of services, put them on a proxy accessor annotated with your interface (`platform.services.api`), and keep `start()` and `stop()` on the SDK object. ## Plugins A plugin is a [module](./containers.md#modules): a function that registers services in the SDK's container. The SDK declares which services plugins can use, and the extension points they contribute to: ```ts /** Sends requests to the platform. Plugins replace or decorate it; tests replace it with a fake. */ export interface Transport { send(method: 'GET' | 'POST', path: string, body?: unknown): Promise; } export interface TelemetryEvent { name: string; data: Record; } /** Receives every flushed batch of telemetry, next to the platform. */ export interface TelemetrySink { send(events: readonly TelemetryEvent[]): Promise; } /** An extension point: plugins register sinks under it, `telemetrySink('name')`. */ export const telemetrySink = createTag('telemetrySink').of(); /** * The services a plugin can depend on, replace or decorate. Their keys and types are public API of the * SDK, like the plugin type itself. */ export interface PluginServices { options: PlatformOptions; logger: Logger; transport: Transport; } /** A plugin is a module: it registers services in the SDK's container, and nothing else. */ export type PlatformPlugin = ( registry: ServiceRegistry, ) => ServiceRegistry; // Plugins import injecute's helpers from the SDK, so they use the SDK's copy of injecute. export { lifecycle, optional } from 'injecute'; ``` A consumer writes plugins with the same calls as any module: ```ts /** Decorates the transport: records every request, then sends it. */ export const recordRequests = (log: string[]): PlatformPlugin => (c) => c.addSingleton( 'transport', (inner): Transport => ({ send: (method, path, body) => { log.push(`${method} ${path}`); return inner.send(method, path, body); }, }), { dependencies: ['transport'], replace: true }, // `inner` is the previous transport ); /** Replaces the transport: everything goes through the company's gateway. */ export const viaGateway = (gateway: Transport): PlatformPlugin => (c) => c.addInstance('transport', gateway, { replace: true }); /** Contributes to an extension point: telemetry also goes to the consumer's analytics. */ export const analytics = (received: TelemetryEvent[]): PlatformPlugin => (c) => c.addSingleton(telemetrySink('analytics'), (): TelemetrySink => ({ send: async (events) => { received.push(...events); }, })); export const createInstrumentedPlatform = ( requests: string[], received: TelemetryEvent[], ) => createPlatform({ service: 'checkout', apiUrl: 'https://platform.internal', token: process.env.PLATFORM_TOKEN ?? '', plugins: [recordRequests(requests), analytics(received)], }); ``` * **Replace** a service with `replace: true`. The core applies plugins after its own registrations, so the API client, the flags and the telemetry buffer use the new transport. Replacing a key the SDK doesn't have throws `INJECUTE_NOTHING_TO_REPLACE`, so a plugin written for another version of the SDK fails at startup instead of adding an unused service. * **Decorate** a service by depending on its own key: `inner` is the previous definition. Plugins apply in order, so a later plugin decorates what an earlier one registered. * **Contribute** to an extension point: the SDK collects every `telemetrySink(…)` with [`collect()`](./tags.md), and the sink's type is checked where the plugin registers it. * **Start and stop with the SDK.** A plugin can register a [lifecycle hook](./startup-shutdown.md#writing-a-hook) (`lifecycle.start('name')`), and the SDK's `start()` and `stop()` run it with the SDK's own hooks. What the types check: * A plugin receives a `ServiceRegistry`. A dependency on a key outside `PluginServices`, such as a module's internal service, is a compile error, and the SDK's [private services](./containers.md#private-services) can't be resolved from a plugin at all. * A registry can register, but not add middlewares, fork or dispose: plugins can't take over the SDK's configuration or lifecycle. See [Roles](../concepts/roles.md). * `replace: true` doesn't check that the new service matches the old one. Annotate the factory's return type with the service's interface, as `(inner): Transport => …` does. What it makes public: * **`PluginServices`, the plugin type and the tags are public API.** Renaming `transport` or changing `Transport` is a breaking change of the SDK. Open only what plugins need; the rest stays internal and free to change. * **The plugin API is typed with injecute.** `PlatformPlugin` uses `ServiceRegistry`, and `telemetrySink` is a `Tag`, so the SDK's `.d.ts` refers to injecute there. The SDK re-exports the helpers plugins use (`lifecycle`, `optional`): a marker such as `optional(key)` is recognized only by the copy of injecute that created it, and the re-export makes plugins use the SDK's copy. ## Tests for consumers Ship a testing entry with the SDK. It creates the SDK with the fakes as the first plugin: ```ts /** What a test can replace. Everything listed here is public API of the testing entry. */ export interface PlatformFakes { transport?: Transport; } /** The SDK with fakes, for consumers' tests. Ships as `platform-sdk/testing`. */ export function createTestPlatform( fakes: PlatformFakes = {}, options: Partial = {}, ): Platform { // The fakes are the first plugin: the consumer's plugins decorate the fake transport like the real one. const useFakes: PlatformPlugin = (c) => fakes.transport ? c.addInstance('transport', fakes.transport, { replace: true }) : c; return createPlatform({ service: 'test', apiUrl: 'http://platform.test', token: 'test', ...options, plugins: [useFakes, ...(options.plugins ?? [])], }); } ``` The fake transport reaches the API client, the flags and the telemetry buffer, also the private one, and the consumer's plugins decorate the fake as they would decorate the real transport. Consumers test their code against the real SDK: ```ts /** A fake transport: answers the flags request and records what was sent. */ export class FakeTransport implements Transport { readonly sent: { method: string; path: string; body?: unknown }[] = []; constructor(private readonly flags: Record = {}) {} async send(method: 'GET' | 'POST', path: string, body?: unknown) { this.sent.push({ method, path, body }); return path === '/flags' ? this.flags : {}; } } // A unit test of the consumer's code, with the real SDK and a fake transport. export async function testCheckout() { const transport = new FakeTransport({ 'one-click': true }); { await using platform = createTestPlatform({ transport }); await platform.start(); new Checkout(platform.flags, platform.telemetry).run(); // 'one-click' } // stop() flushes the telemetry through the fake return transport.sent; } // Plugins run in tests too: they decorate the fake transport like the real one. export async function testPlugins() { const transport = new FakeTransport({ 'one-click': true }); const requests: string[] = []; const received: TelemetryEvent[] = []; { await using platform = createTestPlatform( { transport }, { plugins: [recordRequests(requests), analytics(received)] }, ); await platform.start(); new Checkout(platform.flags, platform.telemetry).run(); } return { requests, received }; // ['GET /flags', 'POST /telemetry'], [checkout.started] } // A test of the whole app: replace the SDK in an isolated fork. export async function testApp() { const transport = new FakeTransport({ 'one-click': true }); const app = createApp() .fork({ isolated: true }) .addSingleton('platform', () => createTestPlatform({ transport }), { replace: true, dispose: (platform) => platform.stop(), }); const running = await startLifecycle(app); const flow = app.get('checkout').run(); await running.stop(); return { flow, sent: transport.sent }; } ``` `PlatformFakes` is public API of the testing entry. Keep it small: a fake transport covers the network, and the SDK's own logic runs as in production. ## Using the SDK A consumer without a container creates the SDK in its entry point: ```ts export async function main() { await using platform = createPlatform({ service: 'checkout', apiUrl: 'https://platform.internal', token: process.env.PLATFORM_TOKEN ?? '', }); await platform.start(); // loads the flags, starts the telemetry flush new Checkout(platform.flags, platform.telemetry).run(); } // stopped here: telemetry is flushed, timers are cleared ``` A consumer that uses injecute registers the SDK as one service, and the parts its own code uses as keys of its app: ```ts // In an app that uses injecute itself, the SDK is one service. Register the parts your code uses as // keys of your app, so your services don't depend on the whole SDK. export const createApp = () => new DIContainer() .addInstance('config', { platformToken: process.env.PLATFORM_TOKEN ?? '' }) .addSingleton( 'platform', (config) => createPlatform({ service: 'checkout', apiUrl: 'https://platform.internal', token: config.platformToken, }), { dependencies: ['config'], dispose: (platform) => platform.stop() }, ) .addSingleton('flags', (platform) => platform.flags, ['platform']) .addSingleton('telemetry', (platform) => platform.telemetry, ['platform']) .addSingleton('checkout', Checkout, ['flags', 'telemetry']) // started with the app; stopped by dispose, after everything that depends on it .addSingleton(lifecycle.init('platform'), (platform) => platform.start(), [ 'platform', ]); ``` * **The app's services don't depend on the SDK object.** `Checkout` receives `FeatureFlags` and `Telemetry`, so its tests pass fakes of two small interfaces. * **The SDK starts with the app.** The `init` hook starts it before the app's `start` hooks (servers, consumers) run. On shutdown, the app's hooks are undone first, then `dispose` stops the SDK after everything that depends on it. * **Tests replace the SDK.** The `testApp()` function in the example above replaces `platform` in an [isolated fork](./containers.md#isolated-forks) with `createTestPlatform()`. ## Packaging ```text platform-sdk/ src/ index.ts createPlatform, the contract and the plugin API: the public API testing.ts createTestPlatform, PlatformFakes core.ts createCore and toPlatform: not exported from the package flags/flags.container.ts telemetry/telemetry.container.ts package.json ``` ```json { "exports": { ".": "./lib/index.js", "./testing": "./lib/testing.js" }, "dependencies": { "injecute": "^1.0.0" } } ``` * **A regular dependency, not a peer.** The SDK never shares its container with the app, and plugins get injecute's helpers from the SDK, so the app and the SDK may use different versions of injecute, and several SDKs may bundle their own copies. A peer dependency is for a library that takes the app's container; see [Libraries built on injecute](../concepts/roles.md#libraries-built-on-injecute). * **Check the public types.** Build the SDK and search its `.d.ts` files for `injecute`: only the plugin API should mention it. * **Size.** In a browser bundle, the container adds about 7 KB gzipped, and the lifecycle about 2.5 KB more. The package has no runtime dependencies and is tree-shakable. * **Versioning.** The SDK's semver covers its contract, its plugin API and its testing entry. A change to the rest of the wiring is not a breaking change. ## When not to If the SDK is one client class, a class with a constructor is the simplest SDK: there is no graph to wire, and nothing for plugins to replace. --- --- url: https://masyaka.github.io/injecute/guide/async.md description: >- Services created asynchronously, in a DIContainer with defer() or in an AsyncDIContainer that awaits every dependency. --- # 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`](../errors/resolution-failed.md) 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 => ({ 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 { return app.get('db'); // Promise } ``` `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` registers a `Config`. Every resolution returns a promise: ```ts const app = new AsyncDIContainer() .addSingleton('config', loadConfig) // Promise → 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`](./containers.md), 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 }; }>, ) { 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](./testing.md)). 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](./startup-shutdown.md): `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 | 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` | --- --- url: https://masyaka.github.io/injecute/guide/request-context.md description: >- Give every service the trace id, tenant and user of the current request with a context accessor backed by AsyncLocalStorage. --- # Request context Services often need to know about the current request: a logger adds the trace id, a repository filters by tenant, an audit log records the user. The recommended way is a **context accessor**: 1. The application declares the context it needs and depends on an accessor for it. 2. The host (an HTTP server, a queue worker, a CLI) registers the accessor first, in the root container, and keeps the current context in [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html). 3. Services stay singletons and read the context when they use it. For state that a unit of work owns, such as a transaction or a per-request cache, see [Scoped state](./request-state.md). ## A context accessor or a scope A service registered in the root container runs in the root (see [Forks](./containers.md#forks)), so it can't depend on a `request` that a [scope](./containers.md#scopes) registers: in the root, the key is a type error and [`INJECUTE_NOT_REGISTERED`](../errors/not-registered.md) at runtime. Both options work; they differ in where the services live: * **A context accessor** keeps every service in the root, built once. Services read the request's data when they run, so a logger, a repository or a domain service registered in the root can use it. * **A scope** gives the services that need the request a home of their own: they are registered in the scope, take the request as an ordinary dependency, and are created again for every request. Data that many layers read, like the trace id, the tenant or the user, usually fits an accessor. A group of services that belong to the request fits a scope. [Scoped state](./request-state.md#choosing) has a table for choosing. ## The application declares what it needs ```ts /** What the application needs to know about the current request, job or command. */ export interface RequestContext { readonly traceId: string; readonly tenantId?: string; } /** Reads the current context; `undefined` outside a request (at startup, in timers). */ export interface ContextAccessor { current(): T | undefined; } class Logger { constructor(private readonly context: ContextAccessor) {} info(message: string): void { // read the context when it is used, never in the constructor const { traceId = '-', tenantId = '-' } = this.context.current() ?? {}; lines.push(`[${traceId} ${tenantId}] ${message}`); } } class Orders { constructor( private readonly logger: Logger, private readonly context: ContextAccessor, ) {} async place(item: string): Promise { await new Promise((resolve) => setTimeout(resolve, 1)); // the context survives awaits this.logger.info(`order placed: ${item}`); return `${this.context.current()?.tenantId ?? 'public'}: ${item}`; } } // The module requires a `context` service: `extend()` checks that the host registered one. export const addOrders = ( c: ServiceRegistry<{ context: ContextAccessor }>, ) => c .addSingleton('logger', Logger, ['context']) .addSingleton('orders', Orders, ['logger', 'context']); ``` * The application owns the shape of `RequestContext`. Put in what your domain needs: tenant, user, locale, a deadline. injecute has no context type of its own. * The application depends on the accessor, not on `AsyncLocalStorage`: it imports neither `node:async_hooks` nor HTTP types, and runs the same under any host. * Services read the context **when they use it**. Reading it in a factory or constructor would keep the context of whichever request created the singleton (see [Pitfalls](#pitfalls)). ## The host provides it ```ts export const storage = new AsyncLocalStorage(); const context: ContextAccessor = { current: () => storage.getStore(), }; // The first layer is the context; the application is added on top of it. export const app = new DIContainer() .addInstance('context', context) .extend(addOrders); await preload(app); // create the singletons at startup, outside any request ``` The context is the host's first layer. `extend(addOrders)` is a type error when `context` is missing, so an application module can't be added to a host that doesn't provide it (see [Modules](./containers.md#modules)). Each request runs inside `storage.run()`: ```ts export function handle(request: Request): Promise { const context: RequestContext = { traceId: request.headers['x-trace-id'] ?? crypto.randomUUID(), tenantId: request.headers['x-tenant-id'], }; // everything the request calls sees `context`, including code after `await` return storage.run(context, () => app.get('orders').place('book')); } ``` Everything the request calls sees its context, including code after `await` and in parallel promises. Concurrent requests don't see each other's context. A whole app on this pattern, with a logger and a tracer that read the context and feature modules in namespaces: [open it in the playground](../playground?example=real-world). ### Queue consumers, cron jobs, CLI commands Every entry point runs its work in `storage.run()`, so the application code is the same everywhere: ```ts // A queue consumer provides the same context, so the application code does not change. export function consume(job: { id: string; tenantId: string; item: string; }): Promise { return storage.run({ traceId: `job-${job.id}`, tenantId: job.tenantId }, () => app.get('orders').place(job.item), ); } ``` ### Frameworks Start the context in a middleware that runs before your handlers, and wrap the rest of the request with `storage.run(context, next)`. The [framework pages](../frameworks/index.md) show where: [Express](../frameworks/express.md), [Fastify](../frameworks/fastify.md#request-context) (after the body is parsed), [Hono](../frameworks/hono.md) and [Next.js](../frameworks/nextjs.md#request-context). ## Logging [Pino](https://github.com/pinojs/pino/blob/main/docs/api.md#mixin-function)'s `mixin` runs on every log line, so a singleton logger can add the current context: ```ts import pino from 'pino'; const addLogger = ( c: ServiceRegistry<{ context: ContextAccessor }>, ) => c.addSingleton( 'logger', (context) => pino({ // return a new object on every call: pino merges into it mixin: () => ({ ...context.current() }), }), ['context'], ); ``` A framework's request logger (Fastify's `request.log`) carries the request id, but only where you pass the request. A logger that reads the context works in every service, including code that doesn't know it runs in a request. ## Create long-lived services at startup Timers, sockets and event listeners run in the context they were created in. A connection pool created by the first request that needs it keeps that request's context (and keeps it in memory) for as long as it lives: ```ts class Pool { readonly checks: (string | undefined)[] = []; // a timer (like a socket or an event listener) runs in the context it was created in readonly #timer = setInterval( () => this.checks.push(storage.getStore()?.traceId), 5, ).unref(); [Symbol.dispose](): void { clearInterval(this.#timer); } } // ✗ Created by the first request that needs it: its timer runs in that request's context forever const lazy = new DIContainer().addSingleton('pool', Pool); storage.run({ traceId: 'a' }, () => lazy.get('pool')); // lazy.get('pool').checks: ["a", "a", …] // ✓ Created at startup, outside any request const eager = new DIContainer().addSingleton('pool', Pool); await preload(eager); storage.run({ traceId: 'a' }, () => eager.get('pool')); // eager.get('pool').checks: [undefined, undefined, …] ``` `await preload(app)` before the server starts listening creates every singleton outside any request. ## Pitfalls **Don't resolve the context once.** A singleton that stores the context, or reads it in its factory or constructor, keeps the first request's context for every later one: ```ts // ✗ The context is resolved once, by the first request, and kept for every later one const captured = new DIContainer() .addSingleton('context', () => storage.getStore()) .addSingleton('logger', (context) => ({ traceId: () => context?.traceId }), [ 'context', ]); // ✓ An accessor is registered, and the context is read when it is used const accessor = new DIContainer() .addInstance('context', { current: () => storage.getStore() }) .addSingleton( 'logger', (context) => ({ traceId: () => context.current()?.traceId }), ['context'], ); const requests = ['a', 'b']; export const fromCaptured = requests.map((traceId) => storage.run({ traceId }, () => captured.get('logger').traceId()), ); // ["a", "a"] export const fromAccessor = requests.map((traceId) => storage.run({ traceId }, () => accessor.get('logger').traceId()), ); // ["a", "b"] ``` The same happens with a function dependency, `[() => storage.getStore()]`, on a singleton: it runs once, when the singleton is created. Don't keep what you read in a field either; call `current()` each time. **Use `run()`, not `enterWith()`.** `enterWith()` sets the context for the rest of the synchronous execution, which can leak it into the next request. `run()` scopes it to a callback, and every runtime that has `AsyncLocalStorage` supports it. **Keep the context small.** Ids and plain values, not services. A service or a container in the context is a dependency that no registration shows, and nothing disposes it. **Work that outlives the request still sees its context.** A `setTimeout` or a fire-and-forget promise started in a request keeps the request's context, and keeps it in memory. Copy the values it needs, or start it with `storage.exit()`. **Changing the context during a request.** Create the context when the request starts. When a later step adds to it (authentication sets the user), let that step set a field of the context object. A nested `storage.run({ ...storage.getStore()!, user }, next)` works too, but it starts a new context object, which [one instance per request](./request-state.md#one-instance-per-request) treats as a new request. ## Beyond ambient data The context carries data that many layers read. For state a request **owns** (a transaction, a per-request cache, a per-tenant implementation), see [Scoped state](./request-state.md): a scope, a factory with `using`, one instance per context or a strategy chosen by the context, with a table for choosing. ## Testing Test the application with a fixed context, without `AsyncLocalStorage`: ```ts // The application needs no AsyncLocalStorage in tests: give it a fixed context. const fixed: ContextAccessor = { current: () => ({ traceId: 'test-1', tenantId: 'acme' }), }; const app = new DIContainer().addInstance('context', fixed).extend(addOrders); export const placed = await app.get('orders').place('book'); // "acme: book" ``` To give a whole app container another context in a test, replace it in an isolated fork: `app.fork({ isolated: true }).addInstance('context', fixed, { replace: true })`. ## Other runtimes `AsyncLocalStorage` is available in Node.js, Deno, Bun and Cloudflare Workers (with Node.js compatibility); use `run()` and `getStore()`, which all of them support. Browsers don't have it yet; the TC39 [AsyncContext proposal](https://github.com/tc39/proposal-async-context) would add a standard one. Only the host touches the storage, so moving to another one changes a single registration. --- --- url: https://masyaka.github.io/injecute/guide/request-state.md description: >- State that belongs to one unit of work (a session, a request, a transaction, a job) - scopes with their own services, and patterns that keep every service in the root (arguments, a unit of work, a context, one instance per context, resources released at the end, a strategy per tenant), with a table for choosing. --- # Scoped state Some state belongs to one unit of work: a user session, a request, a transaction, a queue job. There are two ways to give it to services, and this page compares them: * **A scope:** a fork for the unit of work, with services of its own. Its services may depend on the root's; the root's never depend on a scope's. * **Patterns that keep every service in the root:** the root is built once, and the state reaches the services as an argument, through a context, or through a singleton that hands out one instance per unit of work. | Option | For | The container's part | | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | [A scope](#scopes) | Several services that belong to the unit of work, share its instances and use the root | Creates, wires and disposes the scope's services | | [Pass it as an argument](#pass-it-as-an-argument) | Values of one operation: the order, the transaction | None | | [A factory and `using`](#a-factory-and-using-a-unit-of-work) | Instances an operation creates and releases: a unit of work | Registers the factory as a singleton, with its dependencies | | [A context accessor](./request-context.md) | Data many layers read: the trace id, the tenant, the user | Registers the accessor first, in the root | | [One instance per context](#one-instance-per-request) | Lazy instances per unit of work: a cache, a DataLoader, an identity map | Registers a singleton that hands out one instance per context | | [Resources released at the end](#release-what-a-request-creates) | A transaction several layers share, released when the work ends | Registers a singleton that hands out one resource per context | | [A strategy chosen by the context](#a-different-implementation-per-tenant) | A different implementation per tenant | Registers the implementations and the singleton that picks one | What each costs: * **A scope** creates its services again for every unit of work, and a service that depends on a scope service belongs in the scope too. In return its services take the scoped state as ordinary dependencies, and the scope disposes them. * **The patterns** build the graph once, but the services that use the state know that it is scoped: they take it as an argument or read it through `current()`. [Choosing](#choosing) at the end lists common needs with the option that fits each. ## Scopes A scope is a fork for one unit of work. Several services that belong to it share its instances and use the root's services; the scope wires them and disposes them when the work is done: ```ts const app = new DIContainer() .addSingleton('db', Database) .addSingleton('gateway', Gateway); // Several services share one transaction and also need the app's services: the fork wires them, // and disposes the transaction at the end (rolled back unless committed). export async function placeOrder(item: string, quantity: number) { await using scope = app .fork() .addSingleton('tx', (db) => db.begin(), ['db']) .addSingleton('orders', OrderRepository, ['tx']) .addSingleton('payments', Payments, ['tx', 'gateway']); scope.get('orders').insert(item); if (quantity < 1) throw new Error('quantity must be at least 1'); scope.get('payments').charge(quantity * 10); scope.get('tx').commit(); } ``` [Open in the playground](../playground?example=recipes/request-scope) * The scope's singletons are the unit of work's instances: one `tx` here, shared by `orders` and `payments`, and rolled back by `await using` unless it was committed. * The direction is fixed. A scope's services may depend on the root's; a root service can't name a key that only a scope registers, so no singleton keeps the first scope's instance. Other containers call this a scoped lifetime. * Create the scope in one place (the handler, the job runner, a framework hook) and dispose it with `await using`. * A scope can decorate a root service for its own services, such as a logger with the request id: see [Decorating a root service in a scope](./containers.md#decorating-a-root-service-in-a-scope). A root service that names a scope's key doesn't compile; the error points at the key: ```ts new DIContainer() .addSingleton('db', Database) .addSingleton('report', (tx) => tx, ['tx']); // error: Type '"tx"' is not assignable to type 'Dependency<{}, { db: Database; }>'. ``` Scopes also fit work without `AsyncLocalStorage` (some edge runtimes), where passing the state explicitly would reach too many services. ### Scope modules A module can't register scoped services in the root: the root would create them once, for every unit of work. Give a feature with scoped services two modules, one for the root and one for the scope. The composition root applies the root modules; the place that creates the scope applies every scope module: ```ts // orders/orders.container.ts: the feature's two modules /** What the app shares: registered once, in the root. */ export const addOrders = (c: ServiceRegistry) => c.addSingleton('gateway', Gateway); /** What one request owns: its scope module, applied to each request's scope. */ export const addOrdersRequest = ( c: ServiceRegistry<{ db: Database; gateway: Gateway }>, ) => c .addSingleton('tx', (db) => db.begin(), ['db']) .addSingleton('orders', OrderRepository, ['tx']) .addSingleton( 'payments', (tx, gateway): Payments => new GatewayPayments(tx, gateway), ['tx', 'gateway'], ) .addSingleton('checkout', Checkout, ['orders', 'payments', 'tx']); // container.ts: the composition root applies the root modules export const createApp = () => new DIContainer().addSingleton('db', Database).extend(addOrders).seal(); export type App = ReturnType; /** Every feature's scope module for a request, in one place. */ export const addRequestModules = (c: ServiceRegistry>) => c.extend(addOrdersRequest); // server.ts: one scope per request, created in one place, disposed at the end export const createHandler = (app: App, addRequest = addRequestModules) => async (item: string, quantity: number): Promise => { await using scope = app.fork().extend(addRequest); scope.get('checkout').placeOrder(item, quantity); }; ``` [Open in the playground](../playground?example=recipes/request-modules) * The types keep the two apart: a scope module declares the root services it needs (`ServiceRegistry<{ db: Database; gateway: Gateway }>`), and `extend()` checks that the root provides them. * Don't register a scope's key in the root too (a default `tx`, say): root services that depend on it would keep the root's one. A scope module that registers it then throws `INJECUTE_ALREADY_REGISTERED` and names those services (see [Overriding a parent's service in a fork](./containers.md#overriding-a-parent-s-service-in-a-fork)). * A service that depends on a scope service belongs in the scope module too. Keep scope modules small: what they register is created again in every scope that uses it. ### Testing scoped services ```ts // A root dependency: request forks of an isolated fork are built with its replacement. const testApp = createApp() .fork({ isolated: true }) .addInstance('db', new Database('memory'), { replace: true }); await createHandler(testApp)('book', 1); // tx1: begin on memory, … // A scoped service: the handler applies the scope modules, then the replacement. export const charged: number[] = []; const fakePayments: Payments = { charge: (amount) => void charged.push(amount), }; await createHandler(app, (c) => addRequestModules(c).addInstance('payments', fakePayments, { replace: true }), )('pen', 2); // charged: [20] ``` * To replace a dependency of the root, build the handler from an isolated fork that replaces it. The scopes are forks of the isolated fork, so their services get the replacement. * To replace a scoped service, let the handler take the scope modules as a parameter, and pass them with the replacement applied after them, with `replace: true`. * [`checkWiring()`](./testing.md#check-the-wiring) on a scope built in the test checks the scope modules too. ## Pass it as an argument The simplest per-request state is a parameter. When one or two layers need a value, pass it: ```ts class OrderRepository { insert(tx: Transaction, order: Order) { tx.query('insert into orders …', order); } } ``` The dependency is visible in the types, tests pass what they need, and there is nothing to scope. Move to another pattern when the same value is threaded through many layers only to reach a few places. ## A factory and `using`: a unit of work When an operation creates something and must release it (a transaction, a checked-out connection, temporary files), register a **factory** and let the caller own what it creates. With [`using`](https://github.com/tc39/proposal-explicit-resource-management), the instance is disposed at the end of the block, also when it throws: ```ts /** The repositories of one business operation, bound to one transaction. */ class UnitOfWork { constructor(private readonly tx: Transaction) {} insertOrder(item: string): void { this.tx.query(`insert ${item}`); } commit(): void { this.tx.commit(); } [Symbol.dispose](): void { this.tx[Symbol.dispose](); // rolls back unless committed } } class Checkout { constructor(private readonly work: { begin(): UnitOfWork }) {} placeOrder(item: string, quantity: number): void { // the unit of work lives in this block and is disposed at its end, also when it throws using work = this.work.begin(); work.insertOrder(item); if (quantity < 1) throw new Error('quantity must be at least 1'); work.commit(); } } // The container provides the factory, a singleton; the caller owns what it creates. const app = new DIContainer() .addSingleton('db', Database) .addSingleton( 'unitOfWork', (db) => ({ begin: () => new UnitOfWork(db.begin()) }), ['db'], ) .addSingleton('checkout', Checkout, ['unitOfWork']); app.get('checkout').placeOrder('book', 1); // tx1: insert book, tx1: commit ``` [Open in the playground](../playground?example=recipes/unit-of-work) * `Checkout` and the factory are singletons. The unit of work lives in one method call, not in a container. * The factory is a service like any other: it gets its dependencies from the container, and tests can replace it. * The unit of work groups what the operation needs (repositories bound to the transaction), so they are not passed one by one. ## One instance per request A per-request cache, a [DataLoader](https://github.com/graphql/dataloader) or an identity map is created on first use and shared by everything in the request. Keep it in a singleton that hands out one instance per [context](./request-context.md): ```ts /** One value per context (per request): created on first use, forgotten with the context. */ export interface PerContext { current(): T; } export function perContext( context: ContextAccessor, create: () => T, ): PerContext { const values = new WeakMap(); return { current() { const key = context.current(); if (!key) throw new Error('No request context: call this inside a request'); if (!values.has(key)) values.set(key, create()); return values.get(key)!; }, }; } ``` ```ts const storage = new AsyncLocalStorage<{ traceId: string }>(); const app = new DIContainer() .addInstance('context', { current: () => storage.getStore() }) .addSingleton('db', Database) // a singleton that hands out one cache per request .addSingleton( 'users', (context, db) => perContext(context, () => new UserCache(db)), ['context', 'db'], ) .addSingleton( 'profiles', (users) => ({ name: (id: string) => users.current().get(id).name }), ['users'], ); export function handle(ids: string[]): string[] { // a new context object per request, so a new cache per request return storage.run({ traceId: crypto.randomUUID() }, () => ids.map((id) => app.get('profiles').name(id)), ); } handle(['1', '1', '2']); // 2 queries: the second "1" comes from the request's cache handle(['1']); // 1 more query: a new request, a new cache ``` * The context object is the key: create a new object for each request (`storage.run({ … }, …)`). The `WeakMap` forgets the instances with the context. A nested `storage.run({ ...context, user }, …)` starts a new key, and so new instances: to add to the context during a request, set a field instead. * Outside a request, `current()` throws, so code that needs a request fails loudly instead of sharing one instance. * The instances are not disposed. For instances that must be released, use a factory and `using`, or [release them with the request](#release-what-a-request-creates). * For DataLoaders that only resolvers use, a GraphQL server's per-request context is enough: see [GraphQL](../frameworks/graphql.md). ## Release what a request creates A transaction that several layers share in one request is opened by the first one that needs it, and must be released when the request ends, also when it fails. One instance per request handles the sharing. The request's resources handle the release: ```ts type Resource = Disposable | AsyncDisposable; /** What one request owns. Released when the request ends, the last one first. */ export class RequestResources { #owned: Resource[] = []; add(resource: T): T { this.#owned.push(resource); return resource; } async [Symbol.asyncDispose](): Promise { const owned = this.#owned.reverse(); this.#owned = []; const errors: unknown[] = []; for (const resource of owned) { try { if (Symbol.asyncDispose in resource) await resource[Symbol.asyncDispose](); else resource[Symbol.dispose](); } catch (error) { errors.push(error); // release the others before reporting it } } if (errors.length > 0) throw new AggregateError(errors, 'Releasing request resources failed'); } } export interface RequestContext { readonly traceId: string; readonly resources: RequestResources; } /** One resource per request: created on first use, released with the request. */ export interface PerRequest { current(): T; } export function perRequest( context: { current(): RequestContext | undefined }, create: () => T, ): PerRequest { const values = new WeakMap(); return { current() { const request = context.current(); if (!request) throw new Error('No request context: call this inside a request'); if (!values.has(request)) values.set(request, request.resources.add(create())); return values.get(request)!; }, }; } ``` ```ts class OrderRepository { constructor(private readonly tx: PerRequest) {} insert(item: string): void { this.tx.current().query(`insert ${item}`); } } class Payments { constructor(private readonly tx: PerRequest) {} async charge(amount: number): Promise { await new Promise((resolve) => setTimeout(resolve, 1)); // the gateway call this.tx.current().query(`charge ${amount}`); // the same transaction after awaits } } class Checkout { constructor( private readonly orders: OrderRepository, private readonly payments: Payments, private readonly tx: PerRequest, ) {} async placeOrder(item: string, quantity: number): Promise { this.orders.insert(item); if (quantity < 1) throw new Error('quantity must be at least 1'); await this.payments.charge(quantity * 10); this.tx.current().commit(); } } const storage = new AsyncLocalStorage(); // Every service is a singleton, built once. Only the transaction is per request. const app = new DIContainer() .addInstance('context', { current: () => storage.getStore() }) .addSingleton('db', Database) .addSingleton('tx', (context, db) => perRequest(context, () => db.begin()), [ 'context', 'db', ]) .addSingleton('orders', OrderRepository, ['tx']) .addSingleton('payments', Payments, ['tx']) .addSingleton('checkout', Checkout, ['orders', 'payments', 'tx']); // The host owns the request, so it releases what the request created: after the handler, also // when it throws. export async function handle(item: string, quantity: number): Promise { await using resources = new RequestResources(); const context = { traceId: crypto.randomUUID(), resources }; await storage.run(context, () => app.get('checkout').placeOrder(item, quantity), ); } ``` [Open in the playground](../playground?example=recipes/request-resources) * The host owns the request, so it owns what the request creates: `await using resources` releases it after the handler, the last one first, also when the handler throws. The services stay singletons, and `dispose()` still releases the app. * A resource is created on first use: a request that never touches `tx` opens no transaction. * A failing release doesn't stop the others; the errors are thrown together, as an `AggregateError`. * Don't use a request's resources in work that outlives the request, like a timer or a background job: they are released when the request ends. * Where `AsyncDisposableStack` is available (Node.js 24 and later), it can replace `RequestResources`: `resources.use(create())`. * Compared to [a scope](#scopes), the graph is built once, and every service is registered in the root and its modules. The cost: services that use the resource depend on `PerRequest` and call `current()`, so they know it belongs to a request. ## A different implementation per tenant Instead of an isolated fork per tenant, register the implementations and a singleton that picks one for the current tenant on every call (the Strategy pattern): ```ts const storage = new AsyncLocalStorage(); const tenantMailers: Record = { acme: 'ses' }; const app = new DIContainer() .addInstance('context', { current: () => storage.getStore() }) .addInstance('tenantMailers', tenantMailers) .addSingleton('smtpMailer', SmtpMailer) .addSingleton('sesMailer', SesMailer) // one singleton that picks the implementation for the current tenant on every call .addSingleton( 'mailer', (context, tenantMailers, smtp, ses): Mailer => { const mailers = { smtp, ses }; const current = () => mailers[tenantMailers[context.current()?.tenantId ?? ''] ?? 'smtp']; return { send: (to) => current().send(to) }; }, ['context', 'tenantMailers', 'smtpMailer', 'sesMailer'], ); export const sent = ['acme', 'globex'].map((tenantId) => storage.run({ traceId: tenantId, tenantId }, () => app.get('mailer').send(`ops@${tenantId}.example`), ), ); // ["ses → ops@acme.example", "smtp → ops@globex.example"] ``` For per-tenant **resources**, such as a connection pool per tenant, keep them in a singleton map (one per tenant) and create them at startup: a pool created lazily inside a request keeps that request's context (see [Create long-lived services at startup](./request-context.md#create-long-lived-services-at-startup)). ## Choosing | You need | Use | | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Several services that belong to a session, request, transaction or job and share its state | [a scope](#scopes), disposed with `await using` | | Scoped services in several features | a root module and a [scope module](#scope-modules) per feature | | A scope's services with a decorated root service, like a logger with the request id | [a decoration in the scope](./containers.md#decorating-a-root-service-in-a-scope) | | The trace id, tenant, user or locale in logs and services | [a context accessor](./request-context.md) | | Request data in services registered in the root: domain services, repositories, the logger | a context accessor | | The same application code under HTTP handlers, queue consumers, cron jobs and CLI commands | a context accessor, or the same scope modules in each host | | Data that changes during the work, like the user after authentication | a context accessor | | A value that one or two layers need | an argument | | A transaction, a checked-out connection or temporary files, released at the end | a factory and `using`, or a scope | | State several root services share for one unit of work: a cache, a DataLoader, an identity map | one instance per context | | A transaction several root services share, released at the end | resources released at the end | | A different implementation of a service per tenant | a strategy chosen by the context | | Per-tenant resources, like a pool per tenant | a singleton map, created at startup | | Code that must never run outside a unit of work | a scope (the key doesn't exist outside it), or one instance per context (`current()` throws) | | Tests | a fixed accessor; isolated forks to replace services | | A runtime without `AsyncLocalStorage` | arguments, or a scope | --- --- url: https://masyaka.github.io/injecute/guide/typescript.md description: >- How injecute types containers, how to type modules and consumers, what the errors look like, and supported TypeScript versions. --- # TypeScript ## Types come from the registrations Every `add*` call returns the container with a bigger type, so `get()` and factory parameters are always inferred. You rarely write a type by hand. ## Typing consumers and modules ```ts // Consumers declare the services they use; any container that has them is accepted. function listUsers(services: ServiceProvider<{ db: Database }>): string[] { return services.get('db').query(); } ``` ```ts // Modules receive a registry: they can register, but not add middlewares, fork or dispose. const addUsers = (c: ServiceRegistry<{ db: Database }>) => c.addSingleton('users', (db) => ({ list: () => db.query() }), ['db']); ``` ```ts // The composition root owns the container and wires everything together. const app = new DIContainer().addSingleton('db', Database).extend(addUsers); listUsers(app); type AppServices = ContainerServices; // { db: Database } & { users: … } ``` A `ServiceProvider` of more services is assignable to one of fewer, so declare only what a function needs. See [Roles](../concepts/roles.md) for why modules get a `ServiceRegistry` and consumers a `ServiceProvider`. ## What errors look like | Mistake | Error | | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | a typo in a dependency key | `Type '"loger"' is not assignable to type 'Dependency<…>'. Did you mean '"logger"'?` | | a dependency of the wrong type | `Argument of type '(l: string) => string' is not assignable to parameter of type 'Factory<["logger"], …>'` | | constructor arguments in the wrong order | `Argument of type 'typeof Repo' is not assignable to parameter of type 'Factory<["url", "logger"], …>'` | | a factory with parameters but no dependency list | `… is not assignable to parameter of type 'Factory<[], …>'` | | a module needs a service that is not registered | `… '{ 'injecute: extension requires services that are not registered': "db"; }'` | | an unknown key in `get()` | `Argument of type '"nope"' is not assignable to parameter of type '"url" \| …'` | ## Long registration chains Each registration adds to the container type, and one long chain gets slower to typecheck the longer it is: 300 `addSingleton` calls in a single chain take about 0.6 s. Split big containers into [modules](./containers.md#modules) that declare only what they need (`ServiceRegistry<{ db: Database }>`). A module's registrations are checked against its own small service map, so the same 300 registrations as 10 modules take about 0.2 s, and each file stays readable. In the container's type, each module's services are one object, so hovers stay short too. ## Seal the composition root A container's type is an intersection of every registration (`{ db: … } & { users: … } & …`). Once the composition root is complete, [`seal()`](./containers.md#sealing-the-composition-root) turns it into one object type and removes the registration methods: ```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'); ``` * **Faster typechecking.** Forks, request scopes and tests built on the sealed root typecheck about 25% faster, because TypeScript looks services up in one object instead of scanning every registration. * **Readable hovers and errors**: `{ dbUrl: string; db: Database; users: UserRepository }` instead of hundreds of `{ key: … } &` members. ### Exporting a container from a package When a library or shared package exports a container, TypeScript writes its whole service map into the `.d.ts` file, and writes it again for every exported fork or derived container. Give the map a name with an interface; TypeScript refers to interfaces by name: ```ts // The composition root registers everything... const root = new DIContainer().addSingleton('db', Database).extend(addUsers); // ...and names the result once. interface RootServices extends ContainerServices {} const container: DIContainer = root; // Code built on `container` refers to the name: this fork is DIContainer. const requestScope = container.fork().addInstance('requestId', 'req-1'); ``` Everything built on `container` then refers to `RootServices`: for a 300-service app that also exports a request scope, the `.d.ts` is about half the size, and forks built on it typecheck as fast as on a sealed container. Seal it too if nothing should register in it any more: `const container: SealedDIContainer = root.seal()`. Namespaces don't multiply the map: it holds each namespaced service once, under its full key (`Billing.invoices`), and a `Namespace` marker under the namespace name. `get('Billing')` turns the marker into a `ServiceProvider` of the namespace's services; `NamespaceServices` is the same service map as a type. ## Supported versions TypeScript **5.2 and newer** (CI checks 5.2 and the latest release, currently 7.0). The minimum is raised only in a minor release, announced in the changelog; see [Versioning and support](./versioning.md). If your `lib` setting predates `Symbol.asyncDispose` (for example `"ES2022"`), the package's type declarations add it, so `await using` and `dispose()` typecheck anyway. --- --- url: https://masyaka.github.io/injecute/guide/no-build.md description: Use injecute directly in browsers, Deno and Bun, from npm, JSR or a CDN. --- # Without a build step injecute is a plain ES module with no dependencies, so it runs without a bundler. ## Browsers From a CDN that bundles the package into one request: ```html ``` With an import map and the published files: ```html ``` From JSR, through esm.sh: ```html ``` Evergreen browsers are supported (the build targets ES2022). ## Deno ```ts import { DIContainer } from 'jsr:@masyaka/injecute'; // or from npm: import { DIContainer } from 'npm:injecute'; ``` The guides import from `'injecute'`. `deno add npm:injecute` maps that name to the npm package. To get the same name from JSR, map it in `deno.json`: ```json { "imports": { "injecute": "jsr:@masyaka/injecute@^1.0.0" } } ``` ## Bun and Node.js ```ts import { DIContainer } from 'injecute'; ``` Node.js ≥ 22.12 can also `require('injecute')`. --- --- url: https://masyaka.github.io/injecute/guide/recipes.md description: >- A unit of work per operation, a scope, configuration-driven implementations, feature modules, a module's private services, an SDK for other teams, sharing services between containers. --- # Recipes ## A unit of work per operation A service creates what one operation owns with a factory, and `using` releases it: ```ts /** The repositories of one business operation, bound to one transaction. */ class UnitOfWork { constructor(private readonly tx: Transaction) {} insertOrder(item: string): void { this.tx.query(`insert ${item}`); } commit(): void { this.tx.commit(); } [Symbol.dispose](): void { this.tx[Symbol.dispose](); // rolls back unless committed } } class Checkout { constructor(private readonly work: { begin(): UnitOfWork }) {} placeOrder(item: string, quantity: number): void { // the unit of work lives in this block and is disposed at its end, also when it throws using work = this.work.begin(); work.insertOrder(item); if (quantity < 1) throw new Error('quantity must be at least 1'); work.commit(); } } // The container provides the factory, a singleton; the caller owns what it creates. const app = new DIContainer() .addSingleton('db', Database) .addSingleton( 'unitOfWork', (db) => ({ begin: () => new UnitOfWork(db.begin()) }), ['db'], ) .addSingleton('checkout', Checkout, ['unitOfWork']); app.get('checkout').placeOrder('book', 1); // tx1: insert book, tx1: commit ``` [Open in the playground](../playground?example=recipes/unit-of-work) More patterns for state that belongs to one unit of work, and a table for choosing between them, are in [Scoped state](./request-state.md). ## A scope A fork for one unit of work, such as a request or a transaction, is a scope: several services that belong to it share its instances and use the app's services, and the scope disposes them together: ```ts const app = new DIContainer() .addSingleton('db', Database) .addSingleton('gateway', Gateway); // Several services share one transaction and also need the app's services: the fork wires them, // and disposes the transaction at the end (rolled back unless committed). export async function placeOrder(item: string, quantity: number) { await using scope = app .fork() .addSingleton('tx', (db) => db.begin(), ['db']) .addSingleton('orders', OrderRepository, ['tx']) .addSingleton('payments', Payments, ['tx', 'gateway']); scope.get('orders').insert(item); if (quantity < 1) throw new Error('quantity must be at least 1'); scope.get('payments').charge(quantity * 10); scope.get('tx').commit(); } ``` [Open in the playground](../playground?example=recipes/request-scope) Create the scope in one place (the handler or a framework hook) and dispose it at the end. Its services may depend on the root's, never the reverse. See [Scopes](./request-state.md#scopes) for scope modules and how scopes compare with the other patterns. ## Choosing implementations from configuration ```ts // Hey, try change mailer value to 'console', see the graph on right side const config = { mailer: 'smtp' as 'smtp' | 'console' }; const app = new DIContainer() .addSingleton('smtpMailer', (): Mailer => ({ send: (to) => `smtp → ${to}` })) .addSingleton('consoleMailer', (): Mailer => ({ send: (to) => `console → ${to}`, })) // choose the implementation once, at the composition root .addAlias( 'mailer', config.mailer === 'smtp' ? 'smtpMailer' : 'consoleMailer', ); app.get('mailer').send('ada@example.com'); // "smtp → ada@example.com" ``` ## Feature modules Give each feature a module and compose them at the root: ```ts const app = new DIContainer() .addInstance('config', loadConfig()) .extend(addDatabase) // (c: ServiceRegistry<{ config: Config }>) => … .extend(addUsers) // (c: ServiceRegistry<{ db: Database }>) => … .extend(addBilling); ``` Keep each module in a wiring file of its own; see [Keep the wiring in files of its own](./app-structure.md#keep-the-wiring-in-files-of-its-own). When a module expects a service under another key or in another shape, see [Adapting a module's dependencies](./containers.md#adapting-a-module-s-dependencies). ## A module's private services Register services only a module uses under symbol keys, and apply the module as a namespace: ```ts const cache = Symbol('cache'); // not exported const addCatalog = (c: ServiceRegistry<{ db: Database }>) => c .addSingleton(cache, () => new Map()) .addSingleton('products', ProductService, ['db', cache]); app.namespace('Catalog', addCatalog); // 'Catalog.products' resolves; the cache doesn't ``` See [Private services](./containers.md#private-services). ## An SDK for other teams Build the SDK's services in a container of its own (a new one per call), and give consumers an object typed by the SDK's interface: ```ts /** The services the SDK object exposes, and what it needs to start and stop. */ type Core = ServiceProvider<{ api: ApiClient; 'Flags.flags': FeatureFlags; 'Telemetry.telemetry': Telemetry; }> & { dispose(): Promise }; /** Creates the SDK. Nothing runs until a service is used or `start()` is called. */ export function createPlatform(options: PlatformOptions): Platform { return toPlatform(createCore(options)); } function toPlatform(core: Core): Platform { let running: Promise | undefined; let stopped: Promise | undefined; const stop = () => (stopped ??= (async () => { // a failed start already undid its hooks and disposed the container const run = await running?.catch(() => undefined); await (run ? run.stop() : running ? undefined : core.dispose()); })()); return { get api() { return core.get('api'); }, get flags() { return core.get('Flags.flags'); }, get telemetry() { return core.get('Telemetry.telemetry'); }, async start() { running ??= startLifecycle(core); await running; }, stop, [Symbol.asyncDispose]: stop, }; } ``` Consumers get lazy services, `start()` and `stop()`, plugins that replace, decorate or extend its services, and a test kit. See [Building an SDK](./sdk.md). ## Sharing services between independent containers ```ts import { addNamedResolvers, createNamedResolvers, DIContainer } from 'injecute'; const shared = createNamedResolvers(core, ['db', 'logger']); const feature = new DIContainer().extend(addNamedResolvers(shared)); feature.get('db'); // resolved by `core` ``` ## Exposing a container as a plain object ```ts import { createProxyAccessor } from 'injecute'; const services = createProxyAccessor(app, { keys: ['users', ['mailer', 'email']], }); services.users; // resolves 'users' services.email; // resolves 'mailer' ``` ## Checking the wiring at startup ```ts import { preload } from 'injecute'; await preload(app); // resolves every service now, so configuration errors surface at boot ``` `preload()` stops at the first failure, which is what a starting app needs. In a test, use [`checkWiring()`](./testing.md#check-the-wiring) instead: it reports every problem at once. To run modules' startup work (routes, subscriptions, consumers) and stop it gracefully, see [Startup and shutdown](./startup-shutdown.md). `buildServicesGraph(app)` returns every service with its dependencies, for tooling and diagrams; the [playground](../playground.md) uses it. --- --- url: https://masyaka.github.io/injecute/guide/ai-agents.md description: >- Point coding agents at the docs of the installed version, install the Agent Skill, and use llms.txt or Context7. --- # Using injecute with AI agents Coding agents write better injecute code when the docs of the version you use are in their context. injecute ships them in the package, so they always match the installed version. ## Docs in the package Every release contains: | Path in `node_modules/injecute/` | Contents | | -------------------------------- | ---------------------------------------------------------------------- | | `llms.txt` | An index of the docs below, with a summary of the API rules | | `lib/docs/` | The guides, concepts, error codes and the migration guide, as Markdown | | `lib/index.d.ts` | The type declarations, with TSDoc and an example on every export | | `src/` | The TypeScript sources (declaration maps point here) | | `skills/` | The Agent Skill below | | `CHANGELOG.md` | Changes per version | Tell your agent to read them. Add this to your project's `AGENTS.md` (or `CLAUDE.md`): ```md ## injecute Before writing code that uses injecute, read `node_modules/injecute/llms.txt` and the pages it links (start with `lib/docs/guide/getting-started.md`). Register classes directly, pass dependency keys as the third argument, type modules as `ServiceRegistry<{ …what they need }>`, use `fork({ isolated: true })` in tests, and branch on `InjecuteError.code`. ``` An index that is always in context works better than docs the agent has to decide to look up. ## Agent Skill The `injecute` [Agent Skill](https://agentskills.io) comes with the package and the repository. It has the rules for writing, reviewing and debugging injecute code. Upgrading from 0.x? Point the agent at the [migration guide](../migration/0.x-to-1.0.md). Install it in Claude Code from the plugin marketplace in the repository: ```sh /plugin marketplace add Masyaka/injecute /plugin install injecute@injecute ``` Or, for any agent that supports skills: ```sh npx skills add Masyaka/injecute ``` ## Online * [`llms.txt`](https://masyaka.github.io/injecute/llms.txt) indexes the website; [`llms-full.txt`](https://masyaka.github.io/injecute/llms-full.txt) is every page in one file. * Every page is also available as Markdown: add `.md` to its URL, or use the links at the top of the page. * [Context7](https://context7.com/masyaka/injecute) serves these docs to agents that use it. The online docs describe the latest version. For the version you have installed, prefer the docs in the package. --- --- url: https://masyaka.github.io/injecute/frameworks.md description: >- How injecute fits into a web framework (the composition root, routes from modules, request context, startup and shutdown, tests), with pages for Express, Fastify, Hono, Next.js, GraphQL, React, Vue, Solid and NestJS users. --- # Frameworks injecute doesn't replace your framework. The framework handles requests; the container builds the services behind them, wires them, and releases them when the app stops. Every page in this section follows the same shape: 1. **One composition root.** The app is built in one chain: configuration, connections, feature modules. A missing service is a compile error, not a crash on the first request. 2. **Handlers close over their services.** A module's route factory receives the services it needs, and its handlers use them. Handlers never see the container. 3. **Routes come from modules.** The HTTP module declares an [extension point](../guide/tags.md); each feature module contributes its routes to it. The HTTP module doesn't import the features. 4. **Request data through a context accessor.** The framework's middleware runs each request in `storage.run()`; services read the trace id, tenant or user when they need it. See [Request context](../guide/request-context.md). 5. **Listening is a lifecycle hook.** `startLifecycle()` starts the server, and `running.stop()` stops accepting requests, waits for open ones, then closes the connections. See [Startup and shutdown](../guide/startup-shutdown.md). 6. **Tests use an isolated fork.** Replace the database in a fork, build the server from it and send it requests with the framework's own test helper, without listening on a port. These pages put the framework in the container: routes are services, and the server is a lifecycle hook. For most apps, keeping the framework out of the core is the better default; see [Keep the web framework out of the core](../guide/app-structure.md#keep-the-web-framework-out-of-the-core). The UI frameworks are different: the container builds the core behind the components, and the framework's context passes it down. See [React, Vue and Solid](./ui-frameworks.md). Your services import neither the framework nor injecute, so the same modules run under an HTTP server, a queue worker, a cron job or a CLI. | Page | For | | ------------------------------------------ | ----------------------------------------------------------------------------------------- | | [Express](./express.md) | Express 5 apps | | [Fastify](./fastify.md) | Fastify 5 apps: plugins for HTTP, the container for services | | [Hono](./hono.md) | Hono on Node.js, Bun, Deno and Cloudflare Workers | | [Next.js](./nextjs.md) | The App Router: server components, route handlers and server actions | | [GraphQL](./graphql.md) | GraphQL Yoga and Apollo Server, with a DataLoader per request | | [React, Vue and Solid](./ui-frameworks.md) | When a UI app needs a container; the core behind the UI, TanStack Query, state management | | [Coming from NestJS](./nestjs.md) | Moving from NestJS's dependency injection, all at once or step by step | --- --- url: https://masyaka.github.io/injecute/frameworks/express.md description: >- Use injecute with Express — typed services in handlers without app.locals, routers contributed by feature modules, request context, graceful shutdown, and tests with supertest. --- # Express Express has no place for services. Apps import module-level singletons, which tests can only replace with module mocks, or hang them on `app.locals` and `res.locals`, which are untyped. With injecute: * **Typed services in handlers.** A router's factory receives the services it needs, and its handlers close over them: no `app.locals` casts, no container on `req`. * **Wiring in one place.** The composition root lists every service and what it depends on. A missing service is a compile error, not a `TypeError` on the first request that needs it. * **Modules bring their own routers.** Feature modules contribute routers to an extension point; the HTTP module doesn't import them. * **Shutdown in the right order.** The server stops accepting requests and finishes the open ones before the database pool closes. Express has no shutdown of its own. * **Tests without module mocks.** An isolated fork replaces the database for the whole graph, and supertest calls the same Express app. * **Services without Express.** `OrderService` doesn't import Express, so a queue worker or a CLI uses it as it is. ## The HTTP module The HTTP module owns the Express app and the server. It declares an [extension point](../guide/tags.md) for routers, and starts the server in a [lifecycle hook](../guide/startup-shutdown.md): ```ts // http.ts import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; import express, { type Router } from 'express'; import { collect, createTag, lifecycle, type ServiceRegistry } from 'injecute'; import { storage } from './context.ts'; /** A router a module mounts under a path. */ export interface Route { readonly path: string; readonly router: Router; } /** The extension point: every module's routers. */ export const route = createTag('route').of(); export const addHttp = (c: ServiceRegistry<{ port: number }>) => c .addSingleton( 'http', (routes) => { const http = express(); http.use(express.json()); // after the body parsers: the handlers run in this context http.use((req, _res, next) => storage.run( { traceId: req.get('x-request-id') ?? randomUUID(), tenantId: req.get('x-tenant-id'), }, next, ), ); for (const { path, router } of routes) http.use(path, router); return http; }, [collect(route)], ) .addSingleton( lifecycle.start('server'), async (http, port) => { const server = http.listen(port); await once(server, 'listening'); // rejects when the port is taken // undo: stop accepting connections and wait for open requests return () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); }, ['http', 'port'], ); ``` * `collect(route)` receives the routers of every module, also from namespaces, in registration order. * Each request runs in `storage.run()`, so services read the trace id and tenant through the [context accessor](../guide/request-context.md), without the request being passed to them. * The server is a hook, so `running.stop()` closes it before `dispose()` releases the connections. To force open connections closed after a timeout, see [Timeouts and cancellation](../guide/startup-shutdown.md#timeouts-and-cancellation). ## A feature module A module registers its services and contributes a router. The handlers close over `orders`: ```ts // orders.ts import { Router } from 'express'; import type { ServiceRegistry } from 'injecute'; import { route, type Route } from './http.ts'; export const addOrders = (c: ServiceRegistry<{ db: Database }>) => c.addSingleton('orders', OrderService, ['db']).addSingleton( route('orders'), (orders): Route => { const router = Router(); router.get('/', async (_req, res) => { res.json(await orders.list()); }); router.post('/', async (req, res) => { res.status(201).json(await orders.place(req.body.item)); }); return { path: '/orders', router }; }, ['orders'], ); ``` The router is created once, with the app. Express 5 passes a rejected promise from an async handler to your error middleware; an `InjecuteError` there has a stable [`code`](../errors/index.md). ## The composition root The host keeps the request context in `AsyncLocalStorage`. `RequestContext` and `ContextAccessor` are the application's types, as in [Request context](../guide/request-context.md#the-application-declares-what-it-needs): ```ts // context.ts import { AsyncLocalStorage } from 'node:async_hooks'; export const storage = new AsyncLocalStorage(); export const context: ContextAccessor = { current: () => storage.getStore(), }; ``` ```ts // app.ts import { DIContainer } from 'injecute'; import { context } from './context.ts'; import { addHttp } from './http.ts'; import { addOrders } from './orders.ts'; export const createApp = () => new DIContainer() .addInstance('config', loadConfig()) .addInstance('context', context) .addSingleton('port', (config) => config.port, ['config']) .addSingleton('db', (config) => new Database(config.databaseUrl), { dependencies: ['config'], dispose: (db) => db.close(), }) .extend(addHttp) .namespace('Orders', addOrders) .seal(); ``` ```ts // main.ts import { startLifecycle } from 'injecute'; import { createApp } from './app.ts'; const running = await startLifecycle(createApp()); process.once('SIGTERM', () => running.stop().catch((error) => console.error(error)), ); ``` `running.stop()` closes the server, waits for open requests, then closes the database. For a complete entry point with a grace period and a second signal, see [Graceful shutdown in Node.js](../guide/startup-shutdown.md#graceful-shutdown-in-node-js). ## Tests Build the app, replace the database in an isolated fork, and send requests to its Express app with [supertest](https://github.com/ladjs/supertest): ```ts // orders.test.ts import request from 'supertest'; import { expect, it } from 'vitest'; import { createApp } from './app.ts'; it('places an order', async () => { await using test = createApp() .fork({ isolated: true }) .addInstance('db', new FakeDatabase(), { replace: true }); const response = await request(test.get('http')) .post('/orders') .send({ item: 'book' }); expect(response.status).toBe(201); }); ``` The test doesn't start the lifecycle: supertest calls the Express app without the server hook. The fork's `OrderService` and router are built with the fake database, `createApp()`'s are untouched, and `await using` disposes what the test created. See [Testing](../guide/testing.md). --- --- url: https://masyaka.github.io/injecute/frameworks/fastify.md description: >- Use injecute with Fastify — Fastify plugins for HTTP, the container for services, typed without declaration merging, route plugins contributed by modules, request context, graceful shutdown, and tests with inject(). --- # Fastify Fastify has plugins and decorators: `fastify.decorate('db', db)` makes the database reachable from handlers. To type it, you add `declare module 'fastify'` with `db` on every Fastify instance, whether the plugin that decorates it was registered or not. With injecute: * **Typed from the registrations.** No declaration merging: a service's type comes from its factory, and a module that needs a missing service is a compile error. * **Plugins for HTTP, the container for services.** Keep Fastify plugins for what is about HTTP (auth hooks, CORS, rate limits, schemas). Repositories, clients and domain services live in the container, so a queue worker, a script or a test uses them without starting Fastify. * **Modules bring their own routes.** Each feature module contributes a route plugin with its prefix; the HTTP module doesn't import the features. * **Shutdown in the right order.** `server.close()` runs first, waits for open requests and runs your `onClose` hooks; then the database pool closes. * **Tests with `inject()`.** An isolated fork replaces the database for the whole graph, and `server.inject()` sends requests without a port. ## The HTTP module The HTTP module declares an [extension point](../guide/tags.md) for route plugins, and starts the server in a [lifecycle hook](../guide/startup-shutdown.md): ```ts // http.ts import Fastify, { type FastifyPluginAsync } from 'fastify'; import { collect, createTag, lifecycle, type ServiceRegistry } from 'injecute'; import { storage } from './context.ts'; /** A Fastify plugin a module registers under a prefix. */ export interface Routes { readonly prefix: string; readonly plugin: FastifyPluginAsync; } /** The extension point: every module's routes. */ export const routes = createTag('routes').of(); export const addHttp = (c: ServiceRegistry<{ port: number }>) => c // a factory, not the instance: a Fastify instance is thenable .addSingleton( 'createServer', (contributions) => () => { const server = Fastify({ logger: true }); // preValidation runs after the body is parsed: the handlers run in this context server.addHook('preValidation', (request, _reply, done) => { storage.run( { traceId: request.id, tenantId: request.headers['x-tenant-id'] as string | undefined, }, done, ); }); for (const { prefix, plugin } of contributions) server.register(plugin, { prefix }); return server; }, [collect(routes)], ) .addSingleton( lifecycle.start('server'), async (createServer, port) => { const server = createServer(); await server.listen({ port, host: '0.0.0.0' }); return () => server.close(); // stops accepting requests, waits for open ones, runs onClose hooks }, ['createServer', 'port'], ); ``` ::: warning Register a factory, not the Fastify instance A Fastify instance is thenable: `await server` waits for `ready()`. A factory that returns it is treated like an [async factory](../guide/async.md), so `get()` would return a promise instead of the server. Register a function that creates the server, as above; tests call it too. ::: The server is a hook, so `running.stop()` closes it before `dispose()` releases the connections. ## A feature module A module registers its services and contributes a route plugin. The handlers close over `orders`: ```ts // orders.ts import type { ServiceRegistry } from 'injecute'; import { routes, type Routes } from './http.ts'; export const addOrders = (c: ServiceRegistry<{ db: Database }>) => c.addSingleton('orders', OrderService, ['db']).addSingleton( routes('orders'), (orders): Routes => ({ prefix: '/orders', plugin: async (server) => { server.get('/', () => orders.list()); server.post<{ Body: { item: string } }>( '/', { schema: { body: { type: 'object', required: ['item'], properties: { item: { type: 'string' } }, }, }, }, async (request, reply) => reply.code(201).send(await orders.place(request.body.item)), ); }, }), ['orders'], ); ``` Each route plugin is encapsulated by Fastify as usual: hooks and decorators it adds stay in its prefix. ## The composition root ```ts // app.ts import { DIContainer } from 'injecute'; import { storage } from './context.ts'; // export const storage = new AsyncLocalStorage() import { addHttp } from './http.ts'; import { addOrders } from './orders.ts'; export const createApp = () => new DIContainer() .addInstance('config', loadConfig()) .addInstance('context', { current: () => storage.getStore() }) .addSingleton('port', (config) => config.port, ['config']) .addSingleton('db', (config) => new Database(config.databaseUrl), { dependencies: ['config'], dispose: (db) => db.close(), }) .extend(addHttp) .namespace('Orders', addOrders) .seal(); ``` ```ts // main.ts import { startLifecycle } from 'injecute'; import { createApp } from './app.ts'; const running = await startLifecycle(createApp()); process.once('SIGTERM', () => running.stop().catch((error) => console.error(error)), ); ``` For a complete entry point with a grace period and a second signal, see [Graceful shutdown in Node.js](../guide/startup-shutdown.md#graceful-shutdown-in-node-js). Don't call `dispose()` in an `onClose` hook: the lifecycle closes the server first and disposes after it. ## Request context The HTTP module starts the [request context](../guide/request-context.md) in a `preValidation` hook. ::: warning Start the context after the body is parsed For requests with a body, Fastify runs the hooks after parsing from the request stream's `end` event, in a different async context, so a context started in `onRequest` can be lost by the handler. Start it in `preValidation`, or use [`@fastify/request-context`](https://github.com/fastify/fastify-request-context), which re-enters its context there, and read it in the accessor: `{ current: () => requestContext.get('context') }`. ::: Fastify's `request.log` carries the request id, but only where you pass the request. A logger in the container that reads the context works in every service; see [Logging](../guide/request-context.md#logging). To use one pino instance for both, register it in the container and give it to Fastify: `Fastify({ loggerInstance: logger })`. ## Tests Build the app, replace the database in an isolated fork, create a server from the fork and send it requests with `inject()`: ```ts // orders.test.ts import { expect, it } from 'vitest'; import { createApp } from './app.ts'; it('places an order', async () => { await using test = createApp() .fork({ isolated: true }) .addInstance('db', new FakeDatabase(), { replace: true }); const server = test.get('createServer')(); const response = await server.inject({ method: 'POST', url: '/orders', payload: { item: 'book' }, }); expect(response.statusCode).toBe(201); await server.close(); }); ``` The test doesn't start the lifecycle, so nothing listens. The fork's route plugins are built with the fake database, `createApp()`'s are untouched, and `await using` disposes what the test created. See [Testing](../guide/testing.md). ## Plugins or services? | Put it in | When | | --------------------- | ------------------------------------------------------------------------------------------ | | A Fastify plugin | It is about HTTP: authentication hooks, CORS, rate limits, content parsers, shared schemas | | The container | The app uses it: repositories, clients, domain services, the logger, configuration | | A route plugin by tag | A feature module's routes, built with the module's services | --- --- url: https://masyaka.github.io/injecute/frameworks/hono.md description: >- Use injecute with Hono on Node.js, Bun, Deno and Cloudflare Workers — services built once and typed from the registrations, routes contributed by modules, request context, graceful shutdown, and tests with app.request(). --- # Hono Hono runs on every JavaScript runtime, and so does injecute: no runtime dependencies, no decorators, no `reflect-metadata`, no build step. The same modules run on Node.js, Bun, Deno and Cloudflare Workers. Hono's context variables (`c.set()`, `c.var`) carry the values of one request, typed by hand in the app's `Env`. They are a poor place for services: a middleware sets them again on every request, and only handlers can read them. With injecute: * **Services built once, typed from the registrations.** Handlers close over the services they need; a missing service is a compile error. * **Request data reaches every layer.** A middleware starts the request context, and services deep in the graph read it through an accessor. Keep `c.var` for what only handlers use. * **Modules bring their own routes.** Each feature module contributes a Hono sub-app; the HTTP module doesn't import the features. * **Shutdown in the right order** on Node.js, Bun and Deno: the server finishes open requests before the connections close. * **Tests with `app.request()`**, without a server. ## The HTTP module The HTTP module declares an [extension point](../guide/tags.md) for routes, and starts the [request context](../guide/request-context.md) for each request: ```ts // http.ts import { Hono } from 'hono'; import { collect, createTag, type ServiceRegistry } from 'injecute'; import { storage } from './context.ts'; // export const storage = new AsyncLocalStorage() /** A Hono app a module mounts under a path. */ export interface Route { readonly path: string; readonly app: Hono; } /** The extension point: every module's routes. */ export const route = createTag('route').of(); export const addHttp = (c: ServiceRegistry) => c.addSingleton( 'http', (routes) => { const http = new Hono(); http.use((c, next) => storage.run( { traceId: c.req.header('x-request-id') ?? crypto.randomUUID(), tenantId: c.req.header('x-tenant-id'), }, next, ), ); for (const { path, app } of routes) http.route(path, app); return http; }, [collect(route)], ); ``` Hono's [context storage](https://hono.dev/docs/middleware/builtin/context-storage) middleware works too: add `contextStorage()` and read the accessor from it, `{ current: () => tryGetContext()?.var.requestContext }`. ## A feature module A module registers its services and contributes a sub-app. The handlers close over `orders`: ```ts // orders.ts import { Hono } from 'hono'; import type { ServiceRegistry } from 'injecute'; import { route, type Route } from './http.ts'; export const addOrders = (c: ServiceRegistry<{ db: Database }>) => c.addSingleton('orders', OrderService, ['db']).addSingleton( route('orders'), (orders): Route => ({ path: '/orders', app: new Hono() .get('/', async (c) => c.json(await orders.list())) .post('/', async (c) => { const { item } = await c.req.json<{ item: string }>(); return c.json(await orders.place(item), 201); }), }), ['orders'], ); ``` ## The application, on every runtime The application is a module. It needs a database and a context accessor, and each runtime's entry point provides them: ```ts // app.ts import type { ServiceRegistry } from 'injecute'; import { addHttp } from './http.ts'; import { addOrders } from './orders.ts'; export const addApp = ( c: ServiceRegistry<{ db: Database; context: ContextAccessor; }>, ) => c.extend(addHttp).namespace('Orders', addOrders); ``` ## Node.js, Bun and Deno The entry point registers the database and the context, the application, and the server as a [lifecycle hook](../guide/startup-shutdown.md): ::: code-group ```ts [Node.js] // server.ts import { once } from 'node:events'; import { serve } from '@hono/node-server'; import { DIContainer, lifecycle, startLifecycle } from 'injecute'; import { addApp } from './app.ts'; import { storage } from './context.ts'; const app = new DIContainer() .addInstance('config', loadConfig()) .addInstance('context', { current: () => storage.getStore() }) .addSingleton('db', (config) => new Database(config.databaseUrl), { dependencies: ['config'], dispose: (db) => db.close(), }) .extend(addApp) .addSingleton( lifecycle.start('server'), async (http, config) => { const server = serve({ fetch: http.fetch, port: config.port }); await once(server, 'listening'); // rejects when the port is taken return () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); }, ['http', 'config'], ) .seal(); const running = await startLifecycle(app); process.once('SIGTERM', () => running.stop().catch((error) => console.error(error)), ); ``` ```ts [Bun] // the hook in server.ts .addSingleton( lifecycle.start('server'), (http, config) => { const server = Bun.serve({ fetch: http.fetch, port: config.port }); return () => server.stop(); // waits for open requests }, ['http', 'config'], ) ``` ```ts [Deno] // the hook in server.ts .addSingleton( lifecycle.start('server'), (http, config) => { const server = Deno.serve({ port: config.port }, http.fetch); return () => server.shutdown(); // waits for open requests }, ['http', 'config'], ) ``` ::: `running.stop()` stops the server, then `dispose()` closes the database. See [Graceful shutdown in Node.js](../guide/startup-shutdown.md#graceful-shutdown-in-node-js) for a grace period and a second signal. ## Cloudflare Workers A Worker has no server to start: it exports `fetch`. Build the root once per isolate, with the bindings from `cloudflare:workers`: ```ts // worker.ts import { env } from 'cloudflare:workers'; import { DIContainer } from 'injecute'; import { addApp } from './app.ts'; import { storage } from './context.ts'; // the storage the HTTP module runs each request in const app = new DIContainer() .addInstance('context', { current: () => storage.getStore() }) .addInstance('db', createDatabase(env.DB)) // a D1 binding: safe to share between requests .extend(addApp) .seal(); export default { fetch: (request: Request, env: Env, ctx: ExecutionContext) => app.get('http').fetch(request, env, ctx), }; ``` * **A connection belongs to one request.** Workers don't let a request use a socket or a stream that another request opened ("Cannot perform I/O on behalf of a different request"). Bindings (KV, D1, R2, queues) are safe in singletons; a database client that holds a connection is not. Register a factory and connect per request with `await using` ([a unit of work](../guide/request-state.md#a-factory-and-using-a-unit-of-work)), or keep [one instance per request](../guide/request-state.md#one-instance-per-request). * **No shutdown.** An isolate is evicted without notice, so there is no lifecycle to stop and the container is never disposed. Hand work that outlives the response to `ctx.waitUntil()`. * **`AsyncLocalStorage` needs the `nodejs_compat` compatibility flag.** ## Tests Build the application on a test root with a fake database, and send requests with `app.request()`: ```ts // orders.test.ts import { DIContainer } from 'injecute'; import { expect, it } from 'vitest'; import { addApp } from './app.ts'; it('places an order', async () => { await using test = new DIContainer() .addInstance('db', new FakeDatabase()) .addInstance('context', { current: () => undefined }) .extend(addApp); const response = await test.get('http').request('/orders', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ item: 'book' }), }); expect(response.status).toBe(201); }); ``` No server runs, and the test doesn't touch the runtime: the same test covers the Node.js, Bun, Deno and Workers builds. To replace a service of an entry point's root instead, use an isolated fork; see [Testing](../guide/testing.md). --- --- url: https://masyaka.github.io/injecute/frameworks/nextjs.md description: >- Use injecute with the Next.js App Router — connections that survive hot reloads, typed services in server components, route handlers and server actions, graceful shutdown, request context, and tests without Next.js. --- # Next.js Next.js has no place for services. Apps keep a `globalThis` singleton per client (the pattern Prisma recommends, repeated for Redis and every SDK), import concrete clients in components, and replace them in tests with module mocks. With injecute: * **Connections that survive hot reloads.** One platform container per server process holds the connections. Editing a file doesn't open a new pool. * **Your edits take effect.** The application's services are rebuilt from the new code after every edit, on top of the same connections. * **Typed services everywhere on the server.** Server components, route handlers and server actions resolve the same services with `app.get()`, typed from the registrations. * **A clean shutdown.** The platform closes its connections when the server stops. * **The same modules outside Next.js.** A queue worker or a cron script uses the application's modules with its own entry point. * **Tests without Next.js.** The services don't import Next.js; tests build them on an isolated fork. ## Two layers: the platform and the app Next.js evaluates a module again after every edit in development, and can bundle a module more than once (for example, into `instrumentation.ts` and into the routes). A container created at the top of a module would open new connections every time. Split it in two: * the **platform**: configuration and connections, created once per process; * the **app**: the application's services, a [fork](../guide/containers.md#forks) of the platform. A fork shares the platform's singletons, so the services rebuilt after an edit use the same connections. ```ts // lib/services.ts — no Next.js imports: tests and workers use it too import { DIContainer, type ServiceRegistry } from 'injecute'; /** Connections and clients: one set per server process. */ export const createPlatform = () => new DIContainer() .addInstance('config', loadConfig()) .addSingleton('db', (config) => new Database(config.databaseUrl), { dependencies: ['config'], dispose: (db) => db.close(), }) .seal(); const addOrders = (c: ServiceRegistry<{ db: Database }>) => c.addSingleton('orders', OrderService, ['db']); /** The application's services, on top of the platform. */ export const addApp = (c: ServiceRegistry<{ db: Database }>) => c.namespace('Orders', addOrders); ``` ```ts // lib/platform.ts import { createPlatform } from './services'; type Platform = ReturnType; const shared = globalThis as typeof globalThis & { __platform?: Platform }; // Next.js evaluates this module again on every hot reload, and can bundle it more than once // (instrumentation.ts and the routes): keep one platform per process. export const platform = (shared.__platform ??= createPlatform()); ``` ```ts // lib/app.ts import 'server-only'; import { platform } from './platform'; import { addApp } from './services'; // Built again from the new code after every edit; the connections stay in the platform. export const app = platform.fork().extend(addApp).seal(); ``` `import 'server-only'` makes importing the app from a Client Component a build error. Changes to `createPlatform()` itself take effect when the dev server restarts. ## Server components, route handlers and server actions These are the entry points of a Next.js app: they resolve services, like `main.ts` does elsewhere. Everything below them gets its dependencies injected, and never imports `app`. ::: code-group ```tsx [app/orders/page.tsx] import { app } from '@/lib/app'; import { placeOrder } from './actions'; export default async function OrdersPage() { const orders = await app.get('Orders.orders').list(); return (
    {orders.map((order) => (
  • {order.item}
  • ))}
); } ``` ```ts [app/orders/actions.ts] 'use server'; import { revalidatePath } from 'next/cache'; import { app } from '@/lib/app'; export async function placeOrder(form: FormData) { await app.get('Orders.orders').place(String(form.get('item'))); revalidatePath('/orders'); } ``` ```ts [app/orders/route.ts] import { app } from '@/lib/app'; export async function GET() { return Response.json(await app.get('Orders.orders').list()); } ``` ::: The platform holds Node.js connections: use it from the Node.js runtime (the default for pages, route handlers and server actions), not from the Edge runtime. ## Shutdown Start the platform's [lifecycle](../guide/startup-shutdown.md) in `instrumentation.ts`, and stop it on `SIGTERM`. Next.js handles `SIGTERM` itself and exits the process without waiting for your handlers; set `NEXT_MANUAL_SIG_HANDLE=true` in the server's environment to handle it yourself: ```ts // instrumentation.ts export async function register() { if (process.env.NEXT_RUNTIME !== 'nodejs') return; const { startLifecycle } = await import('injecute'); const { platform } = await import('./lib/platform'); const running = await startLifecycle(platform); // Next.js leaves SIGTERM to you when NEXT_MANUAL_SIG_HANDLE=true process.once('SIGTERM', () => running.stop().then( () => process.exit(0), (error) => { console.error(error); process.exit(1); }, ), ); } ``` Hooks registered in the platform run at startup, for example to check the database connection in `lifecycle.init`. `running.stop()` undoes them, then disposes the platform and closes the connections. Run queue consumers and schedulers in a worker process of their own, with the same `lib/services.ts` and `startLifecycle()`: Next.js can restart or scale its servers at any time, and requests and background work then don't compete for one process. ## Request context Pages, route handlers and server actions read the request with `headers()` and `cookies()`. By default, pass what services need as arguments: `orders.list({ tenantId })`. When services deeper in the graph need it (a logger with the trace id, a repository filtered by tenant), use a [context accessor](../guide/request-context.md), and run route handlers and server actions in the context: ```ts // lib/context.ts import 'server-only'; import { AsyncLocalStorage } from 'node:async_hooks'; import { headers } from 'next/headers'; export const storage = new AsyncLocalStorage(); /** Runs a route handler or a server action in the request's context. */ export const withContext = (handler: (...args: A) => Promise) => async (...args: A): Promise => { const h = await headers(); const context: RequestContext = { traceId: h.get('x-request-id') ?? crypto.randomUUID(), tenantId: h.get('x-tenant-id') ?? undefined, }; return storage.run(context, () => handler(...args)); }; ``` Register `{ current: () => storage.getStore() }` as `context` in `createPlatform()`, and wrap: `export const POST = withContext(async (request: Request) => …)`. Server components render their children outside such a wrapper, so pass arguments there. ## Tests The services don't import Next.js, so tests build them on an isolated fork of a new platform: ```ts // lib/services.test.ts import { expect, it } from 'vitest'; import { addApp, createPlatform } from './services'; it('lists orders', async () => { await using test = createPlatform() .fork({ isolated: true }) .addInstance('db', new FakeDatabase(), { replace: true }) .extend(addApp); expect(await test.get('Orders.orders').list()).toEqual([]); }); ``` Keep pages, route handlers and server actions thin: they resolve a service and call it, and the tests cover the service. See [Testing](../guide/testing.md). --- --- url: https://masyaka.github.io/injecute/frameworks/graphql.md description: >- Use injecute with GraphQL Yoga or Apollo Server — a DataLoader per request without a container per request, resolvers that close over typed services, a schema assembled from modules, and tests without a server. --- # GraphQL A GraphQL server needs a few things per request (DataLoaders above all) and many things per app (repositories, clients, the schema). Apps often build a container per request to get the first, or put every service in the GraphQL context to reach the second. With injecute: * **A DataLoader per request, without a container per request.** A singleton factory, wired by the container, creates the loaders for each request's GraphQL context. * **Resolvers close over typed services.** The context carries only per-request values; resolvers don't call `context.container.get()`. * **A schema assembled from modules.** Each feature module contributes its types and resolvers to an extension point. * **Tests without a server**, with `yoga.fetch()` on an isolated fork: replace a repository and count the queries the loaders batch. The examples use [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server); see [Apollo Server](#apollo-server) for the differences. ## The GraphQL module ```ts // graphql.ts import DataLoader from 'dataloader'; import { createSchema, createYoga } from 'graphql-yoga'; import { collect, createTag, type ServiceRegistry } from 'injecute'; /** What one request's resolvers share. */ export interface GraphQLContext { readonly loaders: Loaders; } export interface Loaders { readonly user: DataLoader; } /** A part of the schema: a module's types and their resolvers. */ export interface SchemaPart { readonly typeDefs: string; readonly resolvers: Record>; } export const schema = createTag('schema').of(); export const addGraphQL = (c: ServiceRegistry<{ users: UserRepository }>) => c // a singleton factory: new loaders for every request .addSingleton( 'loaders', (users) => ({ create: (): Loaders => ({ user: new DataLoader((ids) => users.findByIds(ids)), }), }), ['users'], ) .addSingleton( 'graphql', (parts, loaders) => createYoga<{}, GraphQLContext>({ schema: createSchema({ typeDefs: ['type Query', ...parts.map((p) => p.typeDefs)], resolvers: parts.map((p) => p.resolvers), }), context: () => ({ loaders: loaders.create() }), }), [collect(schema), 'loaders'], ); ``` * `loaders` is the [factory pattern](../guide/request-state.md#a-factory-and-using-a-unit-of-work): a singleton with its dependencies from the container, which creates new loaders when Yoga builds a request's context. The loaders are garbage-collected with the context. * `collect(schema)` receives every module's part of the schema, also from namespaces. ## A feature module A module contributes its types and resolvers. Resolvers close over the module's services, and take per-request values from the context: ```ts // orders.ts import type { ServiceRegistry } from 'injecute'; import { schema, type GraphQLContext, type SchemaPart } from './graphql.ts'; export const addOrders = (c: ServiceRegistry) => c.addSingleton('orders', OrderService).addSingleton( schema('orders'), (orders): SchemaPart => ({ typeDefs: /* GraphQL */ ` type User { id: ID! name: String! } type Order { id: ID! item: String! customer: User } extend type Query { orders: [Order!]! } `, resolvers: { Query: { orders: () => orders.list() }, Order: { customer: ( order: Order, _args: unknown, { loaders }: GraphQLContext, ) => loaders.user.load(order.customerId), }, }, }), ['orders'], ); ``` ## The composition root Yoga is a request handler for Node.js's `http` server (and for Bun, Deno and Workers, like [Hono](./hono.md)). The server is a [lifecycle hook](../guide/startup-shutdown.md): ```ts // app.ts import { once } from 'node:events'; import { createServer } from 'node:http'; import { DIContainer, lifecycle } from 'injecute'; import { addGraphQL } from './graphql.ts'; import { addOrders } from './orders.ts'; export const createApp = () => new DIContainer() .addInstance('port', 4000) .addSingleton('users', UserRepository) .extend(addGraphQL) .namespace('Orders', addOrders) .addSingleton( lifecycle.start('server'), async (graphql, port) => { const server = createServer(graphql); server.listen(port); await once(server, 'listening'); // rejects when the port is taken return () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); }, ['graphql', 'port'], ) .seal(); ``` Start it with `const running = await startLifecycle(createApp())`, and stop it with `running.stop()`; see [Graceful shutdown in Node.js](../guide/startup-shutdown.md#graceful-shutdown-in-node-js). ## Loaders below the resolvers When a service the resolvers call needs a loader (a `PricingService` that loads products), don't thread the GraphQL context through it. Run each request in a [request context](../guide/request-context.md), and keep [one instance per request](../guide/request-state.md#one-instance-per-request) in a singleton: the service gets the loader for the current request, and stays a singleton. ## Tests `yoga.fetch()` executes a query without a server. Replace the repository in an isolated fork, and check that the loader batched the queries: ```ts // orders.test.ts import { expect, it } from 'vitest'; import { createApp } from './app.ts'; it('loads the customers of all orders in one query', async () => { const users = new CountingUserRepository(); await using test = createApp() .fork({ isolated: true }) .addInstance('users', users, { replace: true }); const response = await test.get('graphql').fetch('http://test/graphql', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: '{ orders { item customer { name } } }' }), }); expect(response.status).toBe(200); expect(users.queries).toBe(1); }); ``` ## Apollo Server With [Apollo Server](https://www.apollographql.com/docs/apollo-server), the same parts and loaders go to `ApolloServer` and the standalone server's `context`, and the server is the hook: ```ts import { ApolloServer } from '@apollo/server'; import { startStandaloneServer } from '@apollo/server/standalone'; c.addSingleton( lifecycle.start('graphql'), async (parts, loaders, port) => { const server = new ApolloServer({ typeDefs: ['type Query', ...parts.map((p) => p.typeDefs)], resolvers: parts.map((p) => p.resolvers), }); await startStandaloneServer(server, { listen: { port }, context: async () => ({ loaders: loaders.create() }), }); return () => server.stop(); }, [collect(schema), 'loaders', 'port'], ); ``` With an Express or Fastify integration, register Apollo's middleware in the [Express](./express.md) or [Fastify](./fastify.md) HTTP module instead, and call `server.stop()` in its undo. --- --- url: https://masyaka.github.io/injecute/frameworks/ui-frameworks.md description: >- When a React, Vue or Solid app needs a container and when imports are enough — one core per widget, request or test, a client-side service graph, components that resolve services through the framework's context, TanStack Query, state management libraries, and what the container leaves to the framework. --- # React, Vue and Solid React Context, Vue's `provide`/`inject` and Solid's context pass values down the component tree, and ES module imports already give an app an explicit, typed graph of its modules. What they don't give is more than one instance of that graph: the API client, the session and the stores are module-level singletons, one per page. injecute builds the services behind the UI as a container you can create more than once, and replace parts of. The framework passes one value down, the core, and components resolve what they need from it. With injecute: * **One core per mount or request.** Widgets, micro-frontends, server-rendered requests and tests each get their own container (API client, session, query cache), and `dispose()` releases what it opened. * **A service graph wired in one place.** A session that refreshes tokens, a realtime connection that updates the cache, an offline queue: registered with their dependencies, created when first used, closed dependents first. * **Implementations chosen at startup.** A demo mode with an in-memory API, another storage on another platform, a fake in a story: the composition root picks one, or an isolated fork replaces it for the whole graph. * **Components resolve services but can't rewire them.** Components get a read-only [`ServiceProvider`](../concepts/roles.md) through the framework's context, and resolve typed services with `useService()`. * **The core doesn't import the UI framework.** The same core runs under React, Vue or Solid, and its tests need no DOM. ## When to use it Most single-page apps don't need a container. React with TanStack Query (or Vue Query, Solid Query) and [MSW](https://mswjs.io) for tests is a complete setup for an app that fetches and shows data: imports give a typed graph, the `QueryClient` caches server state, and MSW replaces the network in tests without a seam in the code. A container adds a composition root and a `useService()` hook to that app, and nothing it needs. Use injecute when the app needs what imports can't give: * **More than one instance of its services** on a page or in a process: embedded widgets, micro-frontends, tenants side by side, or server rendering with per-request services beyond the `QueryClient` (the user's session, the tenant, a logger). * **A client-side service graph with lifetimes:** a session that refreshes tokens, a WebSocket that writes into the query cache, an offline mutation queue; services that depend on each other and are opened and closed in order. * **Implementations chosen at startup:** a demo mode, web and React Native, a fake in a story. * **One core for several surfaces** (web, mobile, a worker, Node.js scripts), or a backend that already uses injecute. If none of these applies, keep imports and the framework's context. The rest of this page assumes one does. This page follows [Keep the web framework out of the core](../guide/app-structure.md#keep-the-web-framework-out-of-the-core), with the UI in the place of the web layer. ## The core The core is plain TypeScript: an API client, and stores that hold what the UI shows. A store exposes `getSnapshot()` and `subscribe()`, the shape React's `useSyncExternalStore` takes. Vue and Solid adapt to it in a few lines. The examples use a hand-written store to stay free of libraries; for Zustand, Redux, Jotai or Pinia, see [State management](#state-management). ```ts // src/core/store.ts /** A value components can read and subscribe to. */ export interface ExternalStore { getSnapshot(): T; subscribe(listener: () => void): () => void; } ``` ```ts // src/core/orders/orders-store.ts: no UI framework, no injecute import type { ApiClient, Order } from '../api'; import type { ExternalStore } from '../store'; export class OrdersStore implements ExternalStore { #orders: readonly Order[] = []; #listeners = new Set<() => void>(); constructor(private readonly api: ApiClient) {} getSnapshot = () => this.#orders; subscribe = (listener: () => void) => { this.#listeners.add(listener); return () => { this.#listeners.delete(listener); }; }; async load() { this.#set(await this.api.get('/orders')); } async place(item: string) { const order = await this.api.post('/orders', { item }); this.#set([...this.#orders, order]); } #set(orders: readonly Order[]) { this.#orders = orders; for (const listener of this.#listeners) listener(); } } ``` ```ts // src/core/container.ts: no UI framework imports import { DIContainer, type ContainerServices, type ServiceRegistry, } from 'injecute'; import { ApiClient } from './api'; import { OrdersStore } from './orders/orders-store'; export interface Config { apiUrl: string; } const addOrders = (c: ServiceRegistry<{ api: ApiClient }>) => c.addSingleton('store', OrdersStore, ['api']); export const createCore = (config: Config) => new DIContainer() .addInstance('config', config) .addSingleton('api', (config) => new ApiClient(config.apiUrl), ['config']) .namespace('Orders', addOrders) .seal(); export type Core = ReturnType; export type CoreServices = ContainerServices; ``` `createCore()` returns a new container on every call. Don't create the core at the top of a module: tests, widgets and server rendering each need their own. ## Giving components the services One file per app connects the core to the framework: the context that carries the services, `useService()` to resolve one, and `useStore()` to follow a store. ::: code-group ```tsx [React] // src/ui/services.tsx (React 19) import { createContext, use, useSyncExternalStore } from 'react'; import type { ServiceProvider } from 'injecute'; import type { CoreServices } from '../core/container'; import type { ExternalStore } from '../core/store'; const Services = createContext | null>(null); /** Gives the components below it the core's services. */ export const ServicesProvider = Services; /** Resolves a service of the core. */ export function useService(key: K) { const services = use(Services); if (!services) throw new Error('useService() is used outside '); return services.get(key); } /** Re-renders the component when the store changes. */ export function useStore(store: ExternalStore): T { return useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot, ); } ``` ```ts [Vue] // src/ui/services.ts import { inject, onScopeDispose, shallowRef, type InjectionKey, type ShallowRef, } from 'vue'; import type { ServiceProvider } from 'injecute'; import type { CoreServices } from '../core/container'; import type { ExternalStore } from '../core/store'; export const servicesKey: InjectionKey> = Symbol('services'); /** Resolves a service of the core. */ export function useService(key: K) { const services = inject(servicesKey); if (!services) throw new Error( 'useService() is used outside an app that provides the services', ); return services.get(key); } /** A ref that follows the store. */ export function useStore(store: ExternalStore): Readonly> { const state = shallowRef(store.getSnapshot()); onScopeDispose(store.subscribe(() => (state.value = store.getSnapshot()))); return state; } ``` ```ts [Solid] // src/ui/services.ts import { createContext, createSignal, onCleanup, useContext, type Accessor, } from 'solid-js'; import type { ServiceProvider } from 'injecute'; import type { CoreServices } from '../core/container'; import type { ExternalStore } from '../core/store'; const Services = createContext>(); /** Gives the components below it the core's services. */ export const ServicesProvider = Services.Provider; /** Resolves a service of the core. */ export function useService(key: K) { const services = useContext(Services); if (!services) throw new Error('useService() is used outside '); return services.get(key); } /** A signal that follows the store. */ export function useStore(store: ExternalStore): Accessor { const [state, setState] = createSignal(store.getSnapshot()); onCleanup(store.subscribe(() => setState(() => store.getSnapshot()))); return state; } ``` ::: On React 18, render `` and read it with `useContext()`. `useService('Orders.store')` is typed as `OrdersStore`, and a wrong key is a compile error. The context holds a `ServiceProvider`, not the container, so components can't register services, fork or dispose. ## Components Components resolve the services they use and render the store's state: ::: code-group ```tsx [React] // src/ui/orders-page.tsx import { useEffect } from 'react'; import { useService, useStore } from './services'; export function OrdersPage() { const store = useService('Orders.store'); const orders = useStore(store); useEffect(() => { store.load().catch(console.error); }, [store]); return (
    {orders.map((order) => (
  • {order.item}
  • ))}
); } ``` ```vue [Vue] ``` ```tsx [Solid] // src/ui/OrdersPage.tsx import { For, onMount } from 'solid-js'; import { useService, useStore } from './services'; export function OrdersPage() { const store = useService('Orders.store'); const orders = useStore(store); onMount(() => store.load().catch(console.error)); return (
    {(order) =>
  • {order.item}
  • }
); } ``` ::: ## The entry point The entry point creates the core and gives it to the UI: ::: code-group ```tsx [React] // src/main.tsx import { createRoot } from 'react-dom/client'; import { createCore } from './core/container'; import { App } from './ui/app'; import { ServicesProvider } from './ui/services'; const core = createCore({ apiUrl: import.meta.env.VITE_API_URL }); createRoot(document.getElementById('root')!).render( , ); ``` ```ts [Vue] // src/main.ts import { createApp } from 'vue'; import { createCore } from './core/container'; import App from './ui/App.vue'; import { servicesKey } from './ui/services'; const core = createCore({ apiUrl: import.meta.env.VITE_API_URL }); createApp(App).provide(servicesKey, core).mount('#app'); ``` ```tsx [Solid] // src/index.tsx import { render } from 'solid-js/web'; import { createCore } from './core/container'; import { App } from './ui/App'; import { ServicesProvider } from './ui/services'; const core = createCore({ apiUrl: import.meta.env.VITE_API_URL }); render( () => ( ), document.getElementById('root')!, ); ``` ::: An app that lives as long as the page doesn't need to dispose its core: the browser releases everything when the page closes. Dispose it when the UI is removed and the page stays; see [Widgets](#widgets-one-core-per-mount). ::: tip Error hints in the browser Production builds leave the "how to fix it" hints out of injecute's error messages; development builds with Vite or webpack keep them. With another bundler, or to have them in any build, import `injecute/hints` in the entry point: see [Hints in browser bundles](../errors/index.md#hints-in-browser-bundles). ::: ## Tests Render the component with an isolated fork of the core that replaces a service. The fork creates everything that depends on it again, on top of the fake: ::: code-group ```tsx [React] // src/ui/orders-page.test.tsx import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, expect, it } from 'vitest'; import { createCore } from '../core/container'; import { OrdersPage } from './orders-page'; import { ServicesProvider } from './services'; afterEach(cleanup); it('lists the orders', async () => { await using core = createCore({ apiUrl: 'http://test' }) .fork({ isolated: true }) .addInstance('api', new FakeApi([{ id: '1', item: 'Tea' }]), { replace: true, }); render( , ); expect(await screen.findByText('Tea')).toBeTruthy(); }); ``` ```ts [Vue] // src/ui/OrdersPage.test.ts import { cleanup, render, screen } from '@testing-library/vue'; import { afterEach, expect, it } from 'vitest'; import { createCore } from '../core/container'; import OrdersPage from './OrdersPage.vue'; import { servicesKey } from './services'; afterEach(cleanup); it('lists the orders', async () => { await using core = createCore({ apiUrl: 'http://test' }) .fork({ isolated: true }) .addInstance('api', new FakeApi([{ id: '1', item: 'Tea' }]), { replace: true, }); render(OrdersPage, { global: { provide: { [servicesKey as symbol]: core } }, }); expect(await screen.findByText('Tea')).toBeTruthy(); }); ``` ::: The example replaces the API client to stay short. If your tests mock the network with MSW, keep doing so, and use a fork for what isn't HTTP: the session, a realtime connection, storage, the clock. Solid tests do the same with `@solidjs/testing-library`. In Storybook, a decorator wraps each story in a `ServicesProvider` with such a fork. Test the stores themselves without a component: `core.get('Orders.store')` on the fork. See [Testing](../guide/testing.md). ## Widgets: one core per mount A widget embedded in another page, or a micro-frontend, can be mounted more than once and removed while the page stays. Create a core per mount, and dispose it after the UI is removed: ```tsx // src/widget.tsx import { createRoot } from 'react-dom/client'; import { createCore, type Config } from './core/container'; import { OrdersPage } from './ui/orders-page'; import { ServicesProvider } from './ui/services'; /** Mounts the orders widget into `element`. Returns a function that removes it. */ export function mountOrders(element: HTMLElement, config: Config) { const core = createCore(config); const root = createRoot(element); root.render( , ); return async () => { root.unmount(); await core.dispose(); }; } ``` In Vue, call `core.dispose()` in `app.onUnmount()`; in Solid, after the function `render()` returns. `dispose()` releases the singletons the core created, dependents first: give a service that opens a WebSocket or an `EventSource` a `dispose` option or a `[Symbol.dispose]()` method that closes it. See [Lifecycle and dispose](../guide/lifecycle.md). ## Server-side rendering On the server, a core created at the top of a module is shared by every request: one user's orders would render for the next. Create the core per request, and dispose it after rendering: ```tsx // src/entry-server.tsx import { renderToString } from 'react-dom/server'; import { createCore, type Config } from './core/container'; import { OrdersPage } from './ui/orders-page'; import { ServicesProvider } from './ui/services'; export async function render(config: Config) { // One core per request: its stores hold this user's data only. await using core = createCore(config); const store = core.get('Orders.store'); await store.load(); const html = renderToString( , ); return { html, state: { orders: store.getSnapshot() } }; } ``` Send `state` with the HTML, and give it to the client's stores before hydrating, so both render the same thing. With TanStack Query, `dehydrate()` and `HydrationBoundary` do this; see [TanStack Query](#tanstack-query). In Nuxt, SolidStart or a Next.js app, create the core where the framework creates per-request state, never at the top of a module. For Next.js server code (server components, route handlers, server actions), see [Next.js](./nextjs.md). ## TanStack Query With [TanStack Query](https://tanstack.com/query) (React Query, Vue Query, Solid Query), the query cache holds server state, and replaces stores like `OrdersStore` for data that comes from the API. Keep `useStore()` for client state. The core owns the `QueryClient`: TanStack Query wants one per app, a new one per test and per server request, which are the lifetimes of the core. ```ts // src/core/container.ts import { QueryClient } from '@tanstack/query-core'; export interface Config { apiUrl: string; retry?: boolean; } export const createCore = (config: Config) => new DIContainer() .addInstance('config', config) .addSingleton('api', (config) => new ApiClient(config.apiUrl), ['config']) .addSingleton( 'queryClient', (config) => new QueryClient({ defaultOptions: { queries: { retry: config.retry ?? 3 } }, }), { dependencies: ['config'], dispose: (client) => client.clear() }, ) .namespace('Orders', addOrders) .seal(); ``` A service keeps the query keys and functions of a feature together, with its dependencies injected: ```ts // src/core/orders/orders-queries.ts import type { QueryClient } from '@tanstack/query-core'; import type { ApiClient, Order } from '../api'; export class OrdersQueries { constructor( private readonly api: ApiClient, private readonly queryClient: QueryClient, ) {} list() { return { queryKey: ['orders'] as const, queryFn: () => this.api.get('/orders'), }; } place() { return { mutationFn: (item: string) => this.api.post('/orders', { item }), onSuccess: () => this.queryClient.invalidateQueries({ queryKey: ['orders'] }), }; } } // src/core/container.ts const addOrders = ( c: ServiceRegistry<{ api: ApiClient; queryClient: QueryClient }>, ) => c.addSingleton('queries', OrdersQueries, ['api', 'queryClient']); ``` The UI gives the core's client to TanStack Query, and components pass the options to its hooks: ```tsx // src/ui/root.tsx import { QueryClientProvider } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import type { Core } from '../core/container'; import { ServicesProvider } from './services'; export function Root({ core, children }: { core: Core; children: ReactNode }) { return ( {children} ); } // src/ui/orders-page.tsx export function OrdersPage() { const queries = useService('Orders.queries'); const { data: orders = [] } = useQuery(queries.list()); const place = useMutation(queries.place()); // … } ``` `useQuery()` infers `data` as `Order[]` from the plain options. They keep the core free of React; in a React-only app, wrap them in `queryOptions()` from `@tanstack/react-query` to type `getQueryData()` too. * **Tests.** An isolated fork creates a new `QueryClient`, so each test starts with an empty cache. Create the core with `retry: false` in tests. * **Server-side rendering.** The core per request brings a `QueryClient` per request. Prefetch with the same options, and send the cache to the client: ```tsx await using core = createCore(config); const queryClient = core.get('queryClient'); await queryClient.prefetchQuery(core.get('Orders.queries').list()); const state = dehydrate(queryClient); // render inside ``` * **Vue and Solid.** Register the `QueryClient` from `@tanstack/vue-query` or `@tanstack/solid-query`: they extend the core client for their framework, and the core then imports that package. Give it to `app.use(VueQueryPlugin, { queryClient })` or Solid's ``. React Query's `QueryClient` is the core one. * **Inject, don't look up.** Don't call `core.get()` inside a `queryFn`: give the service its dependencies. The cache belongs to TanStack Query: register the client, not query results. ## State management A container and a state library do different jobs. The container decides how many stores there are and what they depend on; the library holds the state and makes it reactive. Register stores, not the values in them. | State | Where it lives | The container's part | | -------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------- | | Server data: orders, users | The [TanStack Query](#tanstack-query) cache | Creates the `QueryClient` and the API client | | Client state: the session, a cart, preferences, a wizard | A store: Zustand, Redux, Jotai, Pinia, MobX, XState | Creates the store and injects its services | | The state of one component: open, hovered, an input | `useState`, `ref`, `createSignal` | None | ### A store per core Create the store in a factory, and register the factory as a singleton of the core. Each core (a widget, a server request, a test) then has its own store, built on the services of that core: ::: code-group ```ts [Zustand] // src/core/session-store.ts import { createStore } from 'zustand/vanilla'; import type { ApiClient, User } from './api'; interface SessionState { user: User | null; signIn(email: string): Promise; signOut(): void; } export const createSessionStore = (api: ApiClient) => createStore()((set) => ({ user: null, async signIn(email) { set({ user: await api.post('/session', { email }) }); }, signOut() { set({ user: null }); }, })); // src/core/container.ts // .addSingleton('session', createSessionStore, ['api']) // a component: Zustand's hook takes the store // const name = useStore(useService('session'), (s) => s.user?.name); ``` ```ts [Redux Toolkit] // src/core/cart-store.ts import { configureStore, createAsyncThunk, createSlice, type PayloadAction, } from '@reduxjs/toolkit'; import type { ApiClient, Order } from './api'; interface CartItem { id: string; quantity: number; } export const checkout = createAsyncThunk< Order, void, { state: { cart: CartItem[] }; extra: { api: ApiClient } } >('cart/checkout', (_, { getState, extra }) => extra.api.post('/orders', { items: getState().cart }), ); const cart = createSlice({ name: 'cart', initialState: [] as CartItem[], reducers: { add: (state, action: PayloadAction) => { state.push({ id: action.payload, quantity: 1 }); }, }, extraReducers: (builder) => { builder.addCase(checkout.fulfilled, () => []); }, }); export const { add } = cart.actions; export const createCartStore = (api: ApiClient) => configureStore({ reducer: { cart: cart.reducer }, // Services reach thunks as their extra argument, not through the state middleware: (getDefault) => getDefault({ thunk: { extraArgument: { api } } }), }); // src/core/container.ts // .addSingleton('cart', createCartStore, ['api']) // the root: … ``` ```ts [Jotai] // src/core/session-atoms.ts import { atom, createStore } from 'jotai/vanilla'; import type { ApiClient, User } from './api'; /** Set once per store, by createAtomStore(). */ export const apiAtom = atom(null); export const userAtom = atom(null); export const signInAtom = atom(null, async (get, set, email: string) => { set(userAtom, await get(apiAtom)!.post('/session', { email })); }); export const createAtomStore = (api: ApiClient) => { const store = createStore(); store.set(apiAtom, api); return store; }; // src/core/container.ts // .addSingleton('atoms', createAtomStore, ['api']) // the root: … ``` ::: The stores import their library, but no UI framework: `zustand/vanilla`, Redux Toolkit and `jotai/vanilla` don't import React. A MobX class is registered like any other class, with its dependencies; an XState actor is created by a factory, with `dispose: (actor) => actor.stop()`. What you get: * **Tests start with fresh stores.** Each test's core, or isolated fork, builds the stores again on its fakes. No helper resets module-level stores between tests. * **A store per server request,** so one user's state never renders for the next. * **A store per widget,** so two widgets on one page don't share a cart. ### Stores the library owns: Pinia Pinia defines stores once, and creates them per Pinia instance. Create a Pinia per Vue app (and per server request), and give setup stores the services with `inject()`: ```ts // src/ui/stores/session.ts import { defineStore } from 'pinia'; import { inject, ref } from 'vue'; import type { User } from '../../core/api'; import { servicesKey } from '../services'; export const useSessionStore = defineStore('session', () => { const api = inject(servicesKey)!.get('api'); const user = ref(null); async function signIn(email: string) { user.value = await api.post('/session', { email }); } return { user, signIn }; }); // src/main.ts // createApp(App).use(createPinia()).provide(servicesKey, core).mount('#app'); ``` The store takes the services of the app that uses it, so a test that provides a fork gets a store built on its fakes. Keep the services in local variables, as `api` is here, not in the returned state. ### Pitfalls * **Stores at the top of a module.** `create()` from `zustand`, Jotai's default store (atoms used without a `Provider`) and a Redux store exported from a module are one per page. Widgets share them, tests have to reset them, and in server rendering one request's state renders for the next. * **Server data copied into a store.** A slice that holds the orders goes stale when TanStack Query refetches them. Keep server data in the query cache, and client state in stores. * **Values registered instead of stores.** `addInstance('currentUser', user)` is cached and never changes; a transient isn't reactive either. Register the session store, and read the user from it. * **Services in the state.** Instances aren't serializable: Redux warns about them, and DevTools, persistence and server-rendered state break. Pass them as the thunk's extra argument, or keep them in the store's closure. * **Looking services up in actions.** Don't call `core.get()` in an action, a reducer or an atom: give the store its services when the core creates it. * **The auth cycle.** The API client needs the session's token, and the session store needs the API client to sign in: the core throws `INJECUTE_CIRCULAR_DEPENDENCY`. Register a `tokens` service that both depend on: the session store writes the token, the API client reads it. * **Hydration.** In server rendering, the per-request store is filled on the server, and the client's store has to start from the same state: Redux's `preloadedState` (pass it to the store's factory), `setState()` on a Zustand store, `store.set()` for Jotai atoms, `pinia.state.value` for Pinia. * **Stores that start work.** Persistence, subscriptions and actors keep running after the UI is gone. Give their registration a `dispose` option that stops them. ## What the container leaves to the framework * **Reactivity.** The container creates and caches instances; it doesn't know about refs, signals or re-renders. `useStore()` or a state library's hooks make a store reactive; see [State management](#state-management). A store built with the framework's own primitives (Vue's `reactive()`, Solid's `createStore()`) works as a service too, but the core then depends on that framework. * **Component state.** Don't create a fork per component for its local state. React's `` mounts components twice in development: a fork disposed in an effect's cleanup is disposed when the component mounts again, and the next `get()` throws `INJECUTE_DISPOSED`. Use the framework's state, or a store the component creates. * **Components.** The container doesn't create or render components. Components are the edge of the app: they resolve services with `useService()`, and everything below them is injected. * **Lifetimes per render.** In React, `useService()` runs on every render. A singleton is resolved from the cache; a transient would be a new instance each time. Register what components resolve as singletons. --- --- url: https://masyaka.github.io/injecute/frameworks/nestjs.md description: >- How NestJS's dependency injection maps to injecute — plain classes without legacy decorators or reflect-metadata, wiring checked at compile time, one simple pipeline — and how to move step by step. --- # Coming from NestJS NestJS's dependency injection works through your classes: every service is decorated, every module is a decorated class, and the container reads constructor types from metadata the compiler emits. injecute takes the opposite approach: your classes stay plain, and the wiring is a list of keys in one place. ## What changes for your code * **Nothing in your classes.** No `@Injectable()`, `@Inject()` or `@Module()`. Services don't import the DI library, so they are tested with `new`, reused outside the app, and unchanged if you ever remove injecute: you delete the composition root, not a decorator in every file. * **No legacy decorators, no `reflect-metadata`.** Nest's injection reads constructor parameter types from `emitDecoratorMetadata`, which needs TypeScript's legacy `experimentalDecorators` and `reflect-metadata` at runtime. The standard decorators of TypeScript 5 don't emit that metadata, and tools that only strip types can't: Node.js's built-in TypeScript support doesn't run decorators at all, and esbuild (and so tsx) doesn't emit the metadata. Every tool that compiles your code, for the app, the tests and the scripts, has to support legacy decorator metadata. injecute is plain TypeScript: `node main.ts`, Bun, Deno, esbuild, or no build step at all. * **Interfaces without tokens.** Nest injects by runtime class, and an interface has no runtime type, so it needs a token and `@Inject(TOKEN)`. In injecute every dependency is a key, and its type comes from the registration. * **Wiring errors at compile time.** A missing provider in Nest is `Nest can't resolve dependencies of the OrdersService (?, …)` when the app boots. In injecute, the registration doesn't compile, and the error names the missing key. * **One pipeline to read.** Register, resolve, start, stop, dispose. No module scanning, no `forwardRef()`, and no injection scopes that spread: in Nest, a provider that depends on a request-scoped one becomes request-scoped itself, up to the controller. ## Before and after A service that depends on a repository and a payment gateway (an interface), in Nest: ```ts export const PAYMENTS = Symbol('PAYMENTS'); @Injectable() export class OrdersService { constructor( private readonly repo: OrdersRepository, @Inject(PAYMENTS) private readonly payments: PaymentGateway, // an interface needs a token ) {} } @Module({ imports: [DatabaseModule], providers: [ OrdersService, OrdersRepository, { provide: PAYMENTS, useClass: StripeGateway }, ], exports: [OrdersService], }) export class OrdersModule {} ``` With injecute, the class has no decorators, and the module is a function: ```ts export class OrdersService { constructor( private readonly repo: OrdersRepository, private readonly payments: PaymentGateway, ) {} } export const addOrders = (c: ServiceRegistry<{ db: Database }>) => c .addSingleton('repo', OrdersRepository, ['db']) .addSingleton('payments', StripeGateway) .addSingleton('orders', OrdersService, ['repo', 'payments']); ``` * `ServiceRegistry<{ db: Database }>` replaces `imports`: the module states what it needs, and `extend(addOrders)` is a compile error on a container without `db`. * A `StripeGateway` that doesn't implement `PaymentGateway` is a compile error at the registration. * Remove `experimentalDecorators` and `emitDecoratorMetadata` from `tsconfig.json`, and `import 'reflect-metadata'` from the entry point, once the last decorator is gone. * To keep a decorator on each class, standard decorators can register classes under typed keys. See [Custom registration](../guide/custom-registration.md#services-from-decorators). ## Concepts | NestJS | injecute | | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `@Injectable()` | Nothing: register the class | | `@Module({ providers, imports, exports })` | A module function over `ServiceRegistry<{ …what it needs }>`, applied with `extend()` or `namespace()` | | `{ provide, useClass }` | `addSingleton(key, Class, [deps])` | | `{ provide, useValue }` | `addInstance(key, value)` | | `{ provide, useFactory, inject }` | `addSingleton(key, factory, [deps])` | | `{ provide, useExisting }` | `addAlias(key, target)` | | `@Inject(TOKEN)`, `@Optional()` | The key in the dependency list, `optional(key)` | | `Scope.TRANSIENT` | `addTransient()` | | `Scope.REQUEST` | A [context accessor](../guide/request-context.md) or [one instance per request](../guide/request-state.md) | | Dynamic modules, `forRoot(options)` | A function that returns a module: `const addMailer = (options: MailerOptions) => (c: ServiceRegistry) => …` | | Multi-providers, `DiscoveryService` for plugins | An [extension point](../guide/tags.md): `createTag()` and `collect()` | | `ModuleRef.get()` | A `ServiceProvider` at the entry point; services get their dependencies injected | | `forwardRef()` | A cycle is an error with its path: extract the shared part into its own service | | `onModuleInit()`, `onApplicationBootstrap()` | [Lifecycle hooks](../guide/startup-shutdown.md) in `lifecycle.init` and `lifecycle.start`, or `startable()` | | `onModuleDestroy()`, `beforeApplicationShutdown()` | The undo function a hook returns, and the `dispose` of a registration | | `app.enableShutdownHooks()` | `process.once('SIGTERM', () => running.stop())` | | `Test.createTestingModule().overrideProvider()` | `app.fork({ isolated: true }).addInstance(key, fake, { replace: true })` | ## Tests In Nest, a test compiles a testing module with the overrides: ```ts const moduleRef = await Test.createTestingModule({ imports: [OrdersModule] }) .overrideProvider(PAYMENTS) .useValue(fakePayments) .compile(); const orders = moduleRef.get(OrdersService); ``` In injecute, a test replaces the service in an isolated fork of the app, and everything resolved through the fork uses the replacement: ```ts await using test = app .fork({ isolated: true }) .addInstance('payments', fakePayments, { replace: true }); const orders = test.get('orders'); ``` ## Moving step by step You don't have to move everything at once. Build an injecute container next to the Nest app, move services into it module by module, and give them to Nest's controllers by key: ```ts // container.ts export const app = new DIContainer() .addSingleton('db', () => new Database(process.env.DATABASE_URL!), { dispose: (db) => db.close(), }) .extend(addOrders) .seal(); ``` ```ts // app.module.ts import { app } from './container'; @Controller('orders') export class OrdersController { constructor(@Inject('orders') private readonly orders: OrdersService) {} @Get() list() { return this.orders.list(); } } @Module({ controllers: [OrdersController], // services that moved to injecute, provided to Nest by key providers: [{ provide: 'orders', useFactory: () => app.get('orders') }], }) export class AppModule implements OnApplicationShutdown { onApplicationShutdown() { return app.dispose(); } } ``` The moved services lose their decorators, and the controllers keep working. When the last provider has moved, decide whether to keep Nest for HTTP or move the controllers too. ## What Nest gives you that injecute doesn't Nest is a framework: controllers, guards, pipes, interceptors, OpenAPI generation, microservice transports. injecute is only the container. Pair it with an HTTP framework ([Express](./express.md), [Fastify](./fastify.md), [Hono](./hono.md)) and the libraries you choose for validation and API documentation. --- --- url: https://masyaka.github.io/injecute/concepts/principles.md description: >- The principles injecute is built on, what they mean for your code, their known exceptions, and the design tests a new feature has to pass. --- # Design principles This page explains how injecute behaves and why, so you can predict it. The second part lists the tests a new feature has to pass, so the library keeps the same direction as it grows. ## Principles ### 1. Explicit wiring Every registration lists the keys it depends on, next to the factory: `addSingleton('users', UserRepository, ['db'])`. **For you:** the wiring is in one place and readable without running anything. Nothing is matched by name, type or position behind your back. **Why there is no auto-wiring:** every dependency is a line you can review in a diff. A pull request that gives `payments` a new dependency changes `addSingleton('payments', GatewayPayments, ['tx', 'gateway'])`, and the reviewer sees it, including in pull requests a coding agent wrote. With decorators, scanning folders or matching parameter names, the same change is spread over files or decided at runtime, and the review has to trust it. Writing the keys is the cost: the compiler checks them, editors complete them, and agents write them. When you need to find services instead, [custom registration](../guide/custom-registration.md) shows how, and what the compiler still checks. ### 2. No decorators, no reflection Your classes and functions don't import the container, carry metadata or depend on parameter names. **For you:** they stay plain TypeScript, testable without a container, and work without build-time transforms. Adopting or removing injecute doesn't touch them. ### 3. Typed from the registrations The container's type grows with every registration: `get()` results and factory parameters are inferred, and a wrong key, a missing service or (under a [tag](../guide/tags.md)) a wrong contribution is a compile error. **For you:** annotate little, and trust the errors. See [TypeScript](../guide/typescript.md). ### 4. Lazy unless a function says otherwise Registering runs nothing. A service is created when something resolves it. **For you:** registering creates nothing, and unused services cost nothing. Eager work is explicit and named: `preload()` resolves services at startup, and `startLifecycle()` runs [lifecycle hooks](../guide/startup-shutdown.md). ### 5. The container that runs a factory owns the instance A service registered in a parent runs and is cached in the parent; an [isolated fork](../guide/containers.md#isolated-forks) runs everything itself. See [How resolution works](./resolution.md). **For you:** forks share the app's singletons, and isolated forks replace a dependency for the whole graph in tests, without touching the app. ### 6. Each place gets only the part it needs Consumers get a `ServiceProvider` (resolve), modules a `ServiceRegistry` (resolve and register), and only the composition root the `DIContainer` (configure, fork, dispose). See [Roles](./roles.md). **For you:** handlers can't rewire the app, and modules can't take over its configuration. ### 7. Modules only register A module function registers services and returns. Work at startup is a [lifecycle hook](../guide/startup-shutdown.md); contributions to another module go under a [tag](../guide/tags.md). **For you:** a module behaves the same in the app and in a test fork, because it does nothing on its own. ### 8. Keys are names A key means nothing beyond the service it names, except for two reserved forms: `Ns.key` for [namespaces](../guide/containers.md#namespaces) and `name:tag` for [tags](../guide/tags.md) and lifecycle stages. See [Reserved key forms](../guide/registration.md#reserved-key-forms). **For you:** build reserved keys with their helpers (`namespace()`, `route('name')`, `lifecycle.start('name')`) instead of typing them. ### 9. Resolution follows dependencies Which service is created first follows the dependency graph, not the order of registrations. Two documented exceptions use registration order: `collect(tag)` results and the hooks of a lifecycle stage. See [Order](./resolution.md#order). **For you:** when order matters for correctness, express it with a dependency or a stage. ### 10. Owns what it creates The container releases the singletons it created, dependents first, with `dispose()` or `await using`. A running app is stopped before its resources are released: `running.stop()` undoes the lifecycle hooks in reverse, then disposes. See [Lifecycle and dispose](../guide/lifecycle.md). **For you:** close connections and stop servers by disposing, not by tracking them yourself. ### 11. Errors say how to fix them Every error is an `InjecuteError` with a stable `code` (one code, one meaning), the resolution `path` and a link to its [page](../errors/index.md). **For you:** branch on `error.code`, never on the message. ### 12. A small API, one recommended way per task Each task has one default way, and the docs lead with it. Where two ways exist, the docs say which one is the default and when to use the other. **For you:** the first example in a guide is the one to copy. ### 13. Runs everywhere ES modules for Node.js, Deno, Bun and browsers, no runtime dependencies, no build step required. See [Without a build step](../guide/no-build.md). ### 14. Stable, including types Semantic versioning covers the runtime and the types, and the cost of type checking is kept in a budget. See [Versioning and support](../guide/versioning.md). **For you:** a minor version doesn't break your code or your types, and large containers stay fast to typecheck. ## Exceptions Places where injecute bends a principle on purpose. Each one is documented where it applies. | Principle | Exception | Why | | ------------------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | 8. Keys are names | `name:tag` keys: `collect()` gathers them, and `startLifecycle()` runs and validates them as hooks | extension points and hooks without a registry API, typed through tags | | 9. Resolution follows deps | `collect(tag)` results and the hooks of one stage come in registration order (the root's first) | an order that is predictable, without priorities | | Keeping the direction, test 6 | `setCacheInstance()`, the lifecycle and `buildServicesGraph()` use internal container symbols | they need state the public API doesn't expose | | Keeping the direction, test 7 | `startLifecycle()` remembers each container's run outside the container | a running lifecycle is not a service, and must not be reset by `reset()` | | 11. Errors say how to fix them | browser production bundles print no hint line; the message, `code` and `docs` link stay | about 1.5 kB gzipped less in browser bundles; `injecute/hints` adds them | | Keeping the direction, test 7 | `import 'injecute/hints'` registers the hints in module state, for every container | it configures how errors print, not a service | ## Keeping the direction Written for maintainers of injecute: the tests a new feature or change has to pass. A change that bends one of the principles names it in its pull request and adds a row to [Exceptions](#exceptions), so the exception is a decision, not an accident. 1. **Explicit and plain.** It works with plain classes and functions and explicit keys: no decorators, no reflection, no matching by parameter name (1, 2). 2. **Typed end to end.** Its types are inferred from the registrations; mistakes are compile errors with a readable message; it has type tests, and the type benchmark stays within budget (3, 14). 3. **Lazy by default.** It creates nothing on registration. Eager behaviour is a function whose name says so (4). 4. **Fits the forks.** It behaves correctly in a fork and an isolated fork: it resolves in the container that runs the factory, and test overrides reach it (5). 5. **Respects the roles.** Modules get no more than a `ServiceRegistry`; only the composition root configures and owns the lifecycle (6, 7). 6. **Built on the public API.** A utility uses the public API. When it needs container internals, it uses an internal symbol and is listed in [Exceptions](#exceptions). 7. **State lives in containers.** What a feature remembers belongs to a container, so `reset()`, forks and `dispose()` handle it, unless it is listed in [Exceptions](#exceptions). 8. **No new meaning in keys** beyond the reserved forms; a new form is an exception (8). 9. **No hidden ordering.** Behaviour doesn't depend on registration order, unless it is documented in [Order](./resolution.md#order) (9). 10. **Releases what it starts.** Anything it starts can be stopped, in reverse, before resources are released (10). 11. **One error code, one meaning.** A new failure gets its own code, with a hint and a page (11). 12. **One way per task.** It doesn't add a second way to do something the API already does; if it does, the docs name the default (12). 13. **No runtime dependencies**, nothing tied to one runtime, and it runs without a build step (13). 14. **Semver for types too.** Widening a type the API returns, or narrowing one it accepts, is a breaking change (14). --- --- url: https://masyaka.github.io/injecute/concepts/roles.md description: >- Why injecute gives consumers a ServiceProvider, modules a ServiceRegistry, and only the composition root the DIContainer. --- # Roles: provider, registry, container A container is used in three different ways, and each place in your code should get only the part it needs. | Type | Can | Give it to | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `ServiceProvider` | resolve (`get`, `has`, `createResolver`, `call`) and inspect (`keys`, `ownKeys`, `getParent`, `getRegistration`) | handlers and other code that consumes services, tooling | | `ServiceRegistry` | everything a provider can, plus register (`addSingleton`, `addTransient`, `addInstance`, `addAlias`, `namespace`, `extend`, `injecute`, `bind`) | module functions and `namespace()` callbacks | | `DIContainer` | everything, plus configure (`use`, `unuse`, events) and own the lifecycle (`fork`, `reset`, `dispose`) | the composition root: the code that builds the app | Each type is a subset of the next, so a `DIContainer` can be passed wherever a registry or a provider is expected. ## Why * **Consumers cannot rewire the app.** A handler that only needs `users` gets a `ServiceProvider<{ users: UserRepository }>`; it cannot register or replace services by accident. * **Modules cannot take over configuration.** A module registers its services, but adding middlewares, listening to events, forking or disposing stays with the composition root. * **Resolution-time code is read-only.** Middlewares and event listeners receive the container as a provider, and so do `getParent()` and namespace providers from `get('Namespace')`. These are compile-time guarantees; at runtime every view is the same container object. ## Declaring what you need A provider of more services is assignable to a provider of fewer: ```ts function listUsers(services: ServiceProvider<{ users: UserRepository }>) { return services.get('users').findAll(); } listUsers(app); // app has `users` and many other services ``` A module declares its requirements the same way and is applied with `extend()`, which checks them: ```ts const addBilling = (c: ServiceRegistry<{ db: Database }>) => c.addSingleton('invoices', InvoiceRepository, ['db']); app.extend(addBilling); ``` Call modules through `extend()` (or `namespace()`), not directly: `addBilling(app)` would require the module's types to match the container exactly. ## Libraries built on injecute A helper that needs the whole container (for example, to add a middleware) takes a `DIContainer`. Make `injecute` a **peer dependency** of such a package, so the app and the helper share one copy of the class. --- --- url: https://masyaka.github.io/injecute/concepts/resolution.md description: >- Lookup through parent containers, which container runs a factory and owns the instance, decoration, middlewares, cycles and errors. --- # How resolution works ## Lookup `get(key)` looks for a registration of `key` in the container, then in its parent, and so on. The first registration found wins, so a fork can override a parent's service by registering the same key. ## Who runs the factory, and who owns the instance The container that **runs** a factory also **caches** the result (for singletons) and **owns** it (for `dispose()`). * For a normal fork, a service registered in the parent runs in the parent. Its instance is shared by all forks, and its dependencies are resolved in the parent. * For an isolated fork (`fork({ isolated: true })`), every service resolved through the fork runs in the fork, even when it is registered in a parent. Its dependencies are resolved in the fork, so overrides in the fork apply. Caches are kept per registration: replacing a registration starts a new cache entry, and the previous instance is still disposed by `dispose()`. ## Order Resolution follows dependencies, not registration order, with two exceptions where registration order is the order you get: * **`collect(tag)`** returns the services under the tag in registration order, the root's first, then what forks below it added. A namespace's services come where `namespace()` was called. * **Lifecycle hooks** of one stage run in that order too (unless the stage is concurrent), and are undone in reverse. So the order of `extend()` and `namespace()` calls in the composition root decides, for example, the order of routes and of hooks. When the order matters for correctness, express it with stages (or with a dependency), not with the order of the calls. ## Decoration A dependency on the key being registered is resolved to the **previous definition** of that key: the registration it replaces in the same container, or the parent's registration in a fork. Without a previous definition, registering it throws [`INJECUTE_NO_PREVIOUS_DEFINITION`](../errors/no-previous-definition.md). ## Middlewares A `get()` runs the effective middleware chain once: the ancestors' middlewares (root first) followed by the container's own, with the last added running first. Nested dependencies go through the chain of the container that runs their factory, with a higher `context.depth`. ## Cycles Registration checks the dependency graph and throws [`INJECUTE_CIRCULAR_DEPENDENCY`](../errors/circular-dependency.md) when a new registration closes a cycle, including cycles through services registered later. Resolution also guards against cycles that only appear at runtime. ## Errors Every error is an `InjecuteError` with a `code`, the resolution `path` and a `docs` link. Errors thrown by your factories are wrapped as [`INJECUTE_RESOLUTION_FAILED`](../errors/resolution-failed.md) with the original error as `cause`, so the message says which service needed the one that failed. --- --- url: https://masyaka.github.io/injecute/concepts/comparison.md description: >- How injecute compares with typed-inject, needle-di, Awilix, InversifyJS, tsyringe, NestJS and Effect, with the sources for every claim. --- # Comparison How injecute compares with other ways to do dependency injection in TypeScript. Every claim about another library links to its docs, README or source in [Sources](#sources), checked against the versions in the first table. Found something wrong or out of date? [Tell us in Discussions](https://github.com/Masyaka/injecute/discussions/categories/ideas). ## Wiring | Library | Version | How a service gets its dependencies | A missing registration is… | Decorators / reflect-metadata | | ----------------------------- | ------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------- | | **injecute** | 1.0 | Each registration lists its dependency keys: `addSingleton('users', Repo, ['db'])` | a compile error ("Did you mean …?") | none | | [typed-inject](#typed-inject) | 5.0.0 | A `static inject = ['db'] as const` array on the class or function | a compile error | none | | [needle-di](#needle-di) | 1.2.1 | `inject(Token)` in constructor defaults or fields, or providers bound by hand | a runtime error (token types are checked) | optional standard decorators, no reflect-metadata | | [Awilix](#awilix) | 13.0.5 | By name: a destructured "cradle" object, or parsed parameter names; `loadModules` globs | a runtime error (the container type grows since v13, `asClass` isn't typed) | none | | [InversifyJS](#inversifyjs) | 8.2.3 | `@injectable()` / `@inject(id)` and `container.bind(id)` | a runtime error (opt-in typed binding map) | required for decorator injection | | [tsyringe](#tsyringe) | 4.10.0 | `@injectable()` and emitted constructor types; `@inject(token)` for other tokens | a runtime error | required | | [NestJS](#nestjs) | 12.3.2 | `@Injectable()` providers in modules, resolved by constructor type | a runtime error at bootstrap | required | | [Effect](#effect) | 4.1.1 | Services (`Context.Service`) used with `yield*`, provided by layers | a compile error (unmet requirements stay in the type) | none | ## Lifetimes, async and disposal | Library | Scoped services | A singleton that depends on a scoped service | Async services | Disposal | | ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | **injecute** | [Scopes](../guide/request-state.md#scopes): a fork with its own services | a compile error for a scope-only key; a scope that re-registers a root key must say `replace: true`, and the error names the root services that keep the root's instance | `AsyncDIContainer` awaits every dependency; async singletons in any container | `dispose()` / `await using` (`Symbol.dispose`), [lifecycle hooks](../guide/startup-shutdown.md) | | typed-inject | Child injectors; Singleton and Transient | not documented | not documented | `injector.dispose()` calls `dispose()` methods | | needle-di | Child containers; every provider is a singleton per container | not documented | async providers with `getAsync()` | not documented | | Awilix | `SCOPED` lifetime with `createScope()` | a runtime error in `strict` mode (opt-in, off by default) | not built in (a third-party package) | `dispose()` with per-registration disposers | | InversifyJS | Child containers; "Request" means one `get()` call | not handled | `getAsync()` | `onDeactivation` / `@preDestroy` on unbind; `Symbol.dispose` through a plugin | | tsyringe | `ContainerScoped` in child containers | not handled | none | `dispose()` calls `dispose()` methods | | NestJS | `Scope.REQUEST`, durable providers | prevented: a request-scoped dependency makes its consumers request-scoped too | async factory providers | lifecycle hooks (not called for request-scoped providers) | | Effect | No named scope: provide the service or layer per unit of work | not documented | layers are built from effects | scopes and finalizers (`acquireRelease`) | ## Size The size of a small app that registers a value and a service that depends on it, resolves the service and prints it: bundled with esbuild 0.28 for the browser, minified and gzipped, each bundle run to check it prints the right value. | Library | Bundle (gzipped) | Runtime dependencies | Module formats | | ------------ | ---------------------------------------------------- | ----------------------------------------------------- | ---------------------- | | typed-inject | 1.4 kB | none | ESM | | needle-di | 2.7 kB | none | ESM (npm and JSR) | | Awilix | 3.6 kB (browser build, without `loadModules`) | `fast-glob` (Node.js build) | CommonJS, ESM, browser | | **injecute** | 6.3 kB (error hints are left out in browser bundles) | none | ESM (npm and JSR) | | tsyringe | 9.2 kB with the reflect-metadata polyfill it needs | `tslib`, plus a Reflect polyfill | CommonJS, ES5 ESM | | InversifyJS | 22.9 kB | `@inversifyjs/*` packages | ESM | | Effect | 59.7 kB (the Effect runtime) | none | ESM | | NestJS | not comparable: a server framework | 5 small packages; `reflect-metadata`, `rxjs` as peers | ESM | Versions and dependencies are from the npm registry. ## Where injecute is different * **Explicit scopes that can't leak.** A root service can't name a key that only a scope registers, and a scope that re-registers a root key gets an error naming the root services that keep the root's instance. Of the libraries above, NestJS prevents the leak by making consumers request-scoped, Awilix detects it in strict mode, and the rest don't handle it. * **A whole-graph check in one test.** [`checkWiring()`](../guide/testing.md#check-the-wiring) reports every missing key and every failing factory at once. * **Errors with codes and pages.** Every error has a stable code, the resolution path, and a [page](../errors/index.md) that says how to fix it. * **Lifecycle and disposal built in.** `dispose()`, `await using`, and [startup and shutdown hooks](../guide/startup-shutdown.md) without a framework. * **Docs for coding agents in the package.** `llms.txt`, the docs of the installed version and an Agent Skill ship with it ([AI agents](../guide/ai-agents.md)). ## Sources ### typed-inject * Version 5.0.0: [CHANGELOG](https://github.com/nicojs/typed-inject/blob/master/CHANGELOG.md). * `static inject` tokens, no decorators: [Usage](https://github.com/nicojs/typed-inject#usage), [Motivation](https://github.com/nicojs/typed-inject#motivation). * "If your project compiles, you know your dependencies are resolved at runtime": [Installation](https://github.com/nicojs/typed-inject#installation). * Singleton and Transient, child injectors: [Lifecycle control](https://github.com/nicojs/typed-inject#lifecycle-control), [createChildInjector](https://github.com/nicojs/typed-inject#injectorcreatechildinjector-injectortcontext). * Disposal: [Disposing provided stuff](https://github.com/nicojs/typed-inject#disposing-provided-stuff). * ESM only since v4, no dependencies: [CHANGELOG](https://github.com/nicojs/typed-inject/blob/master/CHANGELOG.md). ### needle-di * Version 1.2.1: [CHANGELOG](https://github.com/needle-di/needle-di/blob/main/CHANGELOG.md). * `inject()` and binding: [Injection](https://needle-di.io/concepts/injection.html), [Binding](https://needle-di.io/concepts/binding.html). * Missing providers throw at runtime: [Optional injection](https://needle-di.io/advanced/optional-injection.html). * Standard decorators, optional: [Auto-binding](https://needle-di.io/concepts/binding.html#auto-binding). * Singletons per container, child containers: [Providers](https://needle-di.io/concepts/providers.html), [Child containers](https://needle-di.io/advanced/child-containers.html). * Async providers: [Async injection](https://needle-di.io/advanced/async-injection.html). * ESM, npm and JSR: [Getting started](https://needle-di.io/getting-started.html). ### Awilix * Version 13.0.5: [CHANGELOG](https://github.com/jeffijoe/awilix/blob/master/CHANGELOG.md). * Injection by name, `PROXY` and `CLASSIC` modes: [Injection modes](https://github.com/jeffijoe/awilix#injection-modes). * Typing: [`resolve` overloads](https://github.com/jeffijoe/awilix/blob/master/src/container.ts), [`asClass`](https://github.com/jeffijoe/awilix/blob/master/src/resolvers.ts), [InferCradleFromContainer](https://github.com/jeffijoe/awilix#infercradlefromcontainer). * Lifetimes and scopes: [Lifetime management](https://github.com/jeffijoe/awilix#lifetime-management). * Strict mode, off by default, and the lifetime leak error: [createContainer](https://github.com/jeffijoe/awilix#createcontainer), [Strict mode](https://github.com/jeffijoe/awilix#strict-mode). * Async through a third-party package: [Ecosystem](https://github.com/jeffijoe/awilix#ecosystem). * Disposal: [Disposing](https://github.com/jeffijoe/awilix#disposing). * Browser build without `loadModules`: [Universal module](https://github.com/jeffijoe/awilix#universal-module-browser-support). ### InversifyJS * Version 8.2.3; ESM only since v8: [Migrating from v7](https://inversify.io/docs/guides/migrating-from-v7/). * Decorators and binding: [Getting started](https://inversify.io/docs/introduction/getting-started/). * Decorators and reflect-metadata required: [Requirements](https://inversify.io/docs/faq/requirements/). * Typed binding map (opt-in): [Strongly typed](https://inversify.io/docs/ecosystem/strongly-typed/). * Scopes and child containers: [Binding](https://inversify.io/docs/fundamentals/binding/), [DI hierarchy](https://inversify.io/docs/fundamentals/di-hierarchy/). * No lifetime check: the core's error kinds have none ([InversifyCoreErrorKind](https://github.com/inversify/monorepo/tree/main/packages/container/libraries/core)). * Async: [Asynchronously resolved bindings](https://inversify.io/docs/fundamentals/binding/#asynchronously-resolved-bindings). * Disposal: [Deactivation](https://inversify.io/docs/fundamentals/lifecycle/deactivation/), [plugin-dispose](https://github.com/inversify/monorepo/tree/main/packages/container/libraries/plugin-dispose). ### tsyringe * Version 4.10.0; no code changes since that release: [commits](https://github.com/microsoft/tsyringe/commits/master). * Decorators, emitted types and a Reflect polyfill required: [Installation](https://github.com/microsoft/tsyringe#installation). * Untyped tokens, runtime errors: [dependency-container.ts](https://github.com/microsoft/tsyringe/blob/master/src/dependency-container.ts). * Scopes and child containers: [Scoped](https://github.com/microsoft/tsyringe#scoped), [Child containers](https://github.com/microsoft/tsyringe#child-containers). * Synchronous factories only: [Factory provider](https://github.com/microsoft/tsyringe#factory-provider). * Disposal: [Disposable instances](https://github.com/microsoft/tsyringe#disposable-instances). ### NestJS * Version 12.3.2: [npm](https://www.npmjs.com/package/@nestjs/core). * Providers resolved by type: [Dependency injection](https://docs.nestjs.com/providers#dependency-injection). * Missing providers fail at bootstrap: [Common errors](https://docs.nestjs.com/faq/common-errors#cannot-resolve-dependency-error). * Decorators and emitted metadata: [TypeScript starter](https://github.com/nestjs/typescript-starter/blob/master/tsconfig.json). * Scopes, and "The `REQUEST` scope bubbles up the injection chain": [Injection scopes](https://docs.nestjs.com/fundamentals/injection-scopes#scope-hierarchy). * Async providers: [Async providers](https://docs.nestjs.com/fundamentals/async-providers). * Lifecycle hooks: [Lifecycle events](https://docs.nestjs.com/fundamentals/lifecycle-events). ### Effect * Version 4.1.1; v4 replaced `Context.Tag` and `Effect.Service` with `Context.Service`: [services migration](https://github.com/Effect-TS/effect/blob/main/migration/services.md). * Services and the compile-time requirement check: [Services](https://effect.website/docs/v4/requirements-management/services/). * Layers and memoization: [Layers](https://effect.website/docs/v4/requirements-management/layers/), [Layer memoization](https://effect.website/docs/v4/requirements-management/layer-memoization/). * Scopes and finalizers: [Scope](https://effect.website/docs/v4/resource-management/scope/). ### injecute * Wiring and types: [Design principles](./principles.md), [TypeScript](../guide/typescript.md). * Scopes: [Scoped state](../guide/request-state.md#scopes), [Overriding a parent's service in a fork](../guide/containers.md#overriding-a-parent-s-service-in-a-fork). * Async: [Async services](../guide/async.md). Disposal: [Lifecycle and dispose](../guide/lifecycle.md). * Runtimes: [Versioning and support](../guide/versioning.md#runtimes). --- --- url: https://masyaka.github.io/injecute/errors.md description: Every error injecute throws, with how to fix it. --- # Errors Every error injecute throws is an `InjecuteError` with a stable `code`, the resolution `path` (which service needed what), a `docs` link to the page below, and a message with a hint. Branch on `code`, not on message text. | Code | Meaning | | --- | --- | | [`INJECUTE_NOT_REGISTERED`](./not-registered.md) | No service is registered under this key | | [`INJECUTE_ALREADY_REGISTERED`](./already-registered.md) | The key is already registered in this container or a parent | | [`INJECUTE_CIRCULAR_DEPENDENCY`](./circular-dependency.md) | Services depend on each other in a cycle | | [`INJECUTE_NOTHING_TO_REPLACE`](./nothing-to-replace.md) | A registration replaces a key that nothing is registered under | | [`INJECUTE_NO_PREVIOUS_DEFINITION`](./no-previous-definition.md) | A service depends on itself but there is nothing to decorate | | [`INJECUTE_INVALID_FACTORY`](./invalid-factory.md) | The factory is not a function or class | | [`INJECUTE_INVALID_DEPENDENCY`](./invalid-dependency.md) | A dependency is not a key, optional(key), collect(tag) or a function | | [`INJECUTE_CLASS_NOT_CONSTRUCTED`](./class-not-constructed.md) | A class was called without new | | [`INJECUTE_RESOLUTION_FAILED`](./resolution-failed.md) | A factory threw (or its promise rejected) while creating a service | | [`INJECUTE_NOT_A_FUNCTION`](./not-a-function.md) | call() was used on a service that is not a function | | [`INJECUTE_NAMESPACE_CONFLICT`](./namespace-conflict.md) | The namespace name is already used by a service | | [`INJECUTE_NAMESPACE_RESULT`](./namespace-result.md) | A namespace callback returned something other than a new container | | [`INJECUTE_EXTENSION_RESULT`](./extension-result.md) | An extension returned a container that is not this one or its child | | [`INJECUTE_SEALED`](./sealed.md) | The container is sealed | | [`INJECUTE_DISPOSED`](./disposed.md) | The container is disposed | | [`INJECUTE_DISPOSE_FAILED`](./dispose-failed.md) | Some services failed to dispose | | [`INJECUTE_WIRING_FAILED`](./wiring-failed.md) | The wiring check found services that are missing or fail | | [`INJECUTE_INVALID_OPTION`](./invalid-option.md) | An option is not supported here | | [`INJECUTE_UNKNOWN_EVENT`](./unknown-event.md) | Unknown event name | | [`INJECUTE_READ_ONLY`](./read-only.md) | The object is read-only | | [`INJECUTE_INVALID_HOOK`](./invalid-hook.md) | A lifecycle hook is not registered correctly | | [`INJECUTE_UNDO_FAILED`](./undo-failed.md) | A lifecycle hook could not be undone | | [`INJECUTE_LIFECYCLE_STOPPED`](./lifecycle-stopped.md) | The lifecycle of this container was stopped | ## Hints in browser bundles Bundlers that build for browsers leave the hints out of error messages, to keep the bundle small (about 1.5 kB gzipped). Messages keep what happened and the docs link; `error.code` and `error.docs` are the same everywhere. | Where it runs | Hints in messages | | --- | --- | | Node.js, Deno, Bun, server-side rendering, browsers without a bundler | always | | Vite and webpack, development (the dev server, `--mode development`) | yes | | Vite and webpack, production builds | no | | esbuild, Rolldown | no, unless you add the `development` condition (`conditions: ['development']`) | To have them in any build, import `injecute/hints` where you want them: ```ts // in development only, with any bundler if (import.meta.env.DEV) await import('injecute/hints'); // or in every build (staging, or debugging a production build) import 'injecute/hints'; ``` Without a bundler, and on servers, the import does nothing: those messages have the hints already. An error thrown before a dynamic `import()` finishes has no hint; a static import doesn't have that gap. --- --- url: https://masyaka.github.io/injecute/migration/0.x-to-1.0.md description: >- Every breaking change between injecute 0.x and 1.0, with before/after examples. --- # Migrating from 0.x to 1.0 injecute 1.0 cleans up the public API. This page lists every breaking change with a before/after example. Work through it top to bottom; most changes are mechanical. **Checklist** 1. Use Node.js ≥ 22.12 and `import { DIContainer } from 'injecute'` (no default export, ESM only). 2. Replace `IDIContainer` in module signatures with `ServiceRegistry<{ …what the module needs }>`, and consumers' container parameters with `ServiceProvider<{ … }>`. 3. Drop `construct()` for native classes (keep it for ES5-compiled and bound classes). 4. Replace `'undefined'` dependency keys with `optional(key)` or `() => undefined`. 5. Rename `allowUnresolved` → `optional`, `has(key, false)` → `has(key, { local: true })`, `new DIContainer({ parentContainer })` → `parent.fork()`. 6. Update middlewares to `(key, next, { container })` and `fork({ skipResolvers })` to `fork({ middlewares: false })`. 7. Replace `flatten()` with `fork({ isolated: true })`. 8. Branch on `InjecuteError.code` instead of error messages; factory errors now arrive wrapped (the original is `cause`). 9. `await preload(app)`: it returns a promise now. 10. Replace `ContainerServices['Billing']` (a namespace's container type) with `NamespaceServices`. 11. Pass `{ replace: true }` where a fork, or a namespace callback, registers a key its parent has, and only there: `replace: true` for a key that nobody has throws. ## Module format: ESM only 1.0 ships a single ES module build. The CommonJS build is gone. * **Node.js ≥ 22.12** is required. `require('injecute')` keeps working through Node's `require(esm)` support, which 22.12 is the first 22.x release to enable without a flag. * Browsers, Deno and Bun import the package directly, including from CDNs such as esm.sh and jsDelivr. * Bundlers need no configuration. ```ts // before (CommonJS) const { DIContainer } = require('injecute'); // after (ESM) import { DIContainer } from 'injecute'; // still works on Node ≥ 22.12 (require of an ES module) const { DIContainer } = require('injecute'); ``` ## The `utils` object is removed Every helper was already exported by name. The grouped `utils` object duplicated them and broke the published type declarations for projects with `skipLibCheck: false`. ```ts // before import { utils } from 'injecute'; utils.construct(MyClass); // after import { construct } from 'injecute'; construct(MyClass); ``` ## `null` and `undefined` are valid service values 0.x treated a factory result of `null` or `undefined` as "not registered": `get()` threw `No service registered`, and singletons returning `null` ran their factory again. 1.0 looks services up by key, so any value, including `null` and `undefined`, is returned and cached. ```ts const c = new DIContainer().addSingleton('session', () => null, []); c.get('session'); // 0.x: throws · 1.0: null ``` ## Decorating a service with a self dependency A dependency on the key being registered now resolves the **previous definition** of that key: the replaced one in the same container (with `replace: true`), or the parent's one in a child. In 0.x a child could not do this at all. ```ts const child = parent .fork() .addSingleton('logger', (logger) => withPrefix(logger), { dependencies: ['logger'], replace: true, // required in a fork too, see below }); ``` Re-registering an own key without `replace: true` throws `already registered` (0.x threw `CircularDependencyError` when the new registration depended on itself). ## A fork needs `replace: true` to override a parent's key In 0.x a fork could register a key its parent has without `replace`. Services registered in the parent kept the parent's instance, and nothing said so. 1.0 throws `INJECUTE_ALREADY_REGISTERED` and names those services; pass `{ replace: true }` when the override is meant for the fork's own services only. ```ts // 0.x const scope = app.fork().addInstance('logger', requestLogger); // 1.0 const scope = app .fork() .addInstance('logger', requestLogger, { replace: true }); ``` When a parent service should use the fork's instance, register that service in the fork too, or use `fork({ isolated: true })` to replace it for every service. See [Overriding a parent's service in a fork](../guide/containers.md#overriding-a-parent-s-service-in-a-fork). The same applies to `namespace()` callbacks, which receive a fork. `addAlias()` takes `{ replace: true }` now too. The other way round, `replace: true` for a key that no container has throws `INJECUTE_NOTHING_TO_REPLACE` (0.x registered it as a new service). Remove `replace: true` from first registrations, or find the registration that is missing. ## Middlewares run once per `get()` In 0.x a fork copied its parent's middlewares, and resolving a parent service ran them again at every level, so one `get()` through two forks ran a middleware three times. 1.0 runs the chain once per `get()`. ## Middleware signature: `(key, next, context)` Middlewares no longer receive the container as `this`, so arrow functions work. The container and the resolution path are on the third argument. `next()` continues with the same key; `next(otherKey)` resolves another key. ```ts // before container.use(function (key, next) { this.get('logger').debug(String(key)); return next(key); }); // after container.use((key, next, { container, path }) => { container.get('logger').debug(path.join(' > ')); return next(); }); ``` Forks inherit their ancestors' middlewares **live**: a middleware added to a parent after `fork()` applies to existing forks. `fork({ skipResolvers: true })` is now `fork({ middlewares: false })`. Remove a middleware with `unuse(middleware)`. ## `flatten()` is removed `flatten()` threw with its default options and, when forced to run, cached the flattened container's singletons in the root container. Use an isolated fork instead: it runs every factory it resolves in the fork, so overrides reach the whole graph and nothing leaks back. ```ts // before const copy = app.flatten({ onKeyIntersection: (k) => () => undefined }); // after const copy = app.fork({ isolated: true }); ``` ## Introspection: `getRegistration()` replaces `getFactory()` `getFactory()`, `entryTypeKey`, `firstResult` and the `EntryType` type are removed. Read registration metadata with `getRegistration(key)`, which returns a read-only `RegistrationInfo` (`kind`, `dependencies`, `target`, `namespace`, `depth`). `EntryType` is now `RegistrationKind`. The `replace` event payload reports the replaced registration as `previous: RegistrationInfo` instead of `replaced: { callable, type }`. ## Registration hooks are removed The undocumented `beforeResolving`, `afterResolving` and `beforeReplaced` registration options are gone. Use the `produce` / `replace` events or a middleware instead. ## Container roles: `ServiceProvider`, `ServiceRegistry`, `DIContainer` 1.0 splits the container into three levels. Each piece of code gets only what it needs: | Type | Can | Give it to | | -------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `ServiceProvider` | resolve (`get`, `has`, `createResolver`, `call`) and inspect (`keys`, `getRegistration`) | handlers and other consumers, middleware context, event payloads | | `ServiceRegistry` | everything above, plus register (`add*`, `addAlias`, `namespace`, `extend`, `injecute`, `bind`) | module / extension functions | | `DIContainer` | everything above, plus `use`/`unuse`, events, `fork`, `reset`, `dispose` | the composition root | * `ServiceRegistry` keeps the services it was given (`S`) apart from the ones it added (`A`), and resolves both. You rarely write `A`; it shows up in inferred module types, for example `ServiceRegistry<{ db: Database }, { invoices: InvoiceRepository }>`. A container is a registry that was given nothing: `DIContainer` is a `ServiceRegistry<{}, S>`. * `IDIContainer` still exists as a deprecated alias of `ServiceRegistry<{}, S>`. * The chain keeps its type: `new DIContainer().addInstance(…).use(…)` compiles (0.x lost `use()` after the first `add*`). * `get('SomeNamespace')`, `getParent()` and the middleware/event `container` are read-only providers. * `DIContainer` has one type parameter now: `DIContainer`. Modules no longer need to be generic. Declare only what the module needs and apply it with `extend()`: ```ts // before const addBilling = (c: IDIContainer) => c.addSingleton('invoices', construct(InvoiceRepository), ['db']); // after const addBilling = (c: ServiceRegistry<{ db: Database }>) => c.addSingleton('invoices', InvoiceRepository, ['db']); app.extend(addBilling); // type error if `db` is not registered ``` ## Namespace types A namespace is typed now. `get('Billing')` returns a `ServiceProvider` of the namespace's services instead of an untyped container, and `NamespaceServices` returns those services. In the service map, the namespace name holds a `Namespace` marker (`AsyncNamespace` in an `AsyncDIContainer`) instead of a container type. The map keeps every namespaced service once, under its full key, so nested namespaces don't multiply the type and exported containers keep small `.d.ts` files. ```ts // before: an IDIContainer> type Billing = ContainerServices['Billing']; // after type Billing = NamespaceServices; // { invoices: InvoiceRepository } app.get('Billing'); // ServiceProvider<{ invoices: InvoiceRepository }> app.get('Billing.invoices'); // InvoiceRepository, as before ``` `ServiceType` is what resolving `K` from a service map `S` returns: the registered type, or a provider for a namespace key. ## Classes are registered directly Classes are detected and constructed with `new`; `construct()` is optional. ```ts // before app.addSingleton('users', construct(UserRepository), ['db']); // after app.addSingleton('users', UserRepository, ['db']); ``` Keep `construct()` for classes compiled to ES5 (Babel for old browsers, TypeScript `target: es5`) and bound classes: they look like plain functions and cannot be detected. ## Optional dependencies: `optional(key)` The magic `'undefined'` dependency key is gone (and `'undefined'` is an ordinary key now). Use `optional(key)` for "the service if it is registered", or a function dependency for a fixed value. ```ts // before app.addSingleton('mailer', construct(Mailer), ['undefined']); // after app.addSingleton('mailer', Mailer, [optional('transport')]); app.addSingleton('mailer', Mailer, [() => undefined]); ``` ## Renamed options and removed constructor argument | 0.x | 1.0 | | ---------------------------------------------- | ------------------------------------------------------ | | `get(key, { allowUnresolved: true })` | `get(key, { optional: true })` | | `has(key, false)` | `has(key, { local: true })` | | `new DIContainer({ parentContainer: parent })` | `parent.fork()` | | `createProxyAccessor(c, { allowUnresolved })` | `createProxyAccessor(c, { optional })` | | `import DIContainer from 'injecute'` | `import { DIContainer } from 'injecute'` | | `ArgumentsKey` | `ServiceKey` (deprecated alias kept) | | `DependenciesToTypes` | `ResolveDependencies` (deprecated alias kept) | | `Events` | `ContainerEvents` | `createProxyAccessor()` objects now support `in`, `Object.keys()` and spreading; writes throw `The proxy accessor is read-only.` ## Removed type exports These helper types are gone; most were internal or unused: `Callable`, `Func`, `Constructor`, `CallableResult`, `ValueOf`, `Empty`, `Flatten`, `Merge`, `KeyForValueOfType`, `ValuesOfType`, `ArgumentsTypes`, `Dependencies`, `TypesToKeys`, `PromisedProperties`, `CallableOf`, `ArgumentsResolver`, `InjecuteOptions`, `WithNamespace`, `MapOf`, `ResolversMapKeys`, `Resolve`, `Resolver`, `IDIContainerExtension`, `ContainerOwnServices`, `ContainerParentServices`, `DIContainerConstructorArguments`, `OptionalDependencySkipKey` and `optionalDependencySkipKey`. Use `Factory`, `Produced`, `ResolveDependencies`, `ContainerServices` and the option types (`SingletonOptions`, `InstanceOptions`, `ForkOptions`, …) instead. A module function is simply `(c: ServiceRegistry<{ … }>) => …`. ## Errors: `InjecuteError` with codes Every error injecute throws is an `InjecuteError` with a stable `code` (for example `INJECUTE_NOT_REGISTERED`), the resolution `path`, a `docs` link, and a message that says how to fix it. `CircularDependencyError` is a subclass. Branch on `code` instead of matching message text; messages changed and may change again. ```ts try { app.get('users'); } catch (error) { if ( error instanceof InjecuteError && error.code === 'INJECUTE_NOT_REGISTERED' ) { // error.path: ['users', 'db'], error.docs: https://masyaka.github.io/injecute/errors/not-registered } } ``` Errors thrown by your factories, and rejections of the promises they return, are wrapped in `INJECUTE_RESOLUTION_FAILED` with the original error as `cause`, so the message shows which service needed it. Check `error.cause` if you relied on catching your own error types from `get()`. `dispose()` failures reject with `INJECUTE_DISPOSE_FAILED` (the individual errors are in `cause`, an `AggregateError`). ## `preload()` returns a promise `preload()` now returns a promise that settles once async services (factories that return promises) are created, and rejects with the first failure. In 0.x a failing async service became an unhandled rejection. Synchronous failures still throw immediately. ```ts // before preload(app); // after await preload(app); ``` ## Async services A singleton whose factory rejected is no longer cached: the next `get()` calls the factory again. `dispose()` releases the resolved value of async singletons. `AsyncDIContainer` was never exported in 0.x (an `import { AsyncDIContainer } from 'injecute'` failed). In 1.0 it is public, with its own module and consumer types; see [Async services](../guide/async.md). --- --- url: https://masyaka.github.io/injecute/changelog.md --- # injecute ## 1.0.0 ### Major Changes * [#12](https://github.com/Masyaka/injecute/pull/12) [`e4adcaf`](https://github.com/Masyaka/injecute/commit/e4adcaf40f9637942ea7d82740657116c8005c48) Thanks [@Masyaka](https://github.com/Masyaka)! - **New resolution core.** Registrations are plain data and the container that runs a factory owns its result. * `null` and `undefined` are valid service values (0.x threw "No service registered"). [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#null-and-undefined-are-valid-service-values) * A dependency on the key being registered resolves the previous definition, so a child can decorate a parent service: `fork().addSingleton('logger', (l) => wrap(l), ['logger'])`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#decorating-a-service-with-a-self-dependency) * Middlewares run once per `get()` instead of once per container level. * `extend()` accepts extensions that return a child container, as documented. * `keys` lists overridden keys once; `fork()` keeps the container subclass; registration no longer mutates the `dependencies` array you pass; the circular-dependency check is linear instead of exponential. * **Removed:** `flatten()` (use `fork({ isolated: true })`), `getFactory()`, `entryTypeKey`, `firstResult`, the `EntryType` type (now `RegistrationKind`) and the `beforeResolving` / `afterResolving` / `beforeReplaced` options. New: `getRegistration(key)`. The `replace` event reports `previous: RegistrationInfo`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#flatten-is-removed) * [#12](https://github.com/Masyaka/injecute/pull/12) [`f0b3583`](https://github.com/Masyaka/injecute/commit/f0b35833c1d78adfbd58e2cf71a9337edc38f838) Thanks [@Masyaka](https://github.com/Masyaka)! - **Errors with codes, paths and fixes.** Every error is an `InjecuteError` with a stable `code`, the resolution `path`, a `docs` link and a hint on how to fix it; unknown keys get "did you mean" suggestions. Errors thrown by factories are wrapped as `INJECUTE_RESOLUTION_FAILED` with the original as `cause`. Classes that cannot be detected (ES5-compiled, bound) are reported as `INJECUTE_CLASS_NOT_CONSTRUCTED` with a `construct()` hint, on V8, JavaScriptCore and SpiderMonkey. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#errors-injecuteerror-with-codes) * [#12](https://github.com/Masyaka/injecute/pull/12) [`9d3875c`](https://github.com/Masyaka/injecute/commit/9d3875ca9b81db2ab4ea1724bdabf20902c51a82) Thanks [@Masyaka](https://github.com/Masyaka)! - **ESM only.** injecute now ships a single ES module build and requires Node.js ≥ 22 (`require('injecute')` keeps working there through `require(esm)`). The package works unchanged in Deno, Bun, browsers and CDNs (esm.sh, jsDelivr), and ships its TypeScript sources so go-to-definition lands on real code. See [Module format: ESM only](https://masyaka.github.io/injecute/migration/0.x-to-1.0#module-format-esm-only). **Removed the `utils` object.** Import helpers by name instead: `utils.construct(X)` → `construct(X)`. The object broke the published type declarations for projects with `skipLibCheck: false`. See [The utils object is removed](https://masyaka.github.io/injecute/migration/0.x-to-1.0#the-utils-object-is-removed). * [#15](https://github.com/Masyaka/injecute/pull/15) [`f655b3e`](https://github.com/Masyaka/injecute/commit/f655b3ed3ce68eb3c30a4ccf8086f6a1ca26043c) Thanks [@Masyaka](https://github.com/Masyaka)! - **A fork needs `replace: true` to override a parent's key.** Registering a key that a parent container has now throws `INJECUTE_ALREADY_REGISTERED` unless `{ replace: true }` is passed, in plain forks, isolated forks, modules applied to a fork and `namespace()` callbacks. Before, the fork registered it silently, and services registered in the parent kept the parent's instance without anything saying so. The error now says what the override reaches: in a plain fork it names the parent services that depend on the key (directly or through other services) and keep the parent's instance, and how to give them the fork's one. `addAlias()` takes `{ replace: true }` too. Before → after: `app.fork().addInstance('logger', requestLogger)` → `app.fork().addInstance('logger', requestLogger, { replace: true })`. Decorating a parent's service in a fork needs it as well. See [A fork needs `replace: true` to override a parent's key](https://masyaka.github.io/injecute/migration/0.x-to-1.0#a-fork-needs-replace-true-to-override-a-parent-s-key). * [#12](https://github.com/Masyaka/injecute/pull/12) [`5065414`](https://github.com/Masyaka/injecute/commit/5065414c36841eab4d76dde2a6ff95f2c32ec996) Thanks [@Masyaka](https://github.com/Masyaka)! - **Middlewares: `(key, next, context)`.** Middlewares no longer get the container as `this` (arrow functions work); `context` carries `container`, the resolution `path` and `depth`. `next()` continues, `next(otherKey)` redirects. Forks inherit middlewares live, `fork({ skipResolvers: true })` is now `fork({ middlewares: false })`, and `unuse(middleware)` removes one. `use()` returns the container itself. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#middleware-signature-key-next-context) * [#12](https://github.com/Masyaka/injecute/pull/12) [`e398e8c`](https://github.com/Masyaka/injecute/commit/e398e8ccf1bd688bc208da52765b0be8c0cf3446) Thanks [@Masyaka](https://github.com/Masyaka)! - **Typed namespaces with small declaration files.** `get('Billing')` returns a `ServiceProvider` of the namespace's services (it was an untyped container), also for nested namespaces. In the service map, the namespace name now holds a `Namespace` marker (`AsyncNamespace` for async containers), and each namespaced service is stored once, under its full key. Nested namespaces no longer multiply the container type, so an exported container with three levels of namespaces emits a `.d.ts` about 4× smaller. `ContainerServices['Billing']` → `NamespaceServices`. New types: `Namespace`, `AsyncNamespace`, `ServiceType`, `ServicesInNamespace`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#namespace-types) * [#12](https://github.com/Masyaka/injecute/pull/12) [`f1c73da`](https://github.com/Masyaka/injecute/commit/f1c73da5349853e6812a387236d6e304f1a2fca4) Thanks [@Masyaka](https://github.com/Masyaka)! - **Node.js 22.12 or newer.** The minimum Node.js version is now 22.12, the first 22.x release where `require()` loads ES modules without a flag, so CommonJS code can `require('injecute')`. ES module imports worked on older 22.x releases, but `require()` didn't. Before → after: Node.js ≥ 22 → Node.js ≥ 22.12. See [Module format: ESM only](https://masyaka.github.io/injecute/migration/0.x-to-1.0#module-format-esm-only). * [#17](https://github.com/Masyaka/injecute/pull/17) [`dbcfd55`](https://github.com/Masyaka/injecute/commit/dbcfd552d1a5d95152e6762e2fa8c34a7dfadda4) Thanks [@Masyaka](https://github.com/Masyaka)! - **`replace: true` needs something to replace.** Registering a key with `{ replace: true }` when neither the container nor a parent has that key now throws the new `INJECUTE_NOTHING_TO_REPLACE` error. Before, it quietly registered a new service, so a typo in a test override, or a module that wasn't applied, left the original in place without anything saying so. This applies to `addSingleton`, `addTransient`, `addInstance` and `addAlias`, in every kind of container, module and namespace. Before → after: `new DIContainer().addInstance('config', cfg, { replace: true })` (first registration) → `new DIContainer().addInstance('config', cfg)`. See [A fork needs `replace: true` to override a parent's key](https://masyaka.github.io/injecute/migration/0.x-to-1.0#a-fork-needs-replace-true-to-override-a-parent-s-key). * [#12](https://github.com/Masyaka/injecute/pull/12) [`d5391ce`](https://github.com/Masyaka/injecute/commit/d5391ce18b2813bae5df75bcdff04622b2e869f6) Thanks [@Masyaka](https://github.com/Masyaka)! - **`preload()` returns a promise** that settles once async services are created and rejects with the first failure (before, a failing async service became an unhandled rejection). Synchronous failures still throw immediately. `preload(app)` → `await preload(app)`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#preload-returns-a-promise) * [#12](https://github.com/Masyaka/injecute/pull/12) [`4989f19`](https://github.com/Masyaka/injecute/commit/4989f19fcd2690c6795bd9fd5f6912a48cc92285) Thanks [@Masyaka](https://github.com/Masyaka)! - **Container roles and a new type layer.** `ServiceProvider` (resolve + inspect) ⊂ `ServiceRegistry` (+ register) ⊂ `DIContainer` (+ middlewares, events, fork, reset, dispose). The chain keeps its type, so `.use()` works after `.add*()`; modules can be plain functions over `ServiceRegistry<{ …what they need }>` and `extend()` reports missing services by name; namespace providers, `getParent()` and middleware/event containers are read-only. `IDIContainer` is a deprecated alias of `ServiceRegistry`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#container-roles-serviceprovider-serviceregistry-dicontainer) **Classes are registered directly** (`addSingleton('users', UserRepository, ['db'])`); `construct()` stays for ES5-compiled and bound classes. **`optional(key)`** replaces the `'undefined'` dependency key. **Better type errors:** a typo in a dependency key is reported on the key with "Did you mean", and errors show flat service maps instead of nested helper types. **Renamed:** `get(key, { allowUnresolved })` → `{ optional }`, `has(key, false)` → `has(key, { local: true })`, `new DIContainer({ parentContainer })` → `parent.fork()`, `ArgumentsKey` → `ServiceKey`, `DependenciesToTypes` → `ResolveDependencies`. The default export and unused helper types are removed. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#renamed-options-and-removed-constructor-argument) ### Minor Changes * [#12](https://github.com/Masyaka/injecute/pull/12) [`636bb1e`](https://github.com/Masyaka/injecute/commit/636bb1e169e6bf64e72d8f214f52636533de6030) Thanks [@Masyaka](https://github.com/Masyaka)! - **Docs and an Agent Skill in the package.** The package now contains its documentation as Markdown (`llms.txt` and `lib/docs/`: guides, concepts, error codes, the migration guide) and an Agent Skill (`skills/injecute`), so coding agents read the docs of the installed version. See [Using injecute with AI agents](https://masyaka.github.io/injecute/guide/ai-agents). * [#12](https://github.com/Masyaka/injecute/pull/12) [`d5391ce`](https://github.com/Masyaka/injecute/commit/d5391ce18b2813bae5df75bcdff04622b2e869f6) Thanks [@Masyaka](https://github.com/Masyaka)! - **`AsyncDIContainer`.** A container for graphs with async services: every dependency is awaited before a factory runs, so factories and classes receive resolved values without `defer()`, and a factory returning `Promise` registers `T`. `get()`, `call()`, `injecute()` and resolvers return promises (a missing key rejects instead of throwing). Forks stay async, singletons are created once under concurrent resolutions and retried after a failure, and `dispose()` releases resolved instances. Modules for it take the new `AsyncServiceRegistry`, consumers the new `AsyncServiceProvider`; the sync `ServiceRegistry` / `ServiceProvider` types don't accept it. `buildServicesGraph()` and `preload()` accept async containers. See [Async services](https://masyaka.github.io/injecute/guide/async). * [#12](https://github.com/Masyaka/injecute/pull/12) [`fb1c878`](https://github.com/Masyaka/injecute/commit/fb1c878224e34b55c17ac79b6314fe4a537256e7) Thanks [@Masyaka](https://github.com/Masyaka)! - **Async singletons are retried, wrapped and disposed.** When a singleton factory returns a promise, the container caches the promise as before, but: * a rejected promise is no longer cached forever: the next `get()` calls the factory again; * the rejection is wrapped in `INJECUTE_RESOLUTION_FAILED` with the resolution `path` (the original error is `cause`), like errors thrown synchronously. Check `error.cause` if you caught your own error types from an awaited `get()`; * `dispose()` releases the **resolved** instance, not the promise, so `addSingleton('db', async () => new Pool())` closes the pool. A singleton still being created is awaited first; one whose promise rejected is skipped. `dispose` functions in the options receive the resolved value, and their parameter is typed that way (also for `addInstance` promises registered with `dispose`). * [#12](https://github.com/Masyaka/injecute/pull/12) [`51bc8b2`](https://github.com/Masyaka/injecute/commit/51bc8b2f92793af50788f6483c79b7cff3926980) Thanks [@Masyaka](https://github.com/Masyaka)! - **Smaller browser bundles: error hints are left out of production builds.** Bundlers that build for browsers now leave the "how to fix it" hints out of injecute's error messages, which saves about 1.5 kB gzipped. Development builds with Vite and webpack keep them, through the `development` condition, and so do Node.js, Deno, Bun, server-side rendering and browsers without a bundler. Messages keep what happened and the docs link, and `error.code` and `error.docs` are the same everywhere. To have the hints in any build, import the new `injecute/hints` entry point, for example `if (import.meta.env.DEV) await import('injecute/hints')`, or `import 'injecute/hints'` for every build. With esbuild or Rolldown, adding the `development` condition works too. See [Hints in browser bundles](https://masyaka.github.io/injecute/errors/#hints-in-browser-bundles). * [#12](https://github.com/Masyaka/injecute/pull/12) [`2e93631`](https://github.com/Masyaka/injecute/commit/2e93631847084bd53a37ec93b05e296eff0a12f3) Thanks [@Masyaka](https://github.com/Masyaka)! - **`checkWiring()`: check every service's wiring in one test.** `await checkWiring(container)` reports every dependency key that nothing registers, without running any factory. With `{ resolve: true }` it also creates every service and reports the ones whose factory throws. Each failure is reported once, with the services that need it, and its factory runs once. `{ keys }` adds keys that must be registered, for services that the compiler can't see (found in files or registered by decorators). It rejects with a `WiringError` (`INJECUTE_WIRING_FAILED`) that lists every problem in its message and in `error.problems`. See [Check the wiring](https://masyaka.github.io/injecute/guide/testing#check-the-wiring). * [#12](https://github.com/Masyaka/injecute/pull/12) [`af752e8`](https://github.com/Masyaka/injecute/commit/af752e8ad00d15ae7046b4f9dc5fcf353ffb92ee) Thanks [@Masyaka](https://github.com/Masyaka)! - **`dispose()` and `await using`.** `await container.dispose()` (also `[Symbol.asyncDispose]`, so `await using scope = app.fork()` works) releases every instance the container owns, dependents first. Singletons are disposed with `[Symbol.asyncDispose]` / `[Symbol.dispose]` when they have one, or with a `dispose` function in their registration options (`dispose: false` opts out). `addInstance` values are disposed only with `{ dispose: true | fn }`. Namespace containers are disposed with their parent; forks dispose what they own. Afterwards the container throws on use, and a `dispose` event is emitted. * [#12](https://github.com/Masyaka/injecute/pull/12) [`e398e8c`](https://github.com/Masyaka/injecute/commit/e398e8ccf1bd688bc208da52765b0be8c0cf3446) Thanks [@Masyaka](https://github.com/Masyaka)! - **Faster type-checking.** Nothing changes in how you write registrations. * Code inside `namespace()` and inline `extend()` callbacks typechecks up to about 3× faster: a callback's registry keeps the container's services as one fixed type and grows only its own additions, so lookups into the container are computed once per callback. * Each `namespace()` call no longer gets slower with the number of namespaces before it. * Long `addSingleton` chains need about a third fewer type instantiations. * What a module (`extend()`) or namespace adds becomes one object in the service map instead of one entry per registration: registrations and forks after the modules typecheck about 25–45% faster, and hovers and declaration files show one object per module. A `namespace()` or `extend()` callback must return the registry it received, as the types always intended; returning another container is now a type error. `ServiceRegistry` now keeps the given services (`S`) apart from its own additions (`A`), which shows up in inferred module types; a `DIContainer` is a `ServiceRegistry<{}, S>`. See [Container roles](https://masyaka.github.io/injecute/migration/0.x-to-1.0#container-roles-serviceprovider-serviceregistry-dicontainer). **Exported containers.** The TypeScript guide now shows naming the service map with `interface AppServices extends ContainerServices {}` for packages that export a container: declaration files get about half the size. See [TypeScript](https://masyaka.github.io/injecute/guide/typescript#exporting-a-container-from-a-package). * [#12](https://github.com/Masyaka/injecute/pull/12) [`d663616`](https://github.com/Masyaka/injecute/commit/d663616d90fb3261f30a300b1f171a7c0a062b39) Thanks [@Masyaka](https://github.com/Masyaka)! - **`fork({ isolated: true })`.** An isolated fork runs and caches every service it resolves, including services registered in its parents. Overriding a dependency in the fork (for example a fake database in a test) therefore reaches the whole graph, while the parent stays untouched. Namespaces are re-created inside the fork, and forks of an isolated fork share its instances. Replaces the removed `flatten()`. * [#12](https://github.com/Masyaka/injecute/pull/12) [`95d907d`](https://github.com/Masyaka/injecute/commit/95d907db4a3efd07f13d16944d72f5da4d90e9ad) Thanks [@Masyaka](https://github.com/Masyaka)! - **Lifecycle hooks: `startLifecycle()` and `createLifecycle()`.** Modules can now run code when the app starts and undo it when it stops, without the composition root knowing each of them. A hook is a singleton registered under a stage key, `addSingleton(lifecycle.start('consumer'), (consumer) => { consumer.start(); return () => consumer.stop(); }, ['consumer'])`. `const running = await startLifecycle(app)` runs the stages `init` → `start` → `ready`, including hooks in namespaces. `running.stop()` undoes them in reverse, then disposes the container. A failed start undoes what ran. Options: `concurrent`, `dispose: false`, `signal` (timeouts, also for `stop()`) and `onError`. Apps can define their own stages with `createLifecycle([...])`; stages are tags, so `collect(lifecycle.start)` works too. `startable(key, factory, deps, { start, stop })` registers a service and its hook in one call (in a `DIContainer` or an `AsyncDIContainer`), hooks receive the start signal by depending on `startSignal`, hook types are checked when they are registered, and `onHook` reports each hook and undo with its duration. See [Startup and shutdown](https://masyaka.github.io/injecute/guide/startup-shutdown). New error codes: `INJECUTE_INVALID_HOOK` (a hook isn't a singleton, or depends on another hook), `INJECUTE_UNDO_FAILED` (an undo function failed or didn't finish, in the `cause` of `INJECUTE_DISPOSE_FAILED`) and `INJECUTE_LIFECYCLE_STOPPED` (starting a stopped lifecycle). A key that ends with `:init`, `:start` or `:ready` is a hook when you run `startLifecycle()`; rename such keys if they aren't hooks. * [#12](https://github.com/Masyaka/injecute/pull/12) [`e398e8c`](https://github.com/Masyaka/injecute/commit/e398e8ccf1bd688bc208da52765b0be8c0cf3446) Thanks [@Masyaka](https://github.com/Masyaka)! - **`seal()`.** `app.seal()` ends the registrations of a container: afterwards `add*`, `namespace()` and `extend()` throw the new `INJECUTE_SEALED` error, and the returned type (`SealedDIContainer` / `SealedAsyncDIContainer`) has no registration methods. Resolving, middlewares, events, `reset()` and `dispose()` keep working, and forks stay open, so request scopes and tests fork the sealed root. The sealed type holds the services as one object instead of an intersection of every registration, so forks built on it typecheck about 25% faster and hovers stay readable. See [Sealing the composition root](https://masyaka.github.io/injecute/guide/containers#sealing-the-composition-root). * [#12](https://github.com/Masyaka/injecute/pull/12) [`95d907d`](https://github.com/Masyaka/injecute/commit/95d907db4a3efd07f13d16944d72f5da4d90e9ad) Thanks [@Masyaka](https://github.com/Masyaka)! - **Extension points with tags: `createTag()` and `collect()`.** A tag names an extension point that modules contribute services to. `const route = createTag('route').of()`; a module registers `addSingleton(route('orders'), (db) => ordersRoute(db), ['db'])` (the key `'orders:route'`), and the service is checked against `Route` at compile time; the owner depends on `collect(route)` and receives a `Route[]` of every service under the tag, including namespaces (`Orders.orders:route`), in registration order. Services are collected in the container that creates the owner, so isolated forks see their replacements and additions; an `AsyncDIContainer` awaits them. See [Extension points with tags](https://masyaka.github.io/injecute/guide/tags). `DependencyInfo` (from `getRegistration()`) has a new variant, `{ type: 'collect', tag }`: code that switches over every dependency type should handle it. Typing: keys built by a tag are `TaggedKey`s. In the service map they are plain strings, and `get()`, `call()`, `createResolver()`, dependency lists and `optional()` accept them. `addSingleton`, `addTransient`, `addInstance` and `addAlias` check what is registered under them against the tag's type. `Factory` has an optional fourth type parameter, the type the factory must produce. ### Patch Changes * [#12](https://github.com/Masyaka/injecute/pull/12) [`b421ab3`](https://github.com/Masyaka/injecute/commit/b421ab3085b4e58f62fe3e66b5cb4005df19afc3) Thanks [@Masyaka](https://github.com/Masyaka)! - **Docs: adapting a module's dependencies.** A new section in [Containers, forks and modules](https://masyaka.github.io/injecute/guide/containers#adapting-a-module-s-dependencies) shows how to apply a module whose dependencies the host has under other keys or in another shape: register adapters (`addAlias()`, or a factory that derives the value) in a namespace before `extend()`. The Agent Skill follows the same advice. * [#12](https://github.com/Masyaka/injecute/pull/12) [`e398e8c`](https://github.com/Masyaka/injecute/commit/e398e8ccf1bd688bc208da52765b0be8c0cf3446) Thanks [@Masyaka](https://github.com/Masyaka)! - **`replace: true` changes a service's type.** Registering a key again with `replace: true` now gives the service the new type; before, the old and the new type were intersected, so replacing a `string` with a `number` made `get()` return `never`. In a fork, pass `replace: true` when an override changes the type. A module that replaces a service of the container it is applied to still intersects the types. See [Decorating a service](https://masyaka.github.io/injecute/guide/registration#decorating-a-service). * [#12](https://github.com/Masyaka/injecute/pull/12) [`007441e`](https://github.com/Masyaka/injecute/commit/007441e272452a09ca5c190cbc8254b9a829e29f) Thanks [@Masyaka](https://github.com/Masyaka)! - **New guides: request context and per-request state.** * [Request context](https://masyaka.github.io/injecute/guide/request-context): pass request data that services need (trace id, tenant, user) through a context accessor backed by `AsyncLocalStorage`. The host registers it first in the root container, and singletons read it when they use it. Covers Fastify, queue consumers, logging, pitfalls and testing. * [Per-request state](https://masyaka.github.io/injecute/guide/request-state): patterns to try before a fork per request, each with a tested example: * an argument * a factory with `using` (a unit of work) * one instance per context (caches, DataLoaders) * a strategy chosen by the tenant It also says when a fork is still the right tool, with a table for choosing. The docs shipped in the package, the Agent Skill and the `fork()` TSDoc follow the same advice. ## 1.0.0-rc.2 ### Major Changes * [#15](https://github.com/Masyaka/injecute/pull/15) [`f655b3e`](https://github.com/Masyaka/injecute/commit/f655b3ed3ce68eb3c30a4ccf8086f6a1ca26043c) Thanks [@Masyaka](https://github.com/Masyaka)! - **A fork needs `replace: true` to override a parent's key.** Registering a key that a parent container has now throws `INJECUTE_ALREADY_REGISTERED` unless `{ replace: true }` is passed, in plain forks, isolated forks, modules applied to a fork and `namespace()` callbacks. Before, the fork registered it silently, and services registered in the parent kept the parent's instance without anything saying so. The error now says what the override reaches: in a plain fork it names the parent services that depend on the key (directly or through other services) and keep the parent's instance, and how to give them the fork's one. `addAlias()` takes `{ replace: true }` too. Before → after: `app.fork().addInstance('logger', requestLogger)` → `app.fork().addInstance('logger', requestLogger, { replace: true })`. Decorating a parent's service in a fork needs it as well. See [A fork needs `replace: true` to override a parent's key](https://masyaka.github.io/injecute/migration/0.x-to-1.0#a-fork-needs-replace-true-to-override-a-parent-s-key). * [#12](https://github.com/Masyaka/injecute/pull/12) [`f1c73da`](https://github.com/Masyaka/injecute/commit/f1c73da5349853e6812a387236d6e304f1a2fca4) Thanks [@Masyaka](https://github.com/Masyaka)! - **Node.js 22.12 or newer.** The minimum Node.js version is now 22.12, the first 22.x release where `require()` loads ES modules without a flag, so CommonJS code can `require('injecute')`. ES module imports worked on older 22.x releases, but `require()` didn't. Before → after: Node.js ≥ 22 → Node.js ≥ 22.12. See [Module format: ESM only](https://masyaka.github.io/injecute/migration/0.x-to-1.0#module-format-esm-only). * [#17](https://github.com/Masyaka/injecute/pull/17) [`dbcfd55`](https://github.com/Masyaka/injecute/commit/dbcfd552d1a5d95152e6762e2fa8c34a7dfadda4) Thanks [@Masyaka](https://github.com/Masyaka)! - **`replace: true` needs something to replace.** Registering a key with `{ replace: true }` when neither the container nor a parent has that key now throws the new `INJECUTE_NOTHING_TO_REPLACE` error. Before, it quietly registered a new service, so a typo in a test override, or a module that wasn't applied, left the original in place without anything saying so. This applies to `addSingleton`, `addTransient`, `addInstance` and `addAlias`, in every kind of container, module and namespace. Before → after: `new DIContainer().addInstance('config', cfg, { replace: true })` (first registration) → `new DIContainer().addInstance('config', cfg)`. See [A fork needs `replace: true` to override a parent's key](https://masyaka.github.io/injecute/migration/0.x-to-1.0#a-fork-needs-replace-true-to-override-a-parent-s-key). ### Minor Changes * [#12](https://github.com/Masyaka/injecute/pull/12) [`51bc8b2`](https://github.com/Masyaka/injecute/commit/51bc8b2f92793af50788f6483c79b7cff3926980) Thanks [@Masyaka](https://github.com/Masyaka)! - **Smaller browser bundles: error hints are left out of production builds.** Bundlers that build for browsers now leave the "how to fix it" hints out of injecute's error messages, which saves about 1.5 kB gzipped. Development builds with Vite and webpack keep them, through the `development` condition, and so do Node.js, Deno, Bun, server-side rendering and browsers without a bundler. Messages keep what happened and the docs link, and `error.code` and `error.docs` are the same everywhere. To have the hints in any build, import the new `injecute/hints` entry point, for example `if (import.meta.env.DEV) await import('injecute/hints')`, or `import 'injecute/hints'` for every build. With esbuild or Rolldown, adding the `development` condition works too. See [Hints in browser bundles](https://masyaka.github.io/injecute/errors/#hints-in-browser-bundles). * [#12](https://github.com/Masyaka/injecute/pull/12) [`2e93631`](https://github.com/Masyaka/injecute/commit/2e93631847084bd53a37ec93b05e296eff0a12f3) Thanks [@Masyaka](https://github.com/Masyaka)! - **`checkWiring()`: check every service's wiring in one test.** `await checkWiring(container)` reports every dependency key that nothing registers, without running any factory. With `{ resolve: true }` it also creates every service and reports the ones whose factory throws. Each failure is reported once, with the services that need it, and its factory runs once. `{ keys }` adds keys that must be registered, for services that the compiler can't see (found in files or registered by decorators). It rejects with a `WiringError` (`INJECUTE_WIRING_FAILED`) that lists every problem in its message and in `error.problems`. See [Check the wiring](https://masyaka.github.io/injecute/guide/testing#check-the-wiring). ## 1.0.0-rc.1 ### Minor Changes * [#12](https://github.com/Masyaka/injecute/pull/12) [`95d907d`](https://github.com/Masyaka/injecute/commit/95d907db4a3efd07f13d16944d72f5da4d90e9ad) Thanks [@Masyaka](https://github.com/Masyaka)! - **Lifecycle hooks: `startLifecycle()` and `createLifecycle()`.** Modules can now run code when the app starts and undo it when it stops, without the composition root knowing each of them. A hook is a singleton registered under a stage key, `addSingleton(lifecycle.start('consumer'), (consumer) => { consumer.start(); return () => consumer.stop(); }, ['consumer'])`. `const running = await startLifecycle(app)` runs the stages `init` → `start` → `ready`, including hooks in namespaces. `running.stop()` undoes them in reverse, then disposes the container. A failed start undoes what ran. Options: `concurrent`, `dispose: false`, `signal` (timeouts, also for `stop()`) and `onError`. Apps can define their own stages with `createLifecycle([...])`; stages are tags, so `collect(lifecycle.start)` works too. `startable(key, factory, deps, { start, stop })` registers a service and its hook in one call (in a `DIContainer` or an `AsyncDIContainer`), hooks receive the start signal by depending on `startSignal`, hook types are checked when they are registered, and `onHook` reports each hook and undo with its duration. See [Startup and shutdown](https://masyaka.github.io/injecute/guide/startup-shutdown). New error codes: `INJECUTE_INVALID_HOOK` (a hook isn't a singleton, or depends on another hook), `INJECUTE_UNDO_FAILED` (an undo function failed or didn't finish, in the `cause` of `INJECUTE_DISPOSE_FAILED`) and `INJECUTE_LIFECYCLE_STOPPED` (starting a stopped lifecycle). A key that ends with `:init`, `:start` or `:ready` is a hook when you run `startLifecycle()`; rename such keys if they aren't hooks. * [#12](https://github.com/Masyaka/injecute/pull/12) [`95d907d`](https://github.com/Masyaka/injecute/commit/95d907db4a3efd07f13d16944d72f5da4d90e9ad) Thanks [@Masyaka](https://github.com/Masyaka)! - **Extension points with tags: `createTag()` and `collect()`.** A tag names an extension point that modules contribute services to. `const route = createTag('route').of()`; a module registers `addSingleton(route('orders'), (db) => ordersRoute(db), ['db'])` (the key `'orders:route'`), and the service is checked against `Route` at compile time; the owner depends on `collect(route)` and receives a `Route[]` of every service under the tag, including namespaces (`Orders.orders:route`), in registration order. Services are collected in the container that creates the owner, so isolated forks see their replacements and additions; an `AsyncDIContainer` awaits them. See [Extension points with tags](https://masyaka.github.io/injecute/guide/tags). `DependencyInfo` (from `getRegistration()`) has a new variant, `{ type: 'collect', tag }`: code that switches over every dependency type should handle it. Typing: keys built by a tag are `TaggedKey`s. In the service map they are plain strings, and `get()`, `call()`, `createResolver()`, dependency lists and `optional()` accept them. `addSingleton`, `addTransient`, `addInstance` and `addAlias` check what is registered under them against the tag's type. `Factory` has an optional fourth type parameter, the type the factory must produce. ### Patch Changes * [#12](https://github.com/Masyaka/injecute/pull/12) [`b421ab3`](https://github.com/Masyaka/injecute/commit/b421ab3085b4e58f62fe3e66b5cb4005df19afc3) Thanks [@Masyaka](https://github.com/Masyaka)! - **Docs: adapting a module's dependencies.** A new section in [Containers, forks and modules](https://masyaka.github.io/injecute/guide/containers#adapting-a-module-s-dependencies) shows how to apply a module whose dependencies the host has under other keys or in another shape: register adapters (`addAlias()`, or a factory that derives the value) in a namespace before `extend()`. The Agent Skill follows the same advice. ## 1.0.0-rc.0 ### Major Changes * [#11](https://github.com/Masyaka/injecute/pull/11) [`e4adcaf`](https://github.com/Masyaka/injecute/commit/e4adcaf40f9637942ea7d82740657116c8005c48) Thanks [@github-actions](https://github.com/apps/github-actions)! - **New resolution core.** Registrations are plain data and the container that runs a factory owns its result. * `null` and `undefined` are valid service values (0.x threw "No service registered"). [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#null-and-undefined-are-valid-service-values) * A dependency on the key being registered resolves the previous definition, so a child can decorate a parent service: `fork().addSingleton('logger', (l) => wrap(l), ['logger'])`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#decorating-a-service-with-a-self-dependency) * Middlewares run once per `get()` instead of once per container level. * `extend()` accepts extensions that return a child container, as documented. * `keys` lists overridden keys once; `fork()` keeps the container subclass; registration no longer mutates the `dependencies` array you pass; the circular-dependency check is linear instead of exponential. * **Removed:** `flatten()` (use `fork({ isolated: true })`), `getFactory()`, `entryTypeKey`, `firstResult`, the `EntryType` type (now `RegistrationKind`) and the `beforeResolving` / `afterResolving` / `beforeReplaced` options. New: `getRegistration(key)`. The `replace` event reports `previous: RegistrationInfo`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#flatten-is-removed) * [#11](https://github.com/Masyaka/injecute/pull/11) [`f0b3583`](https://github.com/Masyaka/injecute/commit/f0b35833c1d78adfbd58e2cf71a9337edc38f838) Thanks [@github-actions](https://github.com/apps/github-actions)! - **Errors with codes, paths and fixes.** Every error is an `InjecuteError` with a stable `code`, the resolution `path`, a `docs` link and a hint on how to fix it; unknown keys get "did you mean" suggestions. Errors thrown by factories are wrapped as `INJECUTE_RESOLUTION_FAILED` with the original as `cause`. Classes that cannot be detected (ES5-compiled, bound) are reported as `INJECUTE_CLASS_NOT_CONSTRUCTED` with a `construct()` hint, on V8, JavaScriptCore and SpiderMonkey. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#errors-injecuteerror-with-codes) * [#11](https://github.com/Masyaka/injecute/pull/11) [`9d3875c`](https://github.com/Masyaka/injecute/commit/9d3875ca9b81db2ab4ea1724bdabf20902c51a82) Thanks [@github-actions](https://github.com/apps/github-actions)! - **ESM only.** injecute now ships a single ES module build and requires Node.js ≥ 22 (`require('injecute')` keeps working there through `require(esm)`). The package works unchanged in Deno, Bun, browsers and CDNs (esm.sh, jsDelivr), and ships its TypeScript sources so go-to-definition lands on real code. See [Module format: ESM only](https://masyaka.github.io/injecute/migration/0.x-to-1.0#module-format-esm-only). **Removed the `utils` object.** Import helpers by name instead: `utils.construct(X)` → `construct(X)`. The object broke the published type declarations for projects with `skipLibCheck: false`. See [The utils object is removed](https://masyaka.github.io/injecute/migration/0.x-to-1.0#the-utils-object-is-removed). * [#11](https://github.com/Masyaka/injecute/pull/11) [`5065414`](https://github.com/Masyaka/injecute/commit/5065414c36841eab4d76dde2a6ff95f2c32ec996) Thanks [@github-actions](https://github.com/apps/github-actions)! - **Middlewares: `(key, next, context)`.** Middlewares no longer get the container as `this` (arrow functions work); `context` carries `container`, the resolution `path` and `depth`. `next()` continues, `next(otherKey)` redirects. Forks inherit middlewares live, `fork({ skipResolvers: true })` is now `fork({ middlewares: false })`, and `unuse(middleware)` removes one. `use()` returns the container itself. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#middleware-signature-key-next-context) * [#11](https://github.com/Masyaka/injecute/pull/11) [`e398e8c`](https://github.com/Masyaka/injecute/commit/e398e8ccf1bd688bc208da52765b0be8c0cf3446) Thanks [@github-actions](https://github.com/apps/github-actions)! - **Typed namespaces with small declaration files.** `get('Billing')` returns a `ServiceProvider` of the namespace's services (it was an untyped container), also for nested namespaces. In the service map, the namespace name now holds a `Namespace` marker (`AsyncNamespace` for async containers), and each namespaced service is stored once, under its full key. Nested namespaces no longer multiply the container type, so an exported container with three levels of namespaces emits a `.d.ts` about 4× smaller. `ContainerServices['Billing']` → `NamespaceServices`. New types: `Namespace`, `AsyncNamespace`, `ServiceType`, `ServicesInNamespace`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#namespace-types) * [#11](https://github.com/Masyaka/injecute/pull/11) [`d5391ce`](https://github.com/Masyaka/injecute/commit/d5391ce18b2813bae5df75bcdff04622b2e869f6) Thanks [@github-actions](https://github.com/apps/github-actions)! - **`preload()` returns a promise** that settles once async services are created and rejects with the first failure (before, a failing async service became an unhandled rejection). Synchronous failures still throw immediately. `preload(app)` → `await preload(app)`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#preload-returns-a-promise) * [#11](https://github.com/Masyaka/injecute/pull/11) [`4989f19`](https://github.com/Masyaka/injecute/commit/4989f19fcd2690c6795bd9fd5f6912a48cc92285) Thanks [@github-actions](https://github.com/apps/github-actions)! - **Container roles and a new type layer.** `ServiceProvider` (resolve + inspect) ⊂ `ServiceRegistry` (+ register) ⊂ `DIContainer` (+ middlewares, events, fork, reset, dispose). The chain keeps its type, so `.use()` works after `.add*()`; modules can be plain functions over `ServiceRegistry<{ …what they need }>` and `extend()` reports missing services by name; namespace providers, `getParent()` and middleware/event containers are read-only. `IDIContainer` is a deprecated alias of `ServiceRegistry`. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#container-roles-serviceprovider-serviceregistry-dicontainer) **Classes are registered directly** (`addSingleton('users', UserRepository, ['db'])`); `construct()` stays for ES5-compiled and bound classes. **`optional(key)`** replaces the `'undefined'` dependency key. **Better type errors:** a typo in a dependency key is reported on the key with "Did you mean", and errors show flat service maps instead of nested helper types. **Renamed:** `get(key, { allowUnresolved })` → `{ optional }`, `has(key, false)` → `has(key, { local: true })`, `new DIContainer({ parentContainer })` → `parent.fork()`, `ArgumentsKey` → `ServiceKey`, `DependenciesToTypes` → `ResolveDependencies`. The default export and unused helper types are removed. [Migration](https://masyaka.github.io/injecute/migration/0.x-to-1.0#renamed-options-and-removed-constructor-argument) ### Minor Changes * [#11](https://github.com/Masyaka/injecute/pull/11) [`636bb1e`](https://github.com/Masyaka/injecute/commit/636bb1e169e6bf64e72d8f214f52636533de6030) Thanks [@github-actions](https://github.com/apps/github-actions)! - **Docs and an Agent Skill in the package.** The package now contains its documentation as Markdown (`llms.txt` and `lib/docs/`: guides, concepts, error codes, the migration guide) and an Agent Skill (`skills/injecute`), so coding agents read the docs of the installed version. See [Using injecute with AI agents](https://masyaka.github.io/injecute/guide/ai-agents). * [#11](https://github.com/Masyaka/injecute/pull/11) [`d5391ce`](https://github.com/Masyaka/injecute/commit/d5391ce18b2813bae5df75bcdff04622b2e869f6) Thanks [@github-actions](https://github.com/apps/github-actions)! - **`AsyncDIContainer`.** A container for graphs with async services: every dependency is awaited before a factory runs, so factories and classes receive resolved values without `defer()`, and a factory returning `Promise` registers `T`. `get()`, `call()`, `injecute()` and resolvers return promises (a missing key rejects instead of throwing). Forks stay async, singletons are created once under concurrent resolutions and retried after a failure, and `dispose()` releases resolved instances. Modules for it take the new `AsyncServiceRegistry`, consumers the new `AsyncServiceProvider`; the sync `ServiceRegistry` / `ServiceProvider` types don't accept it. `buildServicesGraph()` and `preload()` accept async containers. See [Async services](https://masyaka.github.io/injecute/guide/async). * [#11](https://github.com/Masyaka/injecute/pull/11) [`fb1c878`](https://github.com/Masyaka/injecute/commit/fb1c878224e34b55c17ac79b6314fe4a537256e7) Thanks [@github-actions](https://github.com/apps/github-actions)! - **Async singletons are retried, wrapped and disposed.** When a singleton factory returns a promise, the container caches the promise as before, but: * a rejected promise is no longer cached forever: the next `get()` calls the factory again; * the rejection is wrapped in `INJECUTE_RESOLUTION_FAILED` with the resolution `path` (the original error is `cause`), like errors thrown synchronously. Check `error.cause` if you caught your own error types from an awaited `get()`; * `dispose()` releases the **resolved** instance, not the promise, so `addSingleton('db', async () => new Pool())` closes the pool. A singleton still being created is awaited first; one whose promise rejected is skipped. `dispose` functions in the options receive the resolved value, and their parameter is typed that way (also for `addInstance` promises registered with `dispose`). * [#11](https://github.com/Masyaka/injecute/pull/11) [`af752e8`](https://github.com/Masyaka/injecute/commit/af752e8ad00d15ae7046b4f9dc5fcf353ffb92ee) Thanks [@github-actions](https://github.com/apps/github-actions)! - **`dispose()` and `await using`.** `await container.dispose()` (also `[Symbol.asyncDispose]`, so `await using scope = app.fork()` works) releases every instance the container owns, dependents first. Singletons are disposed with `[Symbol.asyncDispose]` / `[Symbol.dispose]` when they have one, or with a `dispose` function in their registration options (`dispose: false` opts out). `addInstance` values are disposed only with `{ dispose: true | fn }`. Namespace containers are disposed with their parent; forks dispose what they own. Afterwards the container throws on use, and a `dispose` event is emitted. * [#11](https://github.com/Masyaka/injecute/pull/11) [`e398e8c`](https://github.com/Masyaka/injecute/commit/e398e8ccf1bd688bc208da52765b0be8c0cf3446) Thanks [@github-actions](https://github.com/apps/github-actions)! - **Faster type-checking.** Nothing changes in how you write registrations. * Code inside `namespace()` and inline `extend()` callbacks typechecks up to about 3× faster: a callback's registry keeps the container's services as one fixed type and grows only its own additions, so lookups into the container are computed once per callback. * Each `namespace()` call no longer gets slower with the number of namespaces before it. * Long `addSingleton` chains need about a third fewer type instantiations. * What a module (`extend()`) or namespace adds becomes one object in the service map instead of one entry per registration: registrations and forks after the modules typecheck about 25–45% faster, and hovers and declaration files show one object per module. A `namespace()` or `extend()` callback must return the registry it received, as the types always intended; returning another container is now a type error. `ServiceRegistry` now keeps the given services (`S`) apart from its own additions (`A`), which shows up in inferred module types; a `DIContainer` is a `ServiceRegistry<{}, S>`. See [Container roles](https://masyaka.github.io/injecute/migration/0.x-to-1.0#container-roles-serviceprovider-serviceregistry-dicontainer). **Exported containers.** The TypeScript guide now shows naming the service map with `interface AppServices extends ContainerServices {}` for packages that export a container: declaration files get about half the size. See [TypeScript](https://masyaka.github.io/injecute/guide/typescript#exporting-a-container-from-a-package). * [#11](https://github.com/Masyaka/injecute/pull/11) [`d663616`](https://github.com/Masyaka/injecute/commit/d663616d90fb3261f30a300b1f171a7c0a062b39) Thanks [@github-actions](https://github.com/apps/github-actions)! - **`fork({ isolated: true })`.** An isolated fork runs and caches every service it resolves, including services registered in its parents. Overriding a dependency in the fork (for example a fake database in a test) therefore reaches the whole graph, while the parent stays untouched. Namespaces are re-created inside the fork, and forks of an isolated fork share its instances. Replaces the removed `flatten()`. * [#11](https://github.com/Masyaka/injecute/pull/11) [`e398e8c`](https://github.com/Masyaka/injecute/commit/e398e8ccf1bd688bc208da52765b0be8c0cf3446) Thanks [@github-actions](https://github.com/apps/github-actions)! - **`seal()`.** `app.seal()` ends the registrations of a container: afterwards `add*`, `namespace()` and `extend()` throw the new `INJECUTE_SEALED` error, and the returned type (`SealedDIContainer` / `SealedAsyncDIContainer`) has no registration methods. Resolving, middlewares, events, `reset()` and `dispose()` keep working, and forks stay open, so request scopes and tests fork the sealed root. The sealed type holds the services as one object instead of an intersection of every registration, so forks built on it typecheck about 25% faster and hovers stay readable. See [Sealing the composition root](https://masyaka.github.io/injecute/guide/containers#sealing-the-composition-root). ### Patch Changes * [#11](https://github.com/Masyaka/injecute/pull/11) [`e398e8c`](https://github.com/Masyaka/injecute/commit/e398e8ccf1bd688bc208da52765b0be8c0cf3446) Thanks [@github-actions](https://github.com/apps/github-actions)! - **`replace: true` changes a service's type.** Registering a key again with `replace: true` now gives the service the new type; before, the old and the new type were intersected, so replacing a `string` with a `number` made `get()` return `never`. In a fork, pass `replace: true` when an override changes the type. A module that replaces a service of the container it is applied to still intersects the types. See [Decorating a service](https://masyaka.github.io/injecute/guide/registration#decorating-a-service). * [#11](https://github.com/Masyaka/injecute/pull/11) [`007441e`](https://github.com/Masyaka/injecute/commit/007441e272452a09ca5c190cbc8254b9a829e29f) Thanks [@github-actions](https://github.com/apps/github-actions)! - **New guides: request context and per-request state.** * [Request context](https://masyaka.github.io/injecute/guide/request-context): pass request data that services need (trace id, tenant, user) through a context accessor backed by `AsyncLocalStorage`. The host registers it first in the root container, and singletons read it when they use it. Covers Fastify, queue consumers, logging, pitfalls and testing. * [Per-request state](https://masyaka.github.io/injecute/guide/request-state): patterns to try before a fork per request, each with a tested example: * an argument * a factory with `using` (a unit of work) * one instance per context (caches, DataLoaders) * a strategy chosen by the tenant It also says when a fork is still the right tool, with a table for choosing. The docs shipped in the package, the Agent Skill and the `fork()` TSDoc follow the same advice. --- --- url: https://masyaka.github.io/injecute/guide/versioning.md description: >- What counts as a breaking change, which TypeScript versions and runtimes are supported, and how releases are published. --- # Versioning and support injecute follows [semantic versioning](https://semver.org). Breaking changes only happen in a major version, and each one comes with a before/after entry in a migration guide. ## What the public API is * Everything exported from `injecute` (the package root), with the behaviour its docs describe, and the `injecute/hints` entry point. * The TypeScript types of those exports, following the [Semantic Versioning for TypeScript Types](https://www.semver-ts.org) policy: a change that makes correct code stop compiling is breaking. Fixing a type so that it rejects code that already failed at runtime is a bug fix. * Error `code` values (`INJECUTE_*`). New codes can be added in minor versions. Error **messages** are not part of the API: they get better over time, so branch on `code`. Not part of the API: anything marked `@internal`, files imported by path (`injecute/lib/…`), and the exact text of messages and hints, including whether a browser bundle prints the hints (see [Hints in browser bundles](../errors/index.md#hints-in-browser-bundles)). Every export of the package root, with its type and the members of its classes and interfaces, is recorded in a snapshot that the test suite compares on every change. An API change can't slip in by accident: it updates the snapshot on purpose, with a changeset, and a migration note when it breaks. ## Deprecations A renamed API keeps working under its old name, marked `@deprecated` with the replacement, until the next major version. Your editor shows the deprecation and the replacement. ## Support | Version | Fixes | | ---------------------- | -------------------------------------------------------------------- | | The latest major (1.x) | bug and security fixes, in the latest minor version | | The previous major | bug and security fixes for **6 months** after the next major ships | | 0.x | none: upgrade with the [migration guide](../migration/0.x-to-1.0.md) | Fixes for a previous major are released as patch versions of its latest minor. Report security issues privately, through [GitHub security advisories](https://github.com/Masyaka/injecute/security/advisories/new), not as an issue. ## TypeScript TypeScript **5.2 and newer**, checked in CI against 5.2 and the latest release, and weekly against the next one. The minimum is raised only in a minor release, announced in the changelog. ## Runtimes Node.js **22.12 and newer**, current Deno and Bun, and browsers that support ES2022 modules. Raising the minimum Node.js version is a breaking change. Why 22.12: injecute is a single ES module. Node.js 22.12 is the first 22.x release where `require()` loads ES modules without a flag, so CommonJS code can still `require('injecute')`. Node.js 20 reached its end of life in April 2026. CI runs the tests on Node.js 22, 24 and 26, and the package smoke tests (`import` and `require()`) on 22.12, the minimum. ## Releases Every release is published from GitHub Actions to [npm](https://www.npmjs.com/package/injecute) (with provenance) and [JSR](https://jsr.io/@masyaka/injecute), with notes in the [changelog](../changelog.md) and on [GitHub Releases](https://github.com/Masyaka/injecute/releases). Release candidates use the npm dist-tag `rc`: `npm install injecute@rc`. --- --- url: https://masyaka.github.io/injecute/errors/already-registered.md description: The key is already registered in this container or a parent --- # `INJECUTE_ALREADY_REGISTERED` **The key is already registered in this container or a parent.** ## How to fix it Pass { replace: true } to replace the registration, or use another key. In a fork, { replace: true } is needed to override a parent's key too, and the override reaches only services resolved in the fork: services registered in the parent keep the parent's one. To replace it for every service, use fork({ isolated: true }). ## Example ```ts app.addInstance('logger', console).addInstance('logger', pino()); // throws app.addInstance('logger', pino(), { replace: true }); // replaces it app.fork().addInstance('logger', pino()); // throws: the parent has "logger" app.fork().addInstance('logger', pino(), { replace: true }); // overrides it in the fork ``` ## Overriding a parent's key in a fork A fork needs `{ replace: true }` to register a key its parent has, because the override doesn't reach everything. In a plain fork it reaches only the services resolved in the fork: a service registered in the parent runs and is cached in the parent, so it keeps the parent's instance. The error names those services: ```ts const app = new DIContainer() .addSingleton('tx', (db) => db.begin(), ['db']) .addSingleton('report', Report, ['tx']); app.fork().addSingleton('tx', (db) => db.begin(), ['db']); // throws: "report" (registered in a parent) depends on "tx" and keeps the parent's "tx" ``` What to do depends on what you want: * **Only the fork's own services should use the new one:** pass `{ replace: true }`. * **A parent service should use it too:** register that service in the fork as well (for a scope's services, in a [scope module](../guide/request-state.md#scope-modules)), and don't register the key in the root. * **Every service should use it** (tests, a variant of the app): use an [isolated fork](../guide/containers.md#isolated-forks), with `{ replace: true }`. To wrap the existing service instead of replacing it, depend on its own key (see [Decorating a service](../guide/registration.md#decorating-a-service)). ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_ALREADY_REGISTERED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/circular-dependency.md description: Services depend on each other in a cycle --- # `INJECUTE_CIRCULAR_DEPENDENCY` **Services depend on each other in a cycle.** ## How to fix it Break the cycle: extract the shared part into its own service, or resolve one side lazily with a function dependency (() => container.get(key)). ## Example ```ts app .addSingleton('a', (b) => new A(b), ['b']) .addSingleton('b', (a) => new B(a), ['a']); // throws: a -> *b* -> a ``` ## Breaking the cycle * Move what both services need into a third service that neither depends on the other. * Resolve one side lazily with a function dependency, so it is only resolved when used: ```ts app.addSingleton('b', (getA) => new B(getA), [() => () => app.get('a')]); ``` ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_CIRCULAR_DEPENDENCY') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/class-not-constructed.md description: A class was called without new --- # `INJECUTE_CLASS_NOT_CONSTRUCTED` **A class was called without new.** ## How to fix it Classes compiled to ES5 (Babel for old browsers, TypeScript target: es5) and bound classes cannot be detected. Register them with construct(MyClass). ## Why it happens Native classes are detected and constructed with `new`. Two kinds of classes look like plain functions: * classes **compiled to ES5** (Babel targeting old browsers, TypeScript `target: es5`, prebuilt ES5 libraries in `node_modules`); * **bound** classes (`MyClass.bind(null)`). The container calls them without `new`, and the engine throws, for example "Class constructor X cannot be invoked without 'new'" (V8), "Cannot call a class constructor without |new|" (JavaScriptCore) or "Cannot call a class as a function" (Babel). ## Fix ```ts import { construct } from 'injecute'; app.addSingleton('client', construct(LegacyClient), ['config']); ``` An ES5 constructor that has no prototype methods and never uses `this` cannot be detected at all: it resolves to `undefined`. Use `construct()` for it too. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_CLASS_NOT_CONSTRUCTED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/dispose-failed.md description: Some services failed to dispose --- # `INJECUTE_DISPOSE_FAILED` **Some services failed to dispose.** ## How to fix it Every other service was still disposed. The failures are in `cause` (an AggregateError). ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_DISPOSE_FAILED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/disposed.md description: The container is disposed --- # `INJECUTE_DISPOSED` **The container is disposed.** ## How to fix it Create a new container (or fork) instead of using one after dispose(). ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_DISPOSED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/extension-result.md description: An extension returned a container that is not this one or its child --- # `INJECUTE_EXTENSION_RESULT` **An extension returned a container that is not this one or its child.** ## How to fix it A module function must return the registry it received (after adding services) or a fork of it. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_EXTENSION_RESULT') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/invalid-dependency.md description: A dependency is not a key, optional(key), collect(tag) or a function --- # `INJECUTE_INVALID_DEPENDENCY` **A dependency is not a key, optional(key), collect(tag) or a function.** ## How to fix it Dependencies are service keys, optional(key), collect(tag) with a tag made by createTag(), or functions that return the value. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_INVALID_DEPENDENCY') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/invalid-factory.md description: The factory is not a function or class --- # `INJECUTE_INVALID_FACTORY` **The factory is not a function or class.** ## How to fix it Pass a function that creates the service, a class, or use addInstance() for a ready value. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_INVALID_FACTORY') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/invalid-hook.md description: A lifecycle hook is not registered correctly --- # `INJECUTE_INVALID_HOOK` **A lifecycle hook is not registered correctly.** ## How to fix it A key that ends with `:` is a lifecycle hook. Register it with addSingleton() under `lifecycle.(name)`, and let it depend on services, never on other hooks: order hooks with stages. Rename a key that is not meant as a hook. ## When it happens `startLifecycle()` checks every lifecycle hook before it runs any of them, and rejects with this code when one isn't registered correctly. Nothing has run, and the container is left as it is. A key that ends with `:` (`:init`, `:start`, `:ready`, or a stage of your own) is a hook: * **It isn't a singleton:** ```text "app:ready" ends with ":ready", so it is a lifecycle hook, but it is an instance. Rename it, or register the hook with addSingleton(). ``` Register hooks with `addSingleton(lifecycle.ready('probe'), …)`. If the key isn't meant as a hook, rename it so it doesn't end with a stage name. * **It depends on another hook**, directly, through the services it depends on, or by collecting a stage: ```text Lifecycle hook "server:start" depends on the lifecycle hook "schema:init" (through "db"). Hooks depend on services; order them with stages. ``` The hook would run as a dependency, outside the stage order, and its undo function would run in the wrong order. Depend on the service instead, and let the stages order the effects: hooks of `init` finish before `start` begins. See [Startup and shutdown](../guide/startup-shutdown.md). ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_INVALID_HOOK') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/invalid-option.md description: An option is not supported here --- # `INJECUTE_INVALID_OPTION` **An option is not supported here.** ## How to fix it Transient services are not kept by the container, so they cannot have a dispose option. createTag() and createLifecycle() take unique, non-empty names without ":" or "."; startLifecycle() takes a lifecycle made by createLifecycle() and only its stages in "concurrent". ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_INVALID_OPTION') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/lifecycle-stopped.md description: The lifecycle of this container was stopped --- # `INJECUTE_LIFECYCLE_STOPPED` **The lifecycle of this container was stopped.** ## How to fix it A lifecycle runs once per container. To start again, create a new container or an isolated fork. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_LIFECYCLE_STOPPED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/namespace-conflict.md description: The namespace name is already used by a service --- # `INJECUTE_NAMESPACE_CONFLICT` **The namespace name is already used by a service.** ## How to fix it Choose another namespace name, or remove the service with that key. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_NAMESPACE_CONFLICT') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/namespace-result.md description: A namespace callback returned something other than a new container --- # `INJECUTE_NAMESPACE_RESULT` **A namespace callback returned something other than a new container.** ## How to fix it Return the registry the callback received (after adding services), a fork of it, or a new DIContainer. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_NAMESPACE_RESULT') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/no-previous-definition.md description: A service depends on itself but there is nothing to decorate --- # `INJECUTE_NO_PREVIOUS_DEFINITION` **A service depends on itself but there is nothing to decorate.** ## How to fix it A dependency on the key being registered resolves its previous definition (the replaced one, or the parent one). Register the original service first. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_NO_PREVIOUS_DEFINITION') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/not-a-function.md description: call() was used on a service that is not a function --- # `INJECUTE_NOT_A_FUNCTION` **call() was used on a service that is not a function.** ## How to fix it Only function services can be called with call(); use get() for other services. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_NOT_A_FUNCTION') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/not-registered.md description: No service is registered under this key --- # `INJECUTE_NOT_REGISTERED` **No service is registered under this key.** ## How to fix it Register it (addSingleton / addTransient / addInstance) before resolving it, check the key for typos, or pass { optional: true } to get undefined instead. ## Common causes * **A typo in the key.** The message suggests similar keys ("Did you mean …?"). * **The service is registered in a fork**, and you resolve it from the parent. Forks see their parents' services, not the other way around. * **Registration order in a module** that is applied later: register the dependency first, or declare it in the module's `ServiceRegistry<{ … }>` type so `extend()` checks it. * **A dependency of another service**: the message's `Resolving:` line shows which service needed it. If the service is genuinely optional, depend on `optional('key')` or call `get('key', { optional: true })`. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_NOT_REGISTERED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/nothing-to-replace.md description: A registration replaces a key that nothing is registered under --- # `INJECUTE_NOTHING_TO_REPLACE` **A registration replaces a key that nothing is registered under.** ## How to fix it replace: true replaces a registration of this container or a parent. Nothing has this key, which usually means a registration is missing: a module that was not applied, or a typo in the key. Register the original first, or remove replace: true if this is the first registration. ## Example ```ts const app = new DIContainer().addSingleton('db', createDb); app.fork({ isolated: true }).addInstance('dbb', fakeDb, { replace: true }); // throws: a typo app.fork({ isolated: true }).addInstance('db', fakeDb, { replace: true }); // replaces "db" ``` ## Why it throws `replace: true` says that something is registered under the key. When nothing is, the replacement would quietly become a new service that nothing uses, and the original you meant to replace would still be in place. Common causes: * a typo in the key; * a module that should register the original wasn't applied, or is applied after the replacement: apply modules first, then replace; * a test override for a service the app no longer has. If this is the first registration of the key, remove `replace: true`. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_NOTHING_TO_REPLACE') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/read-only.md description: The object is read-only --- # `INJECUTE_READ_ONLY` **The object is read-only.** ## How to fix it Proxy accessors only read services. Register or replace services on the container. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_READ_ONLY') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/resolution-failed.md description: A factory threw (or its promise rejected) while creating a service --- # `INJECUTE_RESOLUTION_FAILED` **A factory threw (or its promise rejected) while creating a service.** ## How to fix it The original error is in `cause`; `path` shows which service needed it. ## Reading it The message starts with `Failed to create "": `, and the `Resolving:` line shows the chain of services that led to it. The original error is `error.cause`: ```ts try { app.get('users'); } catch (error) { if ( error instanceof InjecuteError && error.code === 'INJECUTE_RESOLUTION_FAILED' ) { console.error(error.path, error.cause); } } ``` Errors that are already `InjecuteError`s (for example a missing dependency deeper in the graph) are not wrapped again. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_RESOLUTION_FAILED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/sealed.md description: The container is sealed --- # `INJECUTE_SEALED` **The container is sealed.** ## How to fix it Register every service before seal(). To add services later (per request, per test), register them in a fork: app.fork().addInstance(...). ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_SEALED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/undo-failed.md description: A lifecycle hook could not be undone --- # `INJECUTE_UNDO_FAILED` **A lifecycle hook could not be undone.** ## How to fix it Its undo function threw, rejected, or did not finish before the stop signal aborted. The hook is in `path`, the original error in `cause`. ## Where you see it `running.stop()` runs every undo function, even when some fail, then rejects with `INJECUTE_DISPOSE_FAILED`. Each failed undo is an `INJECUTE_UNDO_FAILED` in `cause.errors`, with the hook in `path`: ```ts try { await running.stop({ signal: AbortSignal.timeout(25_000) }); } catch (error) { for (const failure of (error as InjecuteError).cause.errors) { if (failure.code === 'INJECUTE_UNDO_FAILED') { console.error(failure.path[0], failure.cause); // which hook, and why } } } ``` After a failed start, the rollback reports them to `onError` with `during: 'rollback'`. An undo that "did not finish before the signal aborted" keeps running in the background. See [Startup and shutdown](../guide/startup-shutdown.md#timeouts-and-cancellation). ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_UNDO_FAILED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/unknown-event.md description: Unknown event name --- # `INJECUTE_UNKNOWN_EVENT` **Unknown event name.** ## How to fix it Supported events: add, replace, reset, get, produce, dispose. ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_UNKNOWN_EVENT') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/errors/wiring-failed.md description: The wiring check found services that are missing or fail --- # `INJECUTE_WIRING_FAILED` **The wiring check found services that are missing or fail.** ## How to fix it Every problem is listed in the message and in error.problems. Register the missing keys (check for typos and for modules that were not applied), and fix the factories that failed: their errors are in each problem and in cause (an AggregateError). ## Example ```ts const app = new DIContainer() .addSingleton('db', () => connect(process.env.DATABASE_URL)) .addSingleton('users', UserRepository, ['db']) .addSingleton('notifications', Notifications, ['mailer']); // nothing registers "mailer" await checkWiring(app, { resolve: true }); // WiringError: The wiring check found 2 problems: // - "mailer" is not registered. Needed by "notifications". // - "db" failed: DATABASE_URL is not set. Not resolved because of it: "users". ``` ## Reading it * **Not registered:** a dependency key, or a key passed in `keys`, that no container registers. Check the key for typos, and that the module that registers it is applied. The services listed need it, directly or through other services. * **Failed:** resolving the service threw. The services listed depend on it and were skipped, so each failure is reported once. The original error is in the problem's `error`, and every failure is in `cause` (an `AggregateError`). `error.problems` has the same list as data, for example to assert on it: ```ts expect(error.problems).toEqual([ { type: 'missing', key: 'mailer', affected: ['notifications'] }, expect.objectContaining({ type: 'failed', key: 'db', affected: ['users'] }), ]); ``` See [Check the wiring](../guide/testing.md#check-the-wiring). ## Handling it in code ```ts import { InjecuteError } from 'injecute'; try { // ... } catch (error) { if (error instanceof InjecuteError && error.code === 'INJECUTE_WIRING_FAILED') { // error.message explains what happened; error.path is the resolution chain } } ``` [All error codes](./index.md) --- --- url: https://masyaka.github.io/injecute/playground.md description: Edit a container in the browser and see its services graph. ---