Build a Dependency Injection Container in Vanilla JavaScript

javatuts1 pts0 comments

Build a Dependency Injection Container in Vanilla JavaScript<br>JavaScript Development Space

Follow Us<br>TelegramBskySubstackDailyDev

-->Search posts, tutorials, and more...Ctrl+K<br>Ctrl+K

Quick Access

All PostsBrowse our latest articles

How-to GuidesStep-by-step tutorials

Code SnippetsReusable code examples

Friday LinksWeekly curated content

No results found<br>Try different keywords or check the spelling

Dependency injection has been part of application architecture for years. In the JavaScript world, however, the discussion almost always ends up revolving around TypeScript, decorators, and reflect-metadata.

That raises an interesting question.

Can you build a practical DI container using nothing but modern JavaScript?

The answer is yes.

In this article, we’ll build a lightweight dependency injection container from scratch without TypeScript, decorators, or runtime reflection. Instead of trying to inspect constructor parameters at runtime, we’ll use JavaScript’s own strengths. Classes are first-class values. They can carry metadata, be used as tokens, and be created dynamically.

The result is a surprisingly clean design that supports singletons, scoped services, transient lifetimes, and dependency resolution while keeping the domain layer completely independent from the container.

The implementation shown here is an experiment rather than a production-ready framework, but it demonstrates that JavaScript already provides everything needed for an elegant DI solution.

Why Constructor Inspection Doesn’t Work

Most dependency injection frameworks try to determine what a class depends on by analyzing its constructor.

Consider this service:

javascript

CopyCopied!<br>class CartApplicationService {<br>constructor(repository, catalog, discountPolicy) {<br>this.repository = repository;<br>this.catalog = catalog;<br>this.discountPolicy = discountPolicy;

At runtime, JavaScript knows only one thing.

This constructor accepts three arguments.

It has no idea that repository should implement a shopping cart repository, that catalog represents a product catalog, or that discountPolicy contains business rules for calculating discounts.

Parameter names are not contracts. After minification they often become a, b, and c. Default parameters, destructuring, and rest syntax make runtime analysis even less reliable.

Many frameworks solve this problem by generating metadata through decorators or TypeScript compilation.

Without those tools, the container has no reliable way to discover dependencies automatically.

A Different Approach

Instead of asking the container to guess dependencies later, we can declare them when the component is created.

Because JavaScript classes are regular runtime values, they can store their own metadata.

Imagine an API like this:

class {<br>constructor({ carts, catalog, discounts }) {<br>this.carts = carts;<br>this.catalog = catalog;<br>this.discounts = discounts;<br>);" data-astro-cid-5zfle2qk>javascript

CopyCopied!<br>const CartApplicationService = component(<br>name: "CartApplicationService",<br>lifetime: "scoped",<br>inject: {<br>carts: CART_REPOSITORY,<br>catalog: PRODUCT_CATALOG,<br>discounts: DISCOUNT_POLICY,<br>},<br>},<br>() =><br>class {<br>constructor({ carts, catalog, discounts }) {<br>this.carts = carts;<br>this.catalog = catalog;<br>this.discounts = discounts;<br>);

This simple factory creates a class and attaches its dependency information at the same time.

No decorators.

No reflection.

No parsing constructor signatures.

The class itself becomes the source of truth.

Concrete services can even use the class object itself as the dependency token, while abstractions can be represented by Symbol values.

Creating a Component

Let’s begin with the component() helper.

Its job is simple.

It creates a class, validates its configuration, and stores dependency metadata directly on the constructor using a private Symbol.

javascript

CopyCopied!<br>const COMPONENT_METADATA = Symbol("di.component.metadata");

const LIFETIMES = new Set([<br>"singleton",<br>"scoped",<br>"transient",<br>]);

function component(<br>name,<br>inject = {},<br>lifetime = "transient",<br>},<br>classFactory<br>) {<br>if (!name) {<br>throw new TypeError("Component name is required");

if (!LIFETIMES.has(lifetime)) {<br>throw new TypeError(`Unsupported lifetime: ${lifetime}`);

const ComponentClass = classFactory();

if (typeof ComponentClass !== "function") {<br>throw new TypeError("classFactory must return a class");

Object.defineProperties(ComponentClass, {<br>name: {<br>value: name,<br>configurable: true,<br>},<br>[COMPONENT_METADATA]: {<br>value: Object.freeze({<br>inject: Object.freeze({ ...inject }),<br>lifetime,<br>}),<br>enumerable: false,<br>writable: false,<br>configurable: false,<br>},<br>});

return ComponentClass;

Instead of maintaining a global metadata registry, every component carries its own configuration.

The metadata lives under a private Symbol, making accidental access almost impossible. It stays hidden during normal property enumeration while remaining available to the container whenever registration happens.

Giving each generated class...

javascript catalog class dependency container metadata

Related Articles