← Blog

2020-06-05

Dependency Injection That Doesn't Take Over Your Codebase

The first DI container I adopted made me decorate every class with @injectable(), add reflect-metadata as a runtime dependency, and flip experimentalDecorators and emitDecoratorMetadata in tsconfig.json before a single line would run. That’s the price of admission for InversifyJS (“Start by installing inversify and reflect-metadata”) and tsyringe alike. Fine if the framework already owns your lifecycle. A bad trade the moment you’re writing a library, a script, or anything that has to stay portable across Node, Bun and the browser.

There’s a deeper assumption baked into the decorator-and-container style, and it’s worth naming because Martin Fowler named it back in 2004. In his canonical Inversion of Control Containers and the Dependency Injection pattern, he pins down what these containers actually invert:

For this new breed of containers the inversion is about how they lookup a plugin implementation.

Lookup. The container’s job is to find and wire an implementation into your object. To do that well it wants the graph — it wants to know, up front, what depends on what, so it can construct everything in the right order. Real systems don’t hand you that graph up front. A config loads from a remote source. A DB connection finishes handshaking after your app has started serving requests. A plugin registers a module only once its own imports resolve. When the container needs the full graph before it does anything, you end up writing lifecycle glue just to satisfy the container — the opposite of what DI was supposed to save you.

What I built instead

I wrote di.libx.js to solve the ordering problem head-on: let any part of the program register a module whenever it’s ready, and let any other part require that module whenever it needs it — regardless of which happens first. No decorators, no module wrapping, no build step that rewrites your classes. You register plain objects, functions, classes, whatever. It’s 3.3KB gzipped (14.7KB on disk), runs identically in Node, Bun and the browser via a CDN build, and it doesn’t ask you to structure your code around it.

InversifyJS / tsyringe di.libx.js
Decorators + reflect-metadata required none
tsconfig changes experimentalDecorators, emitDecoratorMetadata none
Dependency not registered yet resolution error waits, resolves later
Runtime Node-first Node, Bun, browser (CDN)

Deferred resolution is the whole point

The core trick: inject() doesn’t fail if the dependency isn’t registered yet — it waits.

import DependencyInjector from 'di.libx.js';

const di = new DependencyInjector();

// Require a dependency that doesn't exist yet.
// This resolves once 'db' shows up — whenever that is.
di.inject((db) => {
    console.log('db is ready:', db);
});

// Somewhere else, later, in a different file, after async setup:
async function bootDb() {
    const conn = await connectToDatabase();
    di.register('db', conn);   // this triggers the callback above
}
bootDb();

No polling, no manual event emitter, no “did it load yet” checks scattered around. inject parks until the name shows up in the registry. This matters most at startup, where the order things become available is genuinely nondeterministic — a Stripe client mid-handshake, a feature-flag service fetching its config over the network, a plugin system where plugins register themselves whenever their imports resolve. Instead of forcing a strict boot sequence, every consumer declares what it needs and waits its turn.

One real gotcha, and the README calls it out too: await-ing an inject() from inside the same synchronous chain as the register() it’s waiting on will deadlock — the register never runs because it sits after the await. This isn’t a bug, it’s what “deferred” means. The two calls need to actually be interleaved, not artificially serialized. Fire-and-forget the inject, or run register in a separate task/microtask.

The rest is ordinary container stuff

Once resolution is handled, everything else is what you’d expect. di.require('name') grabs a single module synchronously once it exists. di.register('name', val, true) throws on double-registration — handy for catching wiring bugs. injectAndRegister builds a new module out of existing ones:

di.injectAndRegister('userService', (db, logger) => ({
    getUser: (id) => db.query('users', id).then((u) => {
        logger.info('fetched user', id);
        return u;
    }),
}));

For code that wants to skip the callback shape, there’s a proxy mode — await di.proxy.myService.getData() — where property access transparently awaits resolution, so consuming code reads like you’re calling a method on an object that was always there. And sub-containers (new DependencyInjector(parent)) scope registrations to a request, job or test, inheriting from the parent but disposing their own registrations on exit — so test doubles and per-request state don’t leak into the global container.

Where it doesn’t fit

I won’t pretend this replaces a framework-level IoC container in a large, decorator-heavy NestJS-style app — if you’re already committed to that architecture, its conventions earn their keep there. And there’s no compile-time graph validation: if you never register a dependency, your inject() callback waits forever, silently. That’s the cost of not having a static graph. What you get in exchange is a DI layer that works the same in a Bun script, a browser bundle and a Node service, doesn’t touch the classes you inject, and treats “not ready yet” as a normal state instead of an error.

Repo: github.com/Livshitz/di.libx.jsyarn add di.libx.js or npm install di.libx.js. I wrote up the original motivation on Medium back when I first shipped it.

Here’s what I keep going back and forth on: is “the dependency isn’t ready yet” a legitimate normal state a container should model — or a design smell that a proper boot sequence should have eliminated? Which side are you on?

References