DEV Community

I was sick of InversifyJS after 3 years in production, so I built a better DI container

Ayoub Chrigui on May 11, 2026

Three years. That's how long we ran InversifyJS in production at a medium-sized Typescript SaaS. It worked. But working and being good are two diff...
Collapse
 
arvavit profile image
Vadym Arnaut

The 1114-line binding file you screenshotted is the kind of thing
I keep an eye out for in our FastAPI Python codebase too. Our
equivalent is the dependency registry under app/api/deps.py. We
caught it before it got past 200 lines by treating "if you're
touching this file you're touching three places" as a design smell.

What worked for us: we moved most manually-bound classes to
function-level Depends(get_x) callables. Lower ceremony, the type
checker sees through them. We still have a small central registry
for things with genuinely cross-cutting state (db session, current
user), but it stays small because adding a new service doesn't
require touching it.

Curious about your decorator discovery: how do you handle two
implementations of the same interface (test fake vs prod), where
InversifyJS would let you bind a different concrete class per env?

Collapse
 
ayoub-chrigui profile image
Ayoub Chrigui

Hey Vadym, this is how we handle multiple implementations in lazy-di:

@Abstract()
abstract class PaymentGateway {...}

@Injectable()
@Implements(PaymentGateway, { when: process.env.NODE_ENV !== 'test' })
class StripeGateway extends PaymentGateway { ... }

@Injectable()
@Implements(PaymentGateway, { when: process.env.NODE_ENV === 'test' })
class MockGateway extends PaymentGateway { ... }
Enter fullscreen mode Exit fullscreen mode

The classes can be in different files, of course, but this raises another problem.
Business logic is going to use the abstract class, so the two other implementations might not be imported at all in the code. We fixed this by adding a scan method on a container that automatically scans and imports dependencies, this is how it looks:

const container = Container.create();

const result = await container.scan({ rootDir: "src" });
Enter fullscreen mode Exit fullscreen mode
Collapse
 
arvavit profile image
Vadym Arnaut

scan() makes sense as a workaround for the tree-shaking problem. In
Python this doesn't bite because module imports are eager and
decorators fire at import time. If you import the file that defines
StripeGateway, the @Implements call runs and registers it. So our
equivalent setup needs nothing fancier than a gateways/init.py
that re-exports everything, imported once at app boot.

The TS-specific challenge is bundlers wanting to drop imports they
can't see being used. scan() inverts the import direction (container
pulls implementations in instead of business code pulling them in).
Does it slow cold starts noticeably in a serverless context with a
few hundred decorated classes? That's the part I'd worry about.

Thread Thread
 
ayoub-chrigui profile image
Ayoub Chrigui

Decorators fire at runtime in TypeScript as well.
The idea is that an implementation is not bound to the abstract class directly after importing, that is delegated to the @Implements decorator.

And the @Implements decorator itself decides whether to bind it or not based on the condition passed by the user. So the @Implements can be triggered, but doesn't do anything if the condition provided by the developer is false.

Thread Thread
 
ayoub-chrigui profile image
Ayoub Chrigui

It definitely adds some latency to the cold start, but in our case, it is acceptable.
This is the data from our codebase:

Thread Thread
 
arvavit profile image
Vadym Arnaut

Got it. So container.scan() is essentially what binding files were, just implicit — same coupling, less visible. The Inversify pain wasn't bind-files per se but lack of typed-token enforcement. lazy-di solves the second but trades the first for scan cost. Tradeoff makes sense for app-tier where cold start doesn't matter, less so for serverless. Thanks for the thoughtful answers.

Collapse
 
maxrendel profile image
Viacheslav Kabanov

I understand the frustration with InversifyJS, but looking at the architecture of lazy-di, it seems you were inspired by the worst anti-patterns of popular DI containers. You essentially took all the bad parts of existing solutions while completely ignoring the advanced type-level capabilities of modern TypeScript.

By trying to simplify the syntax, lazy-di inherited the same old "diseases" that make production DI a nightmare:

  1. Ignoring Type Safety (Stringly-typed graphs)
    Relying on manual casting like container.resolve<MyService>('token') defeats the entire purpose of TypeScript. If I change the dependency type or make a typo, TS will happily compile it, and it will crash in runtime.

  2. Implicit Magic (The Proxy Trap)
    Using standard "transparent" proxies for lazy evaluation is an illusion of simplicity. Proxies kill V8 performance by deoptimizing Inline Caches, break instanceof checks, and make console.log debugging a nightmare.

  3. Captive Dependencies (Scope Leaks)
    This is the most critical bug in any DI: injecting a short-lived Scoped or Transient service into a long-lived Singleton. Popular containers let this slip through, turning scoped instances into unintended singletons (causing cross-request data leaks).

  4. Lack of Lifecycle Management
    Modern JS/TS has Explicit Resource Management (using / await using with Symbol.asyncDispose). A mature DI container must own its instances and tear them down in strict LIFO (reverse-creation) order. Without this, you get hanging DB connections and memory leaks.

It’s great that lazy-di is lightweight, but it trades crucial compile-time safety and engine performance for a few lines of less boilerplate. Modern TypeScript can do much better than this.

Collapse
 
ayoub-chrigui profile image
Ayoub Chrigui

Hey Viacheslav Kabanov, I think there’s some confusion. I believe you have looked at the wrong repo.

There is already a DI container called exactly "lazy-di" on npm, but it’s not mine. It’s old and no longer maintained.

My package is called "@lazy-di/core" on npm. Both links (GitHub and npm) are provided at the end of the blog post.

Collapse
 
maxrendel profile image
Viacheslav Kabanov • Edited

Hey Ayoub,
First of all — sorry for the confusion. You’re right, I initially looked at the wrong “lazy-di” package on npm (the old unmaintained one). I’ve now carefully reviewed @lazy-di/core from your repository.
Your frustration with InversifyJS is very relatable — especially the constant merge conflicts around the central binding file.
That said, here’s where I still stand by my core criticism:

  • Compile-time safety remains the biggest philosophical difference. lazy-di relies on runtime resolution + reflect-metadata, which is convenient but moves more responsibility to tests and runtime.
  • Explicit Resource Management is still missing. Modern TypeScript has using / await using with Symbol.dispose / Symbol.asyncDispose. A production-grade DI should ideally own its instances and dispose them in strict LIFO order.
  • Performance & Debuggability — the decorator + metadata approach always comes with some trade-offs (though your implementation looks quite clean and lightweight).

Overall, lazy-di is clearly a solid improvement over classic InversifyJS for developers who prioritize minimal ceremony and nice DX. Respect for shipping it and writing the blog post — posts like “I got tired of X after Y years in production” are the most valuable kind.

Collapse
 
arvavit profile image
Vadym Arnaut

@maxrendel -- the scope-leak point is the one I underweighted in my reply.
We hit a structurally similar issue in a different stack (FastAPI Depends()
with mutable singletons): ambient resolution = ambient bugs. inferdi link
appreciated, will dig in.

Collapse
 
rayenmansouri profile image
Rayen

it's more type safe and easy to understand for every one

Collapse
 
ayoubjemai profile image
ayoub jemai

This was a great read. Really helpful solution to a critical problem.