How DI Works Under the Hood

Build a Tiny DI Container

NestJS reads constructor types from metadata. Our runnable example has no reflect-metadata, so we pass the tokens explicitly with an @Inject() parameter decorator (NestJS also has a real @Inject() for exactly this case). The container creates each class once and reuses it.

Example

A mini NestJS-style container with legacy decorators:

type Ctor<T = unknown> = new (...args: any[]) => T;

const injectable = new Set<Ctor>();
const paramTokens = new Map<Ctor, Ctor[]>();

function Injectable(): ClassDecorator {
  return (target) => { injectable.add(target as unknown as Ctor); };
}
function Inject(token: Ctor): ParameterDecorator {
  return (target, _key, index) => {
    const list = paramTokens.get(target as unknown as Ctor) ?? [];
    list[index] = token;
    paramTokens.set(target as unknown as Ctor, list);
  };
}

class Container {
  private instances = new Map<Ctor, unknown>();
  get<T>(cls: Ctor<T>): T {
    if (this.instances.has(cls)) return this.instances.get(cls) as T;
    if (!injectable.has(cls)) throw new Error(cls.name + ' is not @Injectable');
    const deps = (paramTokens.get(cls) ?? []).map((t) => this.get(t));
    console.log('creating', cls.name, 'with', deps.map((d) => (d as object).constructor.name));
    const instance = new cls(...deps);
    this.instances.set(cls, instance);
    return instance;
  }
}

@Injectable()
class PricingService {
  withGst(p: number) { return Math.round(p * 1.18); }
}

@Injectable()
class ProductsRepository {
  findPrice(id: number) { return id === 7 ? 1299 : 999; }
}

@Injectable()
class ProductsService {
  constructor(
    @Inject(ProductsRepository) private readonly repo: ProductsRepository,
    @Inject(PricingService) private readonly pricing: PricingService,
  ) {}
  finalPrice(id: number) { return this.pricing.withGst(this.repo.findPrice(id)); }
}

const app = new Container();
const service = app.get(ProductsService);
console.log('final price of 7:', service.finalPrice(7));
console.log('same instance again?', app.get(ProductsService) === service);

Output

creating ProductsRepository with []
creating PricingService with []
creating ProductsService with [ 'ProductsRepository', 'PricingService' ]
final price of 7: 1533
same instance again? true

What to Notice

  1. The container resolves dependencies first (repository and pricing), then the service that needs them.
  2. Each class is created once and cached: that is the default singleton scope in NestJS.
  3. The service never calls new: you can give it a fake repository in tests.