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

How 3 years of InversifyJS pain — merge conflicts, broken type safety, and manual tokens — led me to build lazy-di.

6 min read

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);
  }
}

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
  }
}
  • 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.