README.md
June 25, 2026 ยท View on GitHub
Node Dependency Injection
Modern TypeScript DI without decorators and without reflect-metadata.
Autowiring, validation, and container compilation for maintainable Node.js services.
Inspired by the battle-tested Symfony DI container.
node-di.dev
Why Node Dependency Injection?
Managing dependencies manually leads to tightly coupled, hard-to-test code. Node Dependency Injection gives you a powerful, flexible IoC container that wires your application together โ keeping your classes clean, your tests simple, and your architecture solid.
If you are comparing options in 2026, the core value is simple: TypeScript autowiring by class, without decorators, without runtime metadata hacks, with compile-time style validation workflows.
โจ Features
| ๐ Autowire โ zero-config DI for TypeScript | ๐ Config files โ YAML, JSON or JS |
| ๐ญ Factory pattern โ flexible service creation | ๐ท๏ธ Service tagging โ group & inject by tag |
| ๐ค Lazy services โ instantiate only when needed | ๐จ Decorators โ wrap services transparently |
| โก Compiler passes โ transform the container at build time | ๐ Private services โ encapsulate internals |
| ๐ณ Parent / Abstract services โ share config via inheritance | ๐งฉ Non-shared services โ new instance per call |
๐ฟ Environment parameters โ %env(VAR)% support | ๐๏ธ Deprecation warnings โ mark services as deprecated |
| ๐ฆ Express middleware โ first-class web framework support | ๐ฅ๏ธ CLI โ inspect & validate your container |
| ๐ Conditional services โ register services based on env or other services | ๐๏ธ Keyed services โ named strategy pattern with registerKeyed |
| ๐ Autowire + Keyed โ inject keyed services via parameter binds |
๐ Why choose NDI over Awilix, InversifyJS or tsyringe?
The goal is not to attack alternatives. This table explains the concrete trade-offs and where NDI is opinionated.
| Feature | node-dependency-injection | InversifyJS | tsyringe | Awilix |
|---|---|---|---|---|
| Config files (YAML / JSON / JS) | โ Native | โ ๏ธ Programmatic-first | โ ๏ธ Programmatic-first | โ ๏ธ Programmatic-first |
| TypeScript autowire without decorators | โ Native | โ | โ | โ |
| Keyed services (named strategy groups) | โ Native | โ ๏ธ Via custom patterns | โ ๏ธ Via tokens/patterns | โ ๏ธ Via aliases/patterns |
| Conditional service registration | โ Native | โ ๏ธ Manual logic | โ ๏ธ Manual logic | โ ๏ธ Manual logic |
| Compiler passes / compile-time transforms | โ Native | โ | โ | โ |
| Service tags and tagged lookup | โ Native | โ ๏ธ Manual metadata patterns | โ ๏ธ Manual token patterns | โ ๏ธ Naming/registration patterns |
| Private services | โ Native | โ ๏ธ Container conventions | โ ๏ธ Container conventions | โ ๏ธ Container conventions |
| CLI container inspection / validation | โ
Native (ndi) | โ | โ | โ |
| Parent / abstract definitions | โ Native | โ ๏ธ Composition-based | โ ๏ธ Composition-based | โ ๏ธ Composition-based |
| Lazy services | โ Native | โ | โ ๏ธ Partial patterns | โ ๏ธ Partial patterns |
Legend: โ built-in, โ ๏ธ possible with custom conventions or extra setup, โ not available as a first-class feature.
๐ Installation
Website: node-di.dev
npm install --save node-dependency-injection
๐ Quick Start
Start with class-based autowiring (the default recommendation):
import { Autowire, ContainerBuilder } from 'node-dependency-injection'
import UserService from '@src/service/UserService'
const container = new ContainerBuilder(false, '/path/to/src')
const autowire = new Autowire(container)
await autowire.process()
await container.compile()
const userService = container.get(UserService)
No decorators. No tokens. No reflect-metadata.
Prefer explicit IDs and programmatic registration? You can still do that:
import { ContainerBuilder } from 'node-dependency-injection'
import Mailer from './services/Mailer'
import ExampleService from './services/ExampleService'
const container = new ContainerBuilder()
container.register('service.example', ExampleService)
container.register('service.mailer', Mailer).addArgument('service.example')
await container.compile()
const mailer = container.get('service.mailer')
๐ Autowire (TypeScript)
Zero-configuration dependency injection โ NDI reads your TypeScript type annotations and wires everything automatically:
import { ContainerBuilder, Autowire } from 'node-dependency-injection'
const container = new ContainerBuilder(false, '/path/to/src')
const autowire = new Autowire(container)
await autowire.process()
await container.compile()
// Retrieve by class โ no string IDs needed
import SomeService from '@src/service/SomeService'
const service = container.get(SomeService)
// Or retrieve by ID with an explicit type
const typedService = container.get<SomeService>('service.some')
Production tip: dump the autowired config to a YAML file and load it directly in prod โ no TypeScript scanning overhead.
if (process.env.NODE_ENV === 'development') {
const autowire = new Autowire(container)
autowire.serviceFile = new ServiceFile('/dist/services.yaml')
await autowire.process()
} else {
const loader = new YamlFileLoader(container)
await loader.load('/dist/services.yaml')
}
await container.compile()
The generated services.yaml uses human-readable service IDs derived from the file path relative to your source root (e.g. Service/Mailer), making it easy to review, audit, and debug your application's service graph:
# services.yaml (human-readable โ default since v4.0)
services:
Service/Mailer:
class: /Service/Mailer
arguments:
- '@Service/Transport'
Service/Transport:
class: /Service/Transport
arguments: []
Service ID strategy
| Strategy | Default | Service ID example | Description |
|---|---|---|---|
readable | โ v4.0+ | Service/Mailer | Path relative to defaultDir, extension stripped |
legacy | v3.x | U2VydmljZS9NYWlsZXI= | Base64-encoded absolute path |
To opt back in to the legacy strategy (e.g. for gradual migration):
const autowire = new Autowire(container)
autowire.makeIdLegacy() // switch back to base64-encoded IDs
๐๏ธ Keyed Services
Keyed services let you register multiple implementations of the same interface under a named group, then retrieve a specific one by key or inject the entire group as a Map.
Programmatic API
import { ContainerBuilder, KeyedReference, KeyedGroupReference } from 'node-dependency-injection'
import StripePayment from './payments/StripePayment'
import PaypalPayment from './payments/PaypalPayment'
import CheckoutService from './CheckoutService'
import PaymentRouter from './PaymentRouter'
const container = new ContainerBuilder()
// Register implementations under the 'payment' group
container.registerKeyed('payment', 'stripe', StripePayment).setDefault(true)
container.registerKeyed('payment', 'paypal', PaypalPayment)
// Inject a specific key
container.register('checkout', CheckoutService)
.addArgument(new KeyedReference('payment', 'stripe'))
// Inject the full group as a Map<key, instance>
container.register('payment.router', PaymentRouter)
.addArgument(new KeyedGroupReference('payment'))
// Retrieve by key or get the default
const stripe = container.getKeyed('payment', 'stripe')
const defaultPayment = container.getKeyed('payment') // returns the .setDefault(true) one
const allPayments = container.getKeyedGroup('payment') // Map { 'stripe' => โฆ, 'paypal' => โฆ }
YAML configuration
services:
payment.stripe:
class: 'payments/StripePayment'
keyed:
group: payment
key: stripe
default: true
payment.paypal:
class: 'payments/PaypalPayment'
keyed:
group: payment
key: paypal
checkout:
class: 'CheckoutService'
arguments: ['@keyed(payment, stripe)']
payment.router:
class: 'PaymentRouter'
arguments: ['@keyed_group(payment)']
Autowire integration
When using Autowire you can inject keyed services into typed constructor parameters by registering a bind whose name matches the parameter name:
// TypeScript service
export default class CheckoutService {
constructor(private readonly payment: IPaymentService) {}
}
export default class PaymentRouter {
constructor(private readonly payments: Map<string, IPaymentService>) {}
}
container.registerKeyed('payment', 'stripe', StripePaymentService)
container.registerKeyed('payment', 'paypal', PaypalPaymentService)
// The bind name must match the constructor parameter name exactly
container.addBind('payment', new KeyedReference('payment', 'stripe'))
container.addBind('payments', new KeyedGroupReference('payment'))
const autowire = new Autowire(container)
await autowire.process()
await container.compile()
// container.get(CheckoutService).payment โ StripePaymentService
// container.get(PaymentRouter).payments โ Map { 'stripe' => โฆ, 'paypal' => โฆ }
Note: binds take priority over type-based resolution. The same mechanism works for scalar values โ
container.addBind('apiKey', '%env(API_KEY)%')โ so keyed service binds fit naturally into the existing bind API.
๐ Configuration Files
Prefer declarative config? Use YAML, JSON or JS:
# services.yaml
services:
service.example:
class: 'services/ExampleService'
service.mailer:
class: 'services/Mailer'
arguments: ['@service.example']
import { ContainerBuilder, YamlFileLoader } from 'node-dependency-injection'
const container = new ContainerBuilder()
const loader = new YamlFileLoader(container)
await loader.load('/path/to/services.yaml')
await container.compile()
const mailer = container.get('service.mailer')
๐ Conditional Services
Register services only when specific conditions are met โ evaluated at compile() time.
import { ContainerBuilder, Condition } from 'node-dependency-injection'
const container = new ContainerBuilder()
// Register only when an environment variable is set
container.register('cache.redis', RedisCache)
.setCondition(Condition.envExists('REDIS_URL'))
// Fallback: register only when another service was NOT registered (TryAdd)
container.register('cache.memory', InMemoryCache)
.whenMissing('cache.redis')
// Register only when a sibling service IS registered
container.register('metrics', Prometheus)
.whenServiceExists('http.server')
// Combine conditions
container.register('feature.x', FeatureX)
.setCondition(Condition.all(
Condition.envEquals('NODE_ENV', 'production'),
Condition.envExists('FEATURE_X_ENABLED')
))
await container.compile()
The same conditions are available declaratively in YAML:
services:
cache.redis:
class: 'services/cache/RedisCache'
when:
env_exists: REDIS_URL
cache.memory:
class: 'services/cache/InMemoryCache'
when:
missing: cache.redis
metrics.prometheus:
class: 'services/metrics/Prometheus'
when:
service_exists: http.server
logger.verbose:
class: 'services/logger/VerboseLogger'
when:
env_equals: { var: LOG_LEVEL, value: debug }
See the Conditional Services wiki page for the full API reference and advanced usage.
๐ฆ Ecosystem
Express Middleware
Use NDI seamlessly with Express โ retrieve the container directly from any request:
npm install --save node-dependency-injection-express-middleware
import NDIMiddleware from 'node-dependency-injection-express-middleware'
import express from 'express'
const app = express()
app.use(new NDIMiddleware({ serviceFilePath: 'services.yaml' }).middleware())
CLI
Inspect and validate your container from the command line:
# Validate a config file
ndi config:check /path/to/services.yaml
# Inspect a specific service
ndi container:service /path/to/services.yaml service.mailer
๐ Documentation
The full documentation lives in the project wiki, including guides on:
- Getting Started
- Autowire
- Keyed Services
- Configuration Files
- Compiler Passes
- Conditional Services
- Tagging Services
- Factory
- Lazy Services
- Decorators
- And much more...
๐ค Contributing
Contributions are welcome! Please read the contribution guide before submitting a pull request.
๐ Credits
Inspired by the Symfony Dependency Injection component โ a special thanks to the Symfony team for their outstanding work.
MIT License ย ยทย Made with โค๏ธ by @zazoomauro