DEV Community

Ayoub Chrigui
Ayoub Chrigui

Posted on • Originally published at chrigui.dev

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

Registration bloat and architectural pushback

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 different things, and over time the frustration became impossible to ignore.

Here's what that cost looked like for us, and what I built to get rid of it.


The pain

Manual registration, in a file nobody wants to touch

InversifyJS requires you to bind every dependency manually in a central container file. In small projects this is fine. In a production SaaS with years of development behind it, it looks like this:

The binding file at line 1114, still going

That is line 1114. The file does not end there.

Every new service, repository, or handler means opening this file, adding a line, and hoping nobody else is doing the same thing in a parallel branch. Because if they are, you have a merge conflict — and not a meaningful one. Not a conflict about logic or behavior. A conflict about a list of names.

We dealt with conflicts on this file almost every merge. And if you forgot to bind a new dependency entirely, nothing would tell you — no compiler error, no warning at startup, just a runtime failure waiting to happen. We eventually built a separate script just to detect duplicate registrations, because binding the same class twice doesn't throw at registration time — you only discover it as an AMBIGUOUS_MATCH error when that dependency is actually resolved.

Getting type safety required building our own abstraction

InversifyJS tokens are plain values — strings, Symbols, or classes — with no structural connection to the Typescript type system. bind<UserService>("UserService") and get<UserService>("UserService") work, but the generic is manual, the string is unchecked, and nothing ties the two together.

We didn't accept that. We built a TypedContainer on top of InversifyJS, backed by a ContainerRegistry type that mapped every service identifier to its concrete type. Then we overrode bind and get to key off that registry automatically:

export class TypedContainer extends Container {
  bind<K extends keyof ContainerRegistry>(
    serviceId: K,
  ): interfaces.BindingToSyntax<ContainerRegistry[K]> {
    return super.bind<ContainerRegistry[K]>(serviceId);
  }

  get<K extends keyof ContainerRegistry>(serviceId: K): ContainerRegistry[K] {
    return super.get<ContainerRegistry[K]>(serviceId);
  }
}
Enter fullscreen mode Exit fullscreen mode

No manual generics. Pass the service ID, get back the right type. It worked.

But it came at a cost. Every new dependency now required two touches: add it to the ContainerRegistry type, add it to the binding file. Two files, every time, for what is ultimately one piece of information — "this class exists and can be injected." And we still had to maintain the ContainerRegistry type as a parallel representation of the entire dependency graph.

Injecting is frustrating and error-prone

The pain doesn't stop at registration. At the injection site, because we used string tokens, @inject was required on every constructor parameter.

// user-service.ts
@injectable()
class UserService {
  constructor(
    @inject("UserRepository") private users: UserRepository,
    @inject("EmailService") private email: EmailService,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

This is noisy, but the real problem runs deeper. The token and the parameter type are completely decoupled. Nothing stops you from writing this:

@inject("EmailService") private users: UserRepository
Enter fullscreen mode Exit fullscreen mode

The token says EmailService. The type says UserRepository. Typescript emits no error. Your app boots fine. You find out something is wrong at runtime, when behavior is already wrong and the cause is not obvious.


The solution

Lazy-di is a zero-ceremony DI container for Typescript. No binding file, no token constants, no @inject on every parameter. You decorate your classes and resolve — that's it.

What this looks like in practice

Here is a controller → service → repository + email setup, in InversifyJS:

// container-registry.ts
export type ContainerRegistry = {
  UserController: UserController;
  UserService: UserService;
  UserRepository: UserRepository;
  EmailService: EmailService;
};

// container.ts
container.bind("UserController").to(UserController);
container.bind("UserService").to(UserService);
container.bind("UserRepository").to(UserRepository);
container.bind("EmailService").to(EmailService);

// user-repository.ts
@injectable()
class UserRepository {
  constructor(@inject("Database") private db: Database) {}
}

// email-service.ts
@injectable()
class EmailService {}

// user-service.ts
@injectable()
class UserService {
  constructor(
    @inject("UserRepository") private users: UserRepository,
    @inject("EmailService") private email: EmailService,
  ) {}
}

// user-controller.ts
@injectable()
class UserController {
  constructor(@inject("UserService") private userService: UserService) {}
}
Enter fullscreen mode Exit fullscreen mode

And here is the same setup in lazy-di:

// user-repository.ts
@Injectable()
class UserRepository {
  constructor(private db: Database) {}
}

// email-service.ts
@Injectable()
class EmailService {}

// user-service.ts
@Injectable()
class UserService {
  constructor(
    private users: UserRepository,
    private email: EmailService,
  ) {}
}

// user-controller.ts
@Injectable()
class UserController {
  constructor(private userService: UserService) {}
}

// main.ts — the only wiring you write
const container = Container.create();
const controller = container.get(UserController);
Enter fullscreen mode Exit fullscreen mode

No ContainerRegistry type. No binding file. No @inject on every parameter. The constructor types are the tokens. If the type is wrong, the compiler tells you — because it's just Typescript.

This completely deleted the two largest files in our codebase. There is no registration step anymore — which means no forgotten bindings, no duplicate registrations, no script to guard against them. Those bugs are not handled better, they are made impossible to write in the first place.


Current limitations

lazy-di is in active use in our production codebase but it is still early. There are things it does not support yet, and you should know about them upfront.

No interface injection

Interfaces are erased at runtime — they cannot serve as tokens. This is a Typescript constraint, not a lazy-di one. No DI container can solve this without some form of manual binding.

lazy-di's answer is abstract classes with @Abstract(). Abstract classes survive compilation and provide the same contract as interfaces:

// Before — interface (cannot be used as a token)
interface PaymentGateway {
  charge(amount: number): Promise<void>;
}

// After — abstract class (works as both contract and token)
@Abstract()
abstract class PaymentGateway {
  abstract charge(amount: number): Promise<void>;
}

@Injectable()
@Implements(PaymentGateway)
class StripeGateway extends PaymentGateway {
  async charge(amount: number) {
    // Stripe implementation
  }
}

// Resolves to StripeGateway automatically — no manual binding
const gateway = container.get(PaymentGateway);
Enter fullscreen mode Exit fullscreen mode

In practice this has not been a blocker for us. Abstract classes are slightly more verbose than interfaces, but they give you runtime identity in exchange — which is exactly what makes injection possible.

No primitive value injection

You cannot inject a raw string or number directly. When we need configuration values inside a class, we wrap them:

@Injectable({ scope: "singleton" })
class AppConfig {
  readonly databaseUrl = process.env.DATABASE_URL;
  readonly port = Number(process.env.PORT);
}
Enter fullscreen mode Exit fullscreen mode

This is a reasonable pattern regardless of DI, and it has not caused friction in practice. Direct token-based injection for primitives is something I plan to add in a future version.


Try it

npm install @lazy-di/core reflect-metadata
Enter fullscreen mode Exit fullscreen mode
// tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
Enter fullscreen mode Exit fullscreen mode
  • GitHub — source, issues, roadmap
  • npm — package

If you hit a use case lazy-di cannot handle, open an issue or send a pull request. Real usage is the best way to shape what comes next.

If this solved a problem you recognized, drop a ⭐ on the repo.

Top comments (14)

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.

Thread Thread
Collapse
 
maxrendel profile image
Viacheslav Kabanov

Vadym, take a look at the best serverless solution where cold start matters: github inferdi/inferdi

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.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.