Build Your Own Dependency Injection Container
You have a UserService that needs a Database. Your OrderService needs UserService, Database, and a PaymentGateway. Your NotificationService needs EmailClient and UserService.
Build Your Own Dependency Injection Container
You have a UserService that needs a Database. Your OrderService needs UserService, Database, and a PaymentGateway. Your NotificationService needs EmailClient and UserService. Manually wiring all of this in your entry point means one function that creates everything in the right order, with the right constructor arguments, knowing the entire dependency graph by heart. Add three more services and you're writing 60 lines of new Foo(new Bar(new Baz(config))).
A dependency injection container flips the problem. You register how to build each thing. The container reads the graph and resolves it on demand.
NestJS, InversifyJS, and Angular's injector are all variations on a small idea. Here's the idea, from scratch, in under 100 lines of JavaScript.
What We're Building
container.register(key, Class, { deps, singleton })— register a classcontainer.registerValue(key, value)— register a constant or config objectcontainer.registerFactory(key, factory, { deps, singleton })— register a factory functioncontainer.resolve(key)— build an instance, recursively resolving all deps- Singleton vs transient lifetimes
- Circular dependency detection with a clear error message
- Scoped child containers for request-level lifetimes
The registry is a plain Map. Resolution is a recursive function that walks the dependency list depth-first. Singleton caching is a second Map. That's the whole mechanism.
Step 1: The Registry
Three registration methods cover every case you'll encounter in practice.
Container skeleton and registration
// container.js
class Container {
constructor() {
this._registry = new Map(); // key → registration descriptor
this._singletons = new Map(); // key → cached instance
}
register(key, Class, { deps, singleton = true } = {}) {
const resolvedDeps = deps ?? Class._inject ?? [];
this._registry.set(key, { type: 'class', Class, deps: resolvedDeps, singleton });
return this; // chainable
}
registerValue(key, value) {
this._registry.set(key, { type: 'value', value });
return this;
}
registerFactory(key, factory, { deps = [], singleton = true } = {}) {
this._registry.set(key, { type: 'factory', factory, deps, singleton });
return this;
}
}
module.exports = { Container };singleton: true is the default because most services — database connections, HTTP clients, service classes — should share one instance. singleton: false (transient) is for objects that hold per-call state and must not be shared.
deps ?? Class._inject ?? [] reads a static _inject property on the class if no explicit deps are given. This lets you co-locate dependency declarations with the class definition instead of burying them in container setup.
return this from all three methods enables chaining: container.register(...).register(...).registerValue(...).
Step 2: Resolving Dependencies
resolve is the heart of the container. Look up the registration, recursively resolve its deps, construct the instance, cache it if needed.
resolve() — recursive construction
// Add to Container class
resolve(key) {
if (!this._registry.has(key)) {
throw new Error(`Container: no registration found for "${key}"`);
}
const reg = this._registry.get(key);
// Values have no construction step
if (reg.type === 'value') return reg.value;
// Singletons: return the cached instance if already built
if (reg.singleton && this._singletons.has(key)) {
return this._singletons.get(key);
}
// Recursively resolve all declared dependencies
const resolvedDeps = reg.deps.map(depKey => this.resolve(depKey));
// Build the instance
const instance = reg.type === 'factory'
? reg.factory(...resolvedDeps)
: new reg.Class(...resolvedDeps);
if (reg.singleton) {
this._singletons.set(key, instance);
}
return instance;
}The recursion bottoms out at value registrations (leaves in the graph) or at classes with no deps. Everything else resolves its own deps before constructing.
Quick smoke test:
const { Container } = require('./container');
class Database {
constructor(config) {
console.log(`[DB] connected to ${config.host}`);
}
query(sql) { return `result: ${sql}`; }
}
class UserService {
constructor(db) { this.db = db; }
findUser(id) { return this.db.query(`SELECT * FROM users WHERE id=${id}`); }
}
UserService._inject = ['db'];
const c = new Container();
c.registerValue('config', { host: 'localhost' })
.register('db', Database, { deps: ['config'] })
.register('userService', UserService);
const svc = c.resolve('userService');
console.log(svc.findUser(1));
// [DB] connected to localhost
// result: SELECT * FROM users WHERE id=1
console.log(c.resolve('userService') === svc); // true — singleton cacheResolve userService twice. Database is constructed once.
Step 3: Circular Dependency Detection
Without a guard, A → B → A recurses until the call stack overflows. The error you get — Maximum call stack size exceeded — tells you nothing about which keys caused it.
The fix is a "currently resolving" Set. Add a key before recursing into its deps; if we encounter it again, we found a cycle.
Cycle detection with a resolution path
// Replace resolve() with this version
resolve(key, _resolving = new Set()) {
if (!this._registry.has(key)) {
throw new Error(`Container: no registration found for "${key}"`);
}
if (_resolving.has(key)) {
const cycle = [..._resolving, key].join(' → ');
throw new Error(`Container: circular dependency detected: ${cycle}`);
}
const reg = this._registry.get(key);
if (reg.type === 'value') return reg.value;
if (reg.singleton && this._singletons.has(key)) {
return this._singletons.get(key);
}
_resolving.add(key);
const resolvedDeps = reg.deps.map(depKey => this.resolve(depKey, _resolving));
_resolving.delete(key); // clear after this branch finishes
const instance = reg.type === 'factory'
? reg.factory(...resolvedDeps)
: new reg.Class(...resolvedDeps);
if (reg.singleton) this._singletons.set(key, instance);
return instance;
}The _resolving set is passed down the recursive calls — each level sees the same Set. The delete after deps resolve is critical: without it, a diamond dependency like A → B, A → C, B → D, C → D would fail because D would still be in _resolving when C tries to resolve it. The delete lets D be reused in separate branches.
const c = new Container();
c.register('a', class A { constructor(b) {} }, { deps: ['b'] });
c.register('b', class B { constructor(a) {} }, { deps: ['a'] });
try {
c.resolve('a');
} catch (err) {
console.error(err.message);
// Container: circular dependency detected: a → b → a
}Step 4: Scoped Child Containers
App-wide singletons live in the root container. Request-scoped objects — a logger tagged with a request ID, a transaction context — should be shared within one request but fresh for the next. The mechanism is a child container that holds the per-request registrations and delegates everything else upward.
The delegation is the whole design, and it's easy to get wrong in a way that looks fine. The obvious implementation is to copy the parent's registrations into the child and give the child a fresh singleton cache. Don't: a copied registration resolves against the child's empty cache, so every request builds its own Database. You get a new connection per request from a container whose entire job was to hand out one. Delegate instead — if the child doesn't own the key, ask the parent, and let the parent cache it where the parent can share it.
createScope() for request-level lifetimes
// Add to the constructor:
// constructor(parent = null) { ...; this._parent = parent; }
//
// And at the top of resolve(), replace the "not found" throw with:
//
// if (!this._registry.has(key)) {
// if (this._parent) return this._parent.resolve(key, _resolving);
// throw new Error(`Container: no registration found for "${key}"`);
// }
createScope() {
// The child owns only what you register on it.
// Anything else resolves in — and caches in — the parent.
return new Container(this);
}Three properties fall out of this, and they're the three you want. Root singletons are built once and shared by every scope. Keys registered on a scope are invisible to the root and to sibling scopes, so one request can't see another's requestId. And a scope-registered service can depend on a root singleton, because unresolved keys travel up while resolution results travel back down.
Wire this into a request handler:
const http = require('http');
const { randomUUID } = require('crypto');
const { Container } = require('./container');
const root = new Container();
root
.registerValue('config', { host: 'localhost' })
.register('db', Database, { deps: ['config'] })
.register('userService', UserService, { deps: ['db'] });
http.createServer((req, res) => {
const scope = root.createScope();
scope.registerValue('requestId', randomUUID());
scope.registerFactory('logger', (reqId) => ({
log: (msg) => console.log(`[${reqId}] ${msg}`)
}), { deps: ['requestId'], singleton: false });
const logger = scope.resolve('logger');
const userService = scope.resolve('userService');
logger.log('handling request');
res.end(JSON.stringify(userService.findUser(1)));
}).listen(3000);db and userService resolve to the same instances across all requests — neither key exists in the scope's own registry, so both delegate to the root and hit the root's singleton cache. Run this and [DB] connected to localhost prints once, on the first request, no matter how many requests follow. requestId and logger are fresh per request because they're registered on the per-request scope, and logger receives the scoped requestId even though it could just as easily have asked for the root's db.
Step 5: Attaching Dep Metadata to Classes
Listing deps as strings in the container setup is error-prone. You rename a class, forget to update the registration, and get a confusing runtime error. The fix: attach the dep list directly to the class so it lives next to the code that needs it.
Co-locating dep declarations with the class
// inject.js
function markInjectable(Class, deps) {
Class._inject = deps;
return Class;
}
module.exports = { markInjectable };const { markInjectable } = require('./inject');
class Database {
constructor(config) { this.config = config; }
}
markInjectable(Database, ['config']);
class EmailClient {
constructor(config) { this.smtp = config.smtp; }
}
markInjectable(EmailClient, ['config']);
class UserService {
constructor(db, email) { this.db = db; this.email = email; }
}
markInjectable(UserService, ['db', 'email']);
// Container setup needs no explicit deps lists
const c = new Container();
c.registerValue('config', { smtp: 'mail.host' })
.register('db', Database)
.register('email', EmailClient)
.register('userService', UserService);When you rename 'email' to 'mailer', you update markInjectable(UserService, ['db', 'mailer']) in the same file as the class — you can't miss it.
The Full Implementation
// container.js
class Container {
constructor(parent = null) {
this._registry = new Map();
this._singletons = new Map();
this._parent = parent;
}
register(key, Class, { deps, singleton = true } = {}) {
const resolvedDeps = deps ?? Class._inject ?? [];
this._registry.set(key, { type: 'class', Class, deps: resolvedDeps, singleton });
return this;
}
registerValue(key, value) {
this._registry.set(key, { type: 'value', value });
return this;
}
registerFactory(key, factory, { deps = [], singleton = true } = {}) {
this._registry.set(key, { type: 'factory', factory, deps, singleton });
return this;
}
resolve(key, _resolving = new Set()) {
if (!this._registry.has(key)) {
// Not ours — let the parent resolve and cache it, so its
// singletons stay shared across every scope.
if (this._parent) return this._parent.resolve(key, _resolving);
throw new Error(`Container: no registration found for "${key}"`);
}
if (_resolving.has(key)) {
const cycle = [..._resolving, key].join(' → ');
throw new Error(`Container: circular dependency detected: ${cycle}`);
}
const reg = this._registry.get(key);
if (reg.type === 'value') return reg.value;
if (reg.singleton && this._singletons.has(key)) {
return this._singletons.get(key);
}
_resolving.add(key);
const resolvedDeps = reg.deps.map(depKey => this.resolve(depKey, _resolving));
_resolving.delete(key);
const instance = reg.type === 'factory'
? reg.factory(...resolvedDeps)
: new reg.Class(...resolvedDeps);
if (reg.singleton) this._singletons.set(key, instance);
return instance;
}
createScope() {
return new Container(this);
}
clearCache(key) {
if (key) { this._singletons.delete(key); }
else { this._singletons.clear(); }
return this;
}
registeredKeys() {
return [...this._registry.keys()];
}
}
module.exports = { Container };Demo: Order Processing App
// demo.js
const { Container } = require('./container');
class Config {
constructor() {
this.db = { host: process.env.DB_HOST || 'localhost' };
this.smtp = { host: 'mail.example.com' };
}
}
class Database {
constructor(config) {
this._store = new Map();
console.log(`[DB] connected to ${config.db.host}`);
}
get(id) { return this._store.get(id) || null; }
set(id, val) { this._store.set(id, val); return val; }
}
Database._inject = ['config'];
class EmailClient {
constructor(config) { this.host = config.smtp.host; }
send(to, subject) { console.log(`[Email] → ${to}: "${subject}" via ${this.host}`); }
}
EmailClient._inject = ['config'];
class UserService {
constructor(db, email) { this.db = db; this.email = email; }
create(id, data) {
this.db.set(id, data);
this.email.send(data.email, 'Welcome!');
return { id, ...data };
}
get(id) { return this.db.get(id); }
}
UserService._inject = ['db', 'email'];
class OrderService {
constructor(db, users) { this.db = db; this.users = users; }
place(userId, item) {
const user = this.users.get(userId);
if (!user) throw new Error(`User ${userId} not found`);
const id = `ord_${Math.floor(Math.random() * 1e6)}`;
this.db.set(id, { userId, item, status: 'pending' });
console.log(`[Order] ${id} → ${user.name}: ${item}`);
return id;
}
}
OrderService._inject = ['db', 'userService'];
const container = new Container();
container
.register('config', Config)
.register('db', Database)
.register('email', EmailClient)
.register('userService', UserService)
.register('orderService', OrderService);
const users = container.resolve('userService');
const orders = container.resolve('orderService');
users.create('u1', { name: 'Alice', email: 'alice@example.com' });
orders.place('u1', 'Mechanical Keyboard');
orders.place('u1', 'USB Hub');
// Both userService and orderService share the same db instance
const db1 = container.resolve('db');
const db2 = container.resolve('db');
console.log('Same DB instance:', db1 === db2); // trueOutput:
[DB] connected to localhost
[Email] → alice@example.com: "Welcome!" via mail.example.com
[Order] ord_482931 → Alice: Mechanical Keyboard
[Order] ord_839201 → Alice: USB Hub
Same DB instance: true
Testing
// test.js — node test.js
const { Container } = require('./container');
function assert(cond, msg) {
if (!cond) throw new Error(`FAIL: ${msg}`);
console.log(`PASS: ${msg}`);
}
// 1. registerValue returns the raw value
{
const c = new Container();
c.registerValue('x', 42);
assert(c.resolve('x') === 42, 'registerValue returns value');
}
// 2. Class instantiation with injected deps
{
const c = new Container();
class A { constructor(v) { this.v = v; } }
c.registerValue('v', 'hello').register('a', A, { deps: ['v'] });
assert(c.resolve('a').v === 'hello', 'class gets dep injected');
}
// 3. Singleton returns the same instance
{
const c = new Container();
class S {}
c.register('s', S, { singleton: true });
assert(c.resolve('s') === c.resolve('s'), 'singleton returns same instance');
}
// 4. Transient returns new instance each time
{
const c = new Container();
class T {}
c.register('t', T, { singleton: false });
assert(c.resolve('t') !== c.resolve('t'), 'transient returns new instance each time');
}
// 5. Circular dependency throws with descriptive cycle path
{
const c = new Container();
c.register('a', class A { constructor(b) {} }, { deps: ['b'] });
c.register('b', class B { constructor(a) {} }, { deps: ['a'] });
try {
c.resolve('a');
assert(false, 'should have thrown');
} catch (err) {
assert(err.message.includes('a → b → a'), 'circular dep error shows the full cycle');
}
}
// 6. Missing registration throws with the key name
{
const c = new Container();
try {
c.resolve('ghost');
assert(false, 'should have thrown');
} catch (err) {
assert(err.message.includes('"ghost"'), 'missing key named in error');
}
}
// 7. Factory function gets resolved deps
{
const c = new Container();
c.registerValue('mult', 3);
c.registerFactory('times3', (m) => (n) => n * m, { deps: ['mult'] });
assert(c.resolve('times3')(7) === 21, 'factory function works');
}
// 8. _inject metadata is read when deps omitted
{
const c = new Container();
class B { constructor(n) { this.n = n; } }
B._inject = ['num'];
c.registerValue('num', 99).register('b', B);
assert(c.resolve('b').n === 99, '_inject metadata used when deps omitted');
}
// 9. Diamond dependency: D constructed exactly once
{
const c = new Container();
let dCount = 0;
class D { constructor() { dCount++; } }
class B2 { constructor(d) { this.d = d; } }
class C2 { constructor(d) { this.d = d; } }
class A2 { constructor(b, cc) { this.b = b; this.c = cc; } }
c.register('d', D)
.register('b2', B2, { deps: ['d'] })
.register('c2', C2, { deps: ['d'] })
.register('a2', A2, { deps: ['b2', 'c2'] });
const a2 = c.resolve('a2');
assert(dCount === 1, 'diamond dep: D constructed once');
assert(a2.b.d === a2.c.d, 'diamond dep: B and C share the same D');
}
// 10. Scoped child — root singletons are SHARED, scope registrations are not
{
const root = new Container();
let built = 0;
class Shared { constructor() { built++; } }
root.register('shared', Shared);
const s1 = root.createScope();
const s2 = root.createScope();
assert(s1.resolve('shared') instanceof Shared, 'child resolves parent registrations');
assert(s1.resolve('shared') === root.resolve('shared'), 'scope reuses the root singleton');
assert(s1.resolve('shared') === s2.resolve('shared'), 'sibling scopes share the root singleton');
assert(built === 1, 'root singleton constructed exactly once across all scopes');
s1.registerValue('requestId', 'r1');
s2.registerValue('requestId', 'r2');
assert(s1.resolve('requestId') === 'r1' && s2.resolve('requestId') === 'r2',
'scope registrations stay per-scope');
let leaked = false;
try { root.resolve('requestId'); leaked = true; } catch { /* expected */ }
assert(!leaked, 'scope registrations do not leak up into the root');
}
// 11. A scope-registered service can depend on a root singleton
{
const root = new Container();
class Db {}
root.register('db', Db);
const scope = root.createScope();
scope.registerValue('requestId', 'abc');
scope.registerFactory('repo', (db, rid) => ({ db, rid }),
{ deps: ['db', 'requestId'], singleton: false });
const repo = scope.resolve('repo');
assert(repo.db === root.resolve('db'), 'scope factory receives the root db singleton');
assert(repo.rid === 'abc', 'scope factory receives the scoped requestId');
}
// 12. Keys registered on the parent after createScope() are still visible
{
const root = new Container();
const scope = root.createScope();
root.registerValue('late', 'yes');
assert(scope.resolve('late') === 'yes', 'late parent registrations are visible to existing scopes');
}
console.log('\nAll tests passed.');clearCache(key) is a lifesaver in tests. Call it between test cases to reset singleton state without re-creating the entire container and re-registering everything.
What We Skipped
Async initialization — some services need async setup: connecting to a database, fetching config from a remote store, waiting for a file to load. The current container is synchronous. Adding async support means resolve returns a Promise and dependencies are awaited in parallel with Promise.all. The tricky part: you need to cache the Promise immediately, before it resolves, to prevent double-initialization if two callers resolve the same key concurrently.
Three-tier lifetimes — proper DI containers have singleton (once per container), scoped (once per child container), and transient (new every time). We have two and a convention. createScope() gives you scoped behaviour for anything you register on the scope, but there's no { lifetime: 'scoped' } flag you can put on a root registration and have each scope automatically get its own instance. Supporting that means marking the registration and having resolve decide which container's cache to write to rather than always using its own.
TypeScript decorators — InversifyJS and NestJS use @injectable(), @inject(), and TypeScript's reflect-metadata to wire dep information at class definition time with zero separate calls. The markInjectable helper here is identical in intent — co-locate deps with the class — without needing TypeScript compilation settings.
Overriding a parent registration from a scope — this one is a real limitation of the delegation above, and it will surprise you. Register config on a scope to override the root's, then resolve a root-registered service that depends on config: the scope hands the whole resolution to the root, the root resolves config from its own registry, and your override is ignored. Resolution happens in the container that owns the registration. Angular behaves much the same way for the same reason; if you need true per-scope overrides, the scope has to re-register anything downstream of the key it's overriding.
Optional deps — the container throws on any missing key. A production container might support { optional: true } on individual dep entries, resolving to null instead of throwing when the key isn't registered.
The core above is roughly what InversifyJS gives you before the TypeScript decorator layer. The source is readable now.
Comments (0)
No comments yet. Be the first to share your thoughts!