Ok so first, let me be honest. This post is partly me venting.
I've been maintaining node-dependency-injection for about 9 years now. 300 stars on GitHub. Not exactly viral. And every time I look at InversifyJS or tsyringe climbing in popularity I think "yeah, but at what cost".
So let me explain my problem with decorators.
The coupling nobody talks about
When you write this:
@Injectable()
export class UserService {
constructor(@Inject(MAILER_TOKEN) private mailer: IMailer) {}
}
Where does that @Injectable() live? In your DI framework. Which means your UserService — which is domain logic, business rules, the thing that should outlive any framework decision — now has a direct dependency on your IoC container library.
Your domain knows about your infrastructure. That's the wrong direction.
I know, I know. "It's just a decorator, it doesn't do anything". But it's still an import. It's still coupling. And if you ever want to swap the container, or move that service somewhere else, or just test it without spinning up the whole container — you now have to think about it.
With NDI, your service is just a class:
export class UserService {
constructor(private mailer: IMailer) {}
}
That's it. No imports from my library. No decorators. No metadata. The service doesn't know it's being injected. The wiring lives completely outside — in a YAML file or in a bootstrap file. Your domain stays clean.
"But Symfony does decorators and it's fine"
Symfony doesn't use decorators in services actually. The DI config is external — YAML, XML, PHP config files. Your service is just a PHP class. That's literally what inspired NDI from the beginning.
What NDI actually does
Quick example. You have two payment providers and you want to inject the right one based on context:
services:
payment.stripe:
class: 'payments/StripePayment'
keyed:
group: payment
key: stripe
default: true
payment.paypal:
class: 'payments/PaypalPayment'
keyed:
group: payment
key: paypal
checkout.service:
class: 'CheckoutService'
arguments: ['@keyed(payment,stripe)']
// CheckoutService.ts — pure class, zero framework imports
export class CheckoutService {
constructor(private payment: IPaymentService) {}
async process(order: Order) {
return this.payment.charge(order.total)
}
}
The strategy pattern, completely outside the class. You can swap stripe for paypal by changing one line of config. Your CheckoutService literally doesn't care.
Autowire without decorators
This is the thing I'm most proud of honestly. NDI can read your TypeScript constructor types and wire everything automatically — without a single decorator:
// Just normal TypeScript. No imports from NDI.
export default class OrderService {
constructor(
private readonly repo: OrderRepository,
private readonly mailer: MailService
) {}
}
const container = new ContainerBuilder(false, '/src')
const autowire = new Autowire(container)
await autowire.process()
await container.compile()
const orders = container.get(OrderService) // fully wired
It parses the TypeScript AST, finds the constructor params, resolves them by type. No reflect-metadata, no decorators, no nothing. Just types.
Conditional services
One feature I don't see in other containers — register services based on environment:
services:
cache.redis:
class: 'services/RedisCache'
when:
env_exists: REDIS_URL
cache.memory:
class: 'services/InMemoryCache'
when:
missing: cache.redis
In prod you have Redis, the memory cache never gets instantiated. Locally you don't, it falls back. Zero code changes.
Compiler passes
Borrowed from Symfony again. You can transform the container at build time before it compiles:
class AddLoggingPass implements CompilerPass {
async process(container: ContainerBuilder) {
for (const { id, definition } of container.findTaggedServiceIds('loggable')) {
// wrap every tagged service with a logging decorator
definition.setDecorator('logger.decorator')
}
}
}
container.addCompilerPass(new AddLoggingPass())
Try doing that cleanly with @Injectable decorators.
Why 300 stars after 9 years
Honestly? Because the ecosystem trained everyone to associate "TypeScript DI" with decorators. NestJS made it the default. And NestJS is great for many things — if you're all-in on the framework, the DI just comes along.
But if you care about Clean Architecture, hexagonal, keeping domain logic free from infrastructure concerns — decorators in your services is exactly what you're trying to avoid.
NDI is for people who want the DI container to be infrastructure, not something that bleeds into your domain. The wiring should be boring config, not annotations on your business logic.
I've been using this in production for 9 years. Works fine. And my UserService still doesn't know what a container is.