Dependency injection pattern
The rule
Section titled “The rule”In any @Service()-decorated class, use class-field initialisers
with Container.get(Dep). Do NOT use constructor-param injection.
// ✅ Correct@Service()export class MyService { private readonly repo = Container.get(MyRepository); private readonly other = Container.get(OtherService); // No constructor — or `constructor() {}` if you need a hook}// ❌ Wrong — silently broken at runtime@Service()export class MyService { constructor( private readonly repo: MyRepository, // typedi injects ContainerInstance here private readonly other: OtherService, // same ) {}}// ❌ Also wrong — `= Container.get(...)` defaults do NOT fire@Service()export class MyService { constructor( private readonly repo: MyRepository = Container.get(MyRepository), ) {}}Bun’s TypeScript transpiler does not emit design:paramtypes
reflect-metadata for decorators. typedi’s constructor-param
injection relies on that metadata; when it’s missing, typedi falls
back to injecting its own ContainerInstance into every slot.
The field “exists” but is actually the typedi container itself.
You then get runtime errors like this.foo.someMethod is not a function the first time you call a method on the “injected”
dep. Tests pass (they new Service(stub) directly), production
breaks. The worst kind of footgun.
Full design rationale: Why class-field DI, not constructor injection.
The testing pattern
Section titled “The testing pattern”Class-field initialisers run during construction and read from the container at construct time. To stub a dep:
function makeService(stubDep: Dep): MyService { Container.set(MyRepository, stubDep); // seed the container BEFORE const instance = new MyService(); // class-field initialisers run NOW Container.set(MyService, instance); // register the result return instance;}Don’t call Container.reset() or
Container.remove(MyService). Either wipes the @Service()
registration and breaks subsequent resolutions of the real
service.
Order matters. Stub the dep before new MyService(). Setting
it after the construction won’t help — the class-field initialiser
has already read the previous value (real or undefined).
Canonical examples:
packages/business/domain/tests/services/HoldingService.test.tspackages/business/domain/tests/services/BalanceAtTimeService.test.tspackages/business/domain/tests/services/PriceGraphService.test.ts
A few more rules of thumb
Section titled “A few more rules of thumb”- Don’t pass deps to constructors at all. Even for tests. If you need a non-DI dep (a config value, a date), set it via a setter or read it from the container.
- Don’t lazy-
Container.get(...)inside methods. Field initialisers run once at construction; method-level reads run on every call. Method-level reads also make the dependency invisible to readers — the field on the class is the contract. @Service()is required. A class without the decorator isn’t in the container;Container.get(Class)on it will throw.