A lightweight, lazy, and type-safe dependency injection library for TypeScript — no decorators, no reflection metadata, no framework lock-in. Services are described as plain factories, composed into modules, and created only when they are actually requested, so a Lambda invocation or an app launch pays only for the subgraph it touches.
| Package | Description |
|---|---|
@composed-di/core |
The DI container: keys, factories, modules, selectors, and graph visualization. |
@composed-di/instrumentation-core |
Framework-agnostic observability hooks for service initialization, disposal, and method calls. |
@composed-di/instrumentation-otel |
OpenTelemetry implementation that records service events as spans. |
- Lazy by default — a service is created on its first
get(), never before. Nothing is constructed at startup, which keeps serverless cold starts and mobile launch times proportional to what a given code path actually uses rather than to the size of the graph. - Type-safe —
ServiceKey<T>is a typed token; a factory'sinitializereceives its dependencies fully typed, in declaration order. No strings, noany. - Observability built in — instrument any factory to trace initialization, disposal, and every method call, with opt-in argument/result capture and redaction rules for sensitive services.
- Fail-fast validation —
ServiceModule.from()detects circular dependencies and missing factories at module creation, with readable traces of the broken path. - Explicit lifecycles — lazily-created singletons with deterministic
dispose(), and one-shot (transient) factories. - Async-native —
initializemay return a promise; concurrent requests for an in-flight singleton share the same initialization. - Runtime selection —
SelectorKeygroups multiple implementations of an interface so a service can pick one at runtime. - Visualization — generate Mermaid or Graphviz DOT diagrams of your dependency graph.
npm install @composed-di/core
# optional, for OpenTelemetry tracing of your services:
npm install @composed-di/instrumentation-otel @opentelemetry/apiimport { ServiceKey, ServiceFactory, ServiceModule } from '@composed-di/core'
interface Config {
dbUrl: string
}
interface Database {
query(sql: string): Promise<unknown[]>
close(): void
}
// 1. Declare typed keys — unique tokens that identify each service.
const configKey = new ServiceKey<Config>('Config')
const databaseKey = new ServiceKey<Database>('Database')
// 2. Describe how each service is built and what it depends on.
const configFactory = ServiceFactory.singleton({
provides: configKey,
initialize: () => ({ dbUrl: process.env.DB_URL! }),
})
const databaseFactory = ServiceFactory.singleton({
provides: databaseKey,
dependsOn: [configKey],
initialize: (config) => connectToDatabase(config.dbUrl), // may be async
dispose: (db) => db.close(),
})
// 3. Compose a module. Cycles and missing dependencies throw here, not later.
const module = ServiceModule.from([configFactory, databaseFactory])
// 4. Request services. Config is created first — lazily, exactly once.
const db = await module.get(databaseKey)
// 5. Tear everything down when you're done.
module.dispose()A ServiceKey<T> is a typed token backed by a unique Symbol, so two keys with the same name never collide.
Declare a key with new ServiceKey(...) in the same package as the factory that provides it. Identity travels with the exported object, so no naming discipline is needed, and the package stays self-contained: it exports a single ServiceModule plus its keys, and consumers import both.
// @myapp/data — the whole package surface: one module + its keys.
import type { Pool } from 'pg'
export const connectionKey = new ServiceKey<Pool>('Connection')
export const userRepositoryKey = new ServiceKey<UserRepository>(
'UserRepository',
)
export const invoiceRepositoryKey = new ServiceKey<InvoiceRepository>(
'InvoiceRepository',
)
const connectionFactory = ServiceFactory.singleton({
provides: connectionKey,
initialize: () => new Pool({ connectionString: process.env.DB_URL }),
dispose: (pool) => pool.end(),
})
const userRepositoryFactory = ServiceFactory.singleton({
provides: userRepositoryKey,
dependsOn: [connectionKey],
initialize: (pool) => new PostgresUserRepository(pool),
})
const invoiceRepositoryFactory = ServiceFactory.singleton({
provides: invoiceRepositoryKey,
dependsOn: [connectionKey],
initialize: (pool) => new PostgresInvoiceRepository(pool),
})
export const dataModule = ServiceModule.from([
connectionFactory,
userRepositoryFactory,
invoiceRepositoryFactory,
])// Elsewhere in the app: compose the module, resolve by the imported key.
import { dataModule, userRepositoryKey } from '@myapp/data'
const module = ServiceModule.from([dataModule, notificationsModule])
const users = await module.get(userRepositoryKey)Third-party resources — a database connection, a cache client, an SDK client — are shared the same way. The package that owns the resource declares its key and provides it, as @myapp/data does above; every other package imports that key and depends on it.
Because a module must be complete, a consuming package composes the providing module into its own:
// @myapp/reporting — a different package, the same connection.
import { dataModule, connectionKey } from '@myapp/data'
export const monthlyReportKey = new ServiceKey<MonthlyReport>('MonthlyReport')
const monthlyReportFactory = ServiceFactory.singleton({
provides: monthlyReportKey,
dependsOn: [connectionKey],
initialize: (pool) => new MonthlyReport(pool),
})
export const reportingModule = ServiceModule.from([
dataModule,
monthlyReportFactory,
])Composing both at the application root is fine — dataModule appears twice, the last entry providing a key wins, and one pool is created on first use and shared by everything that asked for it:
const module = ServiceModule.from([dataModule, reportingModule])Depend on the module, not on the factory. @myapp/reporting imports dataModule and connectionKey, never connectionFactory — so when the connection later needs a secrets client or a logger, that dependency is satisfied inside @myapp/data and no consumer changes. Export the factory instead and its dependsOn list becomes public API: every module that registered it has to be tracked down and updated whenever the list grows, and the failure shows up in the consumer's ServiceModule.from() rather than yours.
Exporting a key is what makes a service substitutable. connectionKey is exported, so ServiceModule.from([dataModule, fakePoolFactory]) replaces the pool — last-wins applies across the flattened graph. A key the package does not export cannot be named by a consumer at all, and therefore cannot be overridden: identity is the exported object, not the name, and declaring a second key with the same name produces a different key. Treat the keys you export as the package's substitution contract, and keep the rest internal.
A ServiceModule is always complete: ServiceModule.from() requires every dependsOn to be satisfied within the same call. That is usually easy to arrange — a package that needs configuration can read the environment itself, or depend on a config package that does and exports a module.
It stops working when a package deliberately must not choose. A notifications package used by two applications, one sending through SES and the other through Twilio, cannot export a complete module, because completing it would mean picking one. Export a function that takes the missing module instead.
// @myapp/notifications — declares the contract, provides everything but the transport.
export interface NotificationTransport {
send(to: string, subject: string, body: string): Promise<void>
}
export const transportKey = new ServiceKey<NotificationTransport>('Transport')
export const notifierKey = new ServiceKey<Notifier>('Notifier')
// Internal: not exported, so no consumer can name it or replace it.
const templateRendererKey = new ServiceKey<TemplateRenderer>('TemplateRenderer')
const templateRendererFactory = ServiceFactory.singleton({
provides: templateRendererKey,
initialize: () => new HandlebarsRenderer(),
})
const notifierFactory = ServiceFactory.singleton({
provides: notifierKey,
dependsOn: [transportKey, templateRendererKey],
initialize: (transport, renderer) => new Notifier(transport, renderer),
})
export const makeNotificationsModule = (transport: ServiceModule) =>
ServiceModule.from([transport, templateRendererFactory, notifierFactory])An implementation package exports no keys of its own. Its entire purpose is to satisfy transportKey, and its own dependencies stay internal:
// @myapp/notifications-ses
import { transportKey } from '@myapp/notifications'
import { SESClient } from '@aws-sdk/client-ses'
const sesClientKey = new ServiceKey<SESClient>('SESClient')
const sesClientFactory = ServiceFactory.singleton({
provides: sesClientKey,
initialize: () => new SESClient({ region: process.env.AWS_REGION }),
dispose: (client) => client.destroy(),
})
const sesTransportFactory = ServiceFactory.singleton({
provides: transportKey,
dependsOn: [sesClientKey],
initialize: (client) => new SESTransport(client),
})
export const sesTransportModule = ServiceModule.from([
sesClientFactory,
sesTransportFactory,
])Each application picks its transport at the composition root:
// apps/web
const appModule = ServiceModule.from([
makeNotificationsModule(sesTransportModule),
dataModule,
])
// apps/internal-tools — same package, different transport
const toolsModule = ServiceModule.from([
makeNotificationsModule(twilioTransportModule),
])Note which way the dependency points: @myapp/notifications never imports the AWS SDK, and sesClientKey is unexported, so the client is genuinely internal to the transport package. Tests get the same mechanism for free — makeNotificationsModule(fakeTransportModule), with no override and no ordering to reason about.
The one cost is that transport: ServiceModule promises nothing about which keys it provides. Pass the wrong module and ServiceModule.from() throws naming the unsatisfied key — still fail-fast, but at composition rather than at the call.
Two lifetimes are provided:
ServiceFactory.singleton({...})—initializeruns on the first request; every later request shares the instance. A failed initialization is never cached, and afterdispose()the next request builds a fresh instance.ServiceFactory.oneShot({...})— a fresh instance on every request, with no framework-managed cleanup; the requester owns the instance.
const requestIdFactory = ServiceFactory.oneShot({
provides: requestIdKey,
initialize: () => crypto.randomUUID(), // new value per request
})A singleton that depends on a one-shot factory captures that value for its entire lifetime — the one-shot runs once, during the singleton's initialization, and the result is then shared like any other singleton state. That is usually not what you want from a per-request value.
ServiceModule.from() accepts factories and other modules, flattening them into one container. When two entries provide the same key, the last one wins — handy for overriding real services with fakes in tests:
const testModule = ServiceModule.from([productionModule, fakeDatabaseFactory])module.get(key) resolves a service (throws NoSuchFactoryError if nothing provides the key); module.getOrNull(key) returns null instead for optional services.
A SelectorKey<T> groups several implementations of the same interface. A factory that depends on one receives a Selector<T> and chooses at runtime:
import { Selector, SelectorKey } from '@composed-di/core'
const paymentSelectorKey = new SelectorKey([stripeKey, paypalKey])
class CheckoutService {
constructor(private readonly payments: Selector<PaymentGateway>) {}
async pay(order: Order) {
const gateway = await this.payments.get(
order.method === 'paypal' ? paypalKey : stripeKey,
)
return gateway.charge(order)
}
}
const checkoutFactory = ServiceFactory.singleton({
provides: checkoutKey,
dependsOn: [paymentSelectorKey],
initialize: (payments) => new CheckoutService(payments),
})import { printMermaidGraph, printDotGraph } from '@composed-di/core'
printMermaidGraph(module) // paste into https://mermaid.live/
printDotGraph(module) // paste into a Graphviz viewerWrap your factories (or a whole module) with OTELServiceInstrumentation to get a span for every service initialization, disposal, module resolution, and method call — parented to whatever OTEL context is active, so they slot into your existing traces:
import { ServiceModule } from '@composed-di/core'
import { OTELServiceInstrumentation } from '@composed-di/instrumentation-otel'
const instrumentation = new OTELServiceInstrumentation() // uses the global tracer provider
const module = instrumentation.install(
ServiceModule.from([configFactory, databaseFactory]),
)Nothing is captured by default — runtime values may be large or secret. Opt in per install(), and scrub sensitive services with redaction rules:
import { redactionRule } from '@composed-di/instrumentation-core'
const module = instrumentation.install(baseModule, {
capture: {
arguments: true,
results: true,
redactionRules: [
redactionRule(vaultKey).redactAll().exclude('ping').build(),
redactionRule(billingKey)
.redact('chargeCard', {
maskResult: (card) => `card ending in ${card.number.slice(-4)}`,
})
.build(),
],
},
})To report to something other than OpenTelemetry, extend ServiceInstrumentation from @composed-di/instrumentation-core and implement two hooks (lifecycleSpan, methodCallSpan), each returning an OperationSpan that is notified when the operation finishes. See the @composed-di/instrumentation-core README for the contract, and packages/instrumentation-otel for a complete reference implementation.
If a key is visibly provided by a module you composed and resolution still fails, the module that declares the key was probably evaluated twice. new ServiceKey(...) mints a fresh Symbol on every evaluation, so the two copies are different keys that print the same name. Usual causes:
- the declaring package resolved to two copies in
node_modules(checknpm ls/pnpm why); - the package was loaded once as ESM and once as CJS;
- a dev-server hot reload re-evaluated the key module while a longer-lived container kept the old key.
The fix is to make the declaring module resolve once — dedupe the dependency, pick a single module format, or rebuild the container on reload.
When two bundles genuinely never share a module graph — module federation remotes, a plugin host loading independently built plugins — no import can carry a key between them. ServiceKey.for covers that case: it is backed by Symbol.for, so the same name resolves to the same key in every bundle in the realm.
// Declared identically in the host and in each plugin bundle.
const authContextKey = ServiceKey.for<AuthContext>('@host/shell/AuthContext')Two costs come with it, so prefer an imported new ServiceKey(...) anywhere an import is possible. The name is global to the realm, shared with every other library in the process — namespace it with the package it belongs to, and note that a registry key is reachable, and therefore overridable, by anyone who can guess the string, where an unexported new ServiceKey(...) cannot be named at all. And the type parameter is an unchecked assertion: two declarations of the same name with different T produce one key, and neither the compiler nor the runtime will object.
Not yet implemented, listed here so the direction is public:
- Opt-in eager initialization for singleton factories —
await module.warmup()to build selected singletons up front, so a bad connection string fails while the process is starting rather than on the first request that needs it. Lazy stays the default; long-running servers opt in, serverless and mobile do not. One-shot factories are unaffected, since there is nothing to build ahead of time.
This is a pnpm workspace:
pnpm install # install dependencies
pnpm build # type-check and build all packages (tsc --build)
pnpm test # run the vitest suite
pnpm lint # oxlint
pnpm fmt:check # oxfmt- Bugs and feature requests — open an issue on GitHub.
- API reference — the source is thoroughly documented with TSDoc; start at
packages/core/src/index.ts.
Maintained by Juan Herrera. Contributions are welcome — open an issue to discuss a change before submitting a pull request.