-
Notifications
You must be signed in to change notification settings - Fork 2
Doctor Container
Doctor.createContainer() mints an isolated child container: it reads
its parent's registrations but keeps its own singleton instances, scope
maps, and stock/revoke overrides. setContainerProvider is the
companion hook a host framework installs once so inject() — even after an
await — resolves against the right per-request container instead of
always falling back to the process-wide global.
Doctor.createContainer(): DoctorContainer;
setContainerProvider(
provider: (() => DoctorContainer | undefined) | undefined,
): void;DoctorContainer is the interface the global Doctor and every child both
implement — prescribe, stock, revoke, dispense, dispenseByName,
resolve, checkup, discharge, dischargeAll, reset, knows, has,
and createContainer itself, so a child can mint grandchildren the same way
— each reading through its own parent, then that parent's parent, and so on.
A child reads the parent's registrations — a @Vial class or a stocked
label registered on the parent resolves in the child too — but every
instance the child builds (its SINGLETON cache, its SCOPED maps, every
TRANSIENT call) is the child's own, and stock / revoke on the child never
touch the parent or a sibling child:
import { Doctor, inject, label } from '@tundralibs/doctor';
const NAME = label<string>('Name');
Doctor.stock(NAME, 'global-value');
class Greeter {
name = inject(NAME);
}
const acme = Doctor.createContainer();
acme.stock(NAME, 'acme-value'); // overrides NAME for `acme` only — no revoke() first needed, it's a separate map
console.log(acme.resolve(Greeter).name); // 'acme-value'
console.log(Doctor.createContainer().resolve(Greeter).name); // 'global-value' — a fresh sibling, untouched
console.log(Doctor.dispense(NAME)); // 'global-value' — the parent never changedReach for a child container when tenants (or tests, or sandboxed plugins)
need independent registries — different stocked config, different
overridden factories — not just independent request state. For
per-request isolation of the same registrations within one tenant, a
SCOPED vial plus Doctor.discharge(scope) is the right tool and needs no
extra container at all — see Lifecycles.
@Vialalways registers into the globalDoctor. The decorator callsDoctor.prescribedirectly — never "whichever container is ambient" — so declaring more@Vialclasses never gives an existing child anything new to override. A child only diverges from its parent once you imperatively callchild.prescribe(...)orchild.stock(...)on it.
A bare
new, or any construction not routed through the child, never sees it.inject()resolves against the ambient container — the one whosedispense/resolveis currently constructing, on the synchronous call stack — falling back to the globalDoctorotherwise. Holding a reference to a child is not enough:import { Doctor, inject, label } from '@tundralibs/doctor'; const NAME = label<string>('Name'); Doctor.stock(NAME, 'global-value'); class Greeter { name = inject(NAME); } const child = Doctor.createContainer(); child.stock(NAME, 'child-value'); console.log(new Greeter().name); // 'global-value' — NOT the child, even though `child` exists console.log(child.resolve(Greeter).name); // 'child-value' — constructed THROUGH the childAlways construct via the child itself —
child.dispense(Vial)for a registered vial,child.resolve(PlainClass, scope?)for anything else — never a barenewwhen the instance is meant to see that child's overrides.
resolve()reads a registeredfactorythrough to the parent, the same asdispense().child.resolve(SomeVial)for a class registered with a customfactoryon an ancestor honours that factory — the read-through the whole container chain shares — so a class whose constructor needs arguments is built the way its factory intends, not with a barenew. Whereresolve()still differs fromdispense(): it always constructs a fresh instance — never the cached SINGLETON / SCOPED one — and it will build a class registered nowhere with a barenew, which is exactly the unregistered-per-request-handler case it exists for.
has checks this container and its ancestors; knows checks only a
class, and only this container — no read-through:
import { Doctor, Vial } from '@tundralibs/doctor';
@Vial('SINGLETON')
class Shared {}
const child = Doctor.createContainer();
console.log(child.has(Shared)); // true — read through to the parent
console.log(child.knows(Shared)); // false — not registered on the child itself
console.log(Doctor.knows(Shared)); // true — registered right hereReach for knows when the question is specifically "did this container
register it" (e.g. deciding whether a child needs its own override); has
for the ordinary "can I dispense this at all" check documented in
stock.
The ambient-container stack inject() reads is synchronous — it spans
exactly one dispense / resolve call and is gone the moment that call
awaits something. A host framework that already tracks "the current
request's container" in its own async context (built on AsyncLocalStorage,
e.g. @tundralibs/ambient's
createContext) installs a provider so inject() keeps resolving against
the right container across awaits too:
// Needs a separate install: deno add @tundralibs/ambient
import { createContext } from '@tundralibs/ambient';
import { Doctor, inject, setContainerProvider, Vial } from '@tundralibs/doctor';
import type { DoctorContainer } from '@tundralibs/doctor';
@Vial('SINGLETON')
class Greeter {
id = Math.random();
greet(name: string): string {
return `hi ${name} (${this.id})`;
}
}
const requestContainer = createContext<DoctorContainer>();
// Call ONCE, at host startup — a later call replaces the previous provider.
setContainerProvider(() => requestContainer.get());
async function handle(
container: DoctorContainer,
name: string,
): Promise<string> {
return requestContainer.run(container, async () => {
await Promise.resolve(); // real async work happens here
return inject(Greeter).greet(name); // still resolves against `container`
});
}
const acme = Doctor.createContainer();
const globex = Doctor.createContainer();
console.log(await handle(acme, 'acme')); // acme's own Greeter singleton
console.log(await handle(globex, 'globex')); // globex's own — a distinct instanceEach tenant's Greeter singleton is built once and cached in that
child, never in the global or in a sibling — the same per-container
caching createContainer() gives you synchronously, now surviving await.
Install exactly one provider, once, at process/module load. A later
setContainerProvider(...)call replaces the previous one outright — it does not compose two hosts' providers. Passundefinedto uninstall.Precedence: a synchronous container operation already in flight (an outer
dispense/resolvestill running) always wins over the provider — the provider is consulted only when nothing is synchronously in flight. And when the provider itself returnsundefined(e.g.get()called outside anyrun()scope),inject()falls all the way back to the globalDoctorquietly, not as an error — a request that forgot to establish its context silently reads the process-wide registry instead of its tenant's.
Same errors as the global Doctor — a child is a full DoctorContainer —
see Errors. createContainer() and
setContainerProvider themselves never throw.
- inject — what actually reads the ambient container
-
stock —
Doctor.stock/revoke, the per-container override primitive - Errors