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:
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);
}
}
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,
) {}
}
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
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) {}
}
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);
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);
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);
}
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
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
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)
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. Wecaught 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 typechecker 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?
Hey Vadym, this is how we handle multiple implementations in lazy-di:
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:
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.
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.
It definitely adds some latency to the cold start, but in our case, it is acceptable.
This is the data from our codebase:
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.
Vadym, take a look at the best serverless solution where cold start matters: github inferdi/inferdi
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-diinherited the same old "diseases" that make production DI a nightmare: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.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
instanceofchecks, and makeconsole.logdebugging a nightmare.Captive Dependencies (Scope Leaks)
This is the most critical bug in any DI: injecting a short-lived
ScopedorTransientservice into a long-livedSingleton. Popular containers let this slip through, turning scoped instances into unintended singletons (causing cross-request data leaks).Lack of Lifecycle Management
Modern JS/TS has Explicit Resource Management (
using/await usingwithSymbol.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-diis 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.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.
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:
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.
@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.
it's more type safe and easy to understand for every one
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.