DEV Community

Cover image for Your DI container has been lying to you at runtime. Here's the library that moved the truth to build time.
Jai Sachdeva
Jai Sachdeva

Posted on

Your DI container has been lying to you at runtime. Here's the library that moved the truth to build time.

Most TypeScript backends you've written have probably used a DI container. Maybe InversifyJS. Maybe tsyringe. Maybe NestJS under the hood. They're ergonomic, they auto-wire your services, and they feel like magic. But there's a catch you've probably never thought about: that magic is happening while your server is booting up, using runtime reflection to figure out who depends on what.

A library called diadem (@devcraft-ts/diadem) just said: what if we didn't do that at all?


1. 🔍 The dirty secret of reflect-metadata

Every major TypeScript DI library — Inversify, tsyringe, TypeDI, NestJS — shares the same dependency: reflect-metadata. This polyfill lets JavaScript read TypeScript type annotations at runtime. It's clever, but it means your app is doing type analysis — the kind of work a compiler does — every single time it boots.

The problem isn't just performance. It's that a runtime system can only be as correct as what it can see in the moment. Cycles, missing tokens, ambiguous bindings — these all become runtime errors, not build errors. Your first sign something is wrong is often a crash in production.

"On the JVM this is the Spring → Dagger / Micronaut / Quarkus shift (runtime reflection → build-time wiring); diadem brings that shift to TypeScript."

diadem runs a CLI tool — diadem build — that uses the TypeScript AST to analyse your decorated classes before you ever start the server. The dependency graph is resolved at build time, not at boot time. If something is wrong, you find out in your CI pipeline, not in production.

Traditional DI:

Decorate classes → Boot server → Read types at runtime → Wire deps
Enter fullscreen mode Exit fullscreen mode

diadem:

Decorate classes → diadem build (AST) → Generated wiring → Boot server
Enter fullscreen mode Exit fullscreen mode

2. ⚡ "Compiled emit" is a bigger deal than it sounds

Most people's first reaction to diadem is: "cool, it generates a manifest file." But the more interesting mode is --emit=compiled, which generates something qualitatively different: straight-line wiring code. Not a data structure to be interpreted. Actual TypeScript new calls, in topological order, with direct references.

Manifest mode (interpreted):

resolve(ILogger) → look up token → find factory → call it
Enter fullscreen mode Exit fullscreen mode

Compiled mode (codegen):

const _Logger = new ConsoleLogger()
const _Svc = new TaskService(_Logger)
Enter fullscreen mode Exit fullscreen mode

The compiled output is just TypeScript. Your bundler can tree-shake it. There's no runtime interpreter, no token lookup, no resolver loop. It's exactly the code you'd write by hand — but generated automatically. This is the same approach Dagger uses on the JVM, and it's the reason Micronaut starts so much faster than Spring.

The bonus: the generated createServices() accessor is fully typed. If you try to resolve a token that isn't wired, tsc tells you at compile time. Not a runtime throw. A compile error.


3. 🛡️ A silent file rename could rewire your production app

This one is genuinely unsettling, and diadem's v0.4 release notes are admirably candid about it.

When a constructor parameter's type isn't a declared token, older versions of diadem would fall back to a naming convention — IFoo matches a class named Foo, or a unique FooService. If two classes both matched (say, LoanService and LoanRepository both matching ILoan), it would wire whichever one the file scanner found first. Rename a file, change scan order, accidentally rewire production.

"A file rename could silently rewire production. Now: every convention-based wiring emits a warning naming the guessed implementation, and fails the build under --strict."

v0.4 fixed this hard: multiple candidates are never wired silently. The build reports all candidates and refuses to pick. --strict rejects heuristic wiring entirely. The honesty of shipping a fix for this while documenting the prior behaviour is a good signal about the maturity of the project's thinking.


4. 🔬 The DI container is just the delivery vehicle. The graph is the product.

Here's the part that reframes the whole project. diadem isn't really trying to be a better Inversify. The DI container is just the runtime artifact. What it's actually building toward is an Architectural Intelligence Platform — a system that makes the structure of your software visible and enforceable.

Run diadem graph --serve and you get an interactive HTML visualization of your dependency graph: every service as a node, every dependency as an edge, lifecycles colour-coded, cycles highlighted in red. Captive scope violations (a singleton accidentally depending on a scoped service) are flagged at build time. Boundary enforcement, coupling metrics, and drift detection between PRs are on the roadmap.

The insight here is sharp: most DI libraries throw away the graph after wiring. diadem keeps it as a first-class artifact. That means the same build step that wires your app can also tell you which services are becoming hotspots, whether your layering (controller → service → repository) actually matches your code, and whether a PR introduced a new cycle.


5. 🧪 Testing without module mocking is actually achievable

The compiled mode's typed Overrides surface makes for a surprisingly clean testing story. Instead of reaching for jest.mock() or dependency patching, you pass a replacement directly into the container factory:

const container = await createContainerAsync({ IClock: new FixedClock() })
const app = buildApp(container)

// No module mocking. No sockets. No network.
// The override flows all the way through to your scoped services.
const res = await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Ship it' } })
Enter fullscreen mode Exit fullscreen mode

"No module mocking, no sockets, no network. Just swap the implementation at the composition root."

The Fastify example that ships with the library demonstrates this end-to-end: tests get a fresh isolated container per test, a fixed-clock override proves the override flowed all the way through to the scoped TaskService, and app.inject() drives everything without touching a real port. It's the kind of test setup that makes you slightly embarrassed by your current test suite.

The broader point: when your wiring is a generated, explicit artifact rather than a black box of runtime reflection, it becomes composable. You can hand it overrides, fork it for different environments, diff it between branches. That's not something you can easily do when your DI is woven into your runtime.


Where this leaves us

diadem is still early — 7 stars on GitHub, no official framework adapters yet, a name-based token identity system that will need an opt-in --type-check pass for large monorepos. None of that diminishes the core idea.

The runtime reflection model that every major TypeScript DI library relies on is a workaround for the fact that TypeScript types don't survive to runtime. diadem simply declines to work around it, and runs the analysis where types actually exist: the build step.

The question worth sitting with isn't whether diadem will win. It's whether the pattern will.

As TypeScript matures, how many more things are we still doing at runtime that should have been done at build time?


Check it out: github.com/astralstriker/diadem · npm install @devcraft-ts/diadem

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

Moving dependency truth toward build time is a strong direction for TypeScript backends. Runtime DI can be flexible, but it often hides wiring mistakes until the application path finally gets exercised.

The best part of build-time validation is not just fewer crashes; it changes how confidently a team can refactor. If the container graph is checked earlier, dependency changes become visible in review instead of appearing as late runtime surprises.