I've been doing Dependency Injection in Node.js without decorators for 9 years. Here's why I still think it's the right call.
Source: Dev.to
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. 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. 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. 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. 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. 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. 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. 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. GitHub npm Wiki