@zakkster/lite-di-strategies
v1.0.0
Published
A fail-closed, zero-GC strategy router over a booted DI container: a gate maps an input to a declared key, the key resolves to a construction-frozen token, and select()/resolve() add no allocation and no catch. Read-only by construction -- it never mutate
Maintainers
Readme
@zakkster/lite-di-strategies
A fail-closed, zero-GC STRATEGY ROUTER over a booted DI container. Declare a map of routing KEYS to container TOKENS and a GATE that turns a runtime input into a key;
select(input)returns the token,resolve(input)is exactlycontainer.get(select(input)). One gate call, one Map lookup, one=== undefinedcompare, one return -- no string building, no try/catch, no allocation on the cached-singleton lane. READ-ONLY by construction: the router never mutates the container. Zero deps, single file.
The dispatch the DI ecosystem was missing
lite-di-strategies is the DISPATCH hinge of the @zakkster/lite-di-* service-kernel line. The container OWNS resolution and hot-swap, the supervisor HEALS, health REPORTS, the orchestrator RETIRES on SIGTERM, the lock guards a critical section. Nothing turned a runtime signal -- a tenant tier, a feature flag, a payload kind -- into "which of N registered implementations do I resolve?" without a hand-rolled switch that quietly drifts from the container's registrations. This is that piece, and it stays a pure SELECTOR.
npm install @zakkster/lite-di-strategiesimport { Container } from '@zakkster/lite-di-container';
import { StrategyRouter } from '@zakkster/lite-di-strategies';
const c = new Container();
c.singleton('plan:free', FreePlan);
c.singleton('plan:pro', ProPlan);
c.singleton('plan:ent', EnterprisePlan);
c.boot();
// strategies: { key -> token }; gate: (input) -> key. Both validated at construction.
const plans = new StrategyRouter(c, {
strategies: { free: 'plan:free', pro: 'plan:pro', ent: 'plan:ent' },
gate: (user) => user.tier,
});
const plan = plans.resolve(currentUser); // == c.get(the token for user.tier)One gate call, one Map lookup, one compare. An undeclared key THROWS -- never a silent default. The router calls get and has and nothing else, ever: it is boot-lock-clean by construction, and 0 B/op on the cached-singleton lane.
Table of contents
- Why this exists
- What you get
- The selector, in one read
- API reference
- Composability with the ecosystem
- Zero-GC design notes
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
- License
Why this exists
The Strategy pattern in a DI app usually degrades into a switch on some runtime
value that returns a hand-picked instance. That switch is a SECOND registry: it
drifts from the container's real registrations, it is not fail-closed (an unknown case
falls through to a default or undefined), and it allocates on a path you call per
request.
StrategyRouter replaces the switch with a lens onto the container's ONE registry. A
gate maps your input to a declared key; the key resolves to a precomputed token; the
token resolves through container.get. There is no second source of truth, no silent
fallthrough, and no allocation on the hot lane.
What you get
- A pure SELECTOR:
select(input) -> token,resolve(input) -> instance. Nothing else. - Fail closed EVERYWHERE: construction validates the container, the options, the gate,
and every token (
container.has(token) === true, naming key AND token on failure). A gate that returns an undeclared key throws;gate: undefinedis never coerced. - 0 B/op on the cached-singleton lane, gated two ways in the torture harness.
- Boot-lock-clean by construction: the router never mutates the container. Proven by a mutator-spy total of exactly 0 across a million selects.
resolveadds NO catch: the container's own errors propagate byte-for-byte.- Zero runtime dependencies. Single ESM file. TypeScript types.
The selector, in one read
select(input) {
const key = this._gate(input); // your gate: input -> key
const token = this._tokens.get(key); // precomputed key -> token Map
if (token === undefined) throw this._unknownKey(key); // cold builder, off the hot body
return token;
}
resolve(input) {
return this._container.get(this.select(input)); // no try/catch, no wrapping
}Tokens are precomputed into a construction-frozen Map, so the hot body does NO string
building. The single === undefined compare is the only branch; it covers a typo,
null, undefined, a number, an object, or NaN in one shot -- any of which throws a
TypeError from the COLD _unknownKey builder (which lists the declared keys and adds
a Levenshtein did-you-mean). resolve wraps nothing: if container.get throws, that
error is what you catch, unchanged.
API reference
new StrategyRouter(container, options)
new StrategyRouter(container, {
strategies: { [key]: token }, // exactly one shape; two keys MAY map to one token
gate: (input) => key, // required; undefined is NEVER coerced to match-all
})container is duck-typed: it must expose get(token), has(token), and an
isBooted boolean, and isBooted must be true. Construction fails closed, before
any read, on each of:
| Rejected at construction | Result |
| --- | --- |
| container non-object / null | TypeError |
| container missing get / has | TypeError |
| container missing isBooted boolean | TypeError |
| isBooted === false | Error (build a router only over a booted container) |
| options non-object / null | TypeError |
| an unknown option key | Error -- unknown option 'strategis'. Did you mean 'strategies'? |
| gate missing / non-function | TypeError |
| gate: undefined | TypeError -- never coerced to match-all |
| strategies missing / empty | TypeError / Error |
| a token not a non-empty string or symbol | TypeError |
| a token where container.has(token) !== true | Error naming the KEY and the TOKEN |
Two keys mapping to one token is LEGAL and documented.
select and resolve
| Member | Signature | Notes |
| --- | --- | --- |
| select | (input) => token | HOT. Throws a TypeError (with a did-you-mean over the declared keys) if the gate returns an undeclared key. Never false, never a silent no-op. A gate that throws propagates unchanged. |
| resolve | (input) => instance | HOT. == container.get(select(input)). Adds no catch: the container's throws propagate byte-for-byte. |
| keys | ReadonlyArray | Frozen array of the declared keys, built once at construction. |
| size | number | The number of declared keys. |
resolve re-throws, unchanged, the container's own errors -- Service 'x' is not
registered. Available: [...], 'x' is multi. Use getAll()., 'x' is async. Use
getAsync()., Container shut down.
Constants
| Export | Value |
| --- | --- |
| VERSION | '1.0.0' (synced with package.json + llms.txt) |
No default export. No bind helper. No frozen enum -- every rejection is a thrown Error.
Composability with the ecosystem
The router selects; the container swaps. They compose without merging. To change WHICH
implementation a token resolves to at runtime, call container.rebind BESIDE the
router -- the router makes zero API calls, yet its next resolve returns the new
instance:
import { Container, TYPES } from '@zakkster/lite-di-container';
import { StrategyRouter } from '@zakkster/lite-di-strategies';
const parent = new Container();
parent.singleton('impl:free', FreePlan);
parent.singleton('impl:pro', ProPlan);
parent.singleton('impl:ent', EnterprisePlan);
parent.boot();
// A booted child scope with no local impl binding -- resolution delegates to parent.
const scope = parent.scope();
scope.boot();
const router = new StrategyRouter(scope, {
strategies: { free: 'impl:free', pro: 'impl:pro', ent: 'impl:ent' },
gate: (ctx) => ctx.tier,
});
router.resolve({ tier: 'pro' }); // ProPlan, resolved through the child scope
// Hot-swap the pro plan live -- the router is NOT involved.
await parent.rebind('impl:pro', { type: TYPES.VALUE, value: new ProPlanV2(), isAsync: false });
router.resolve({ tier: 'pro' }); // ProPlanV2 -- zero router API callsrebind lives at Container.js:1138 and is orthogonal to selection (see
decisions/0001-selector-not-rebinder.md).
Zero-GC design notes
The hot lanes are measured, not claimed. In the torture harness, select and
resolve are driven 1,000,000 times each across TWO routers over TWO containers with
disjoint key sets and rotating input (so nothing is constant-folded), gated two ways:
measureAllocs at maxBytesPerCall: 0, and measureOps(stabilize:'deep') with
checkNoGc({ maxMajor: 0 }), maxPauseMs <= 2.0, and arrayBuffers.growthBytes === 0.
| Lane | What runs | Bytes/op |
| --- | --- | --- |
| select -> token | one gate call, one Map lookup, one compare, return | 0.000 |
| resolve -> instance, CACHED-SINGLETON token (Container.js:203) | the above + container.get cache hit | 0.000 |
| resolve -> instance, VALUE / ALIAS token (Container.js:228-237) | the above + the container's path-tracking lane (_path push/pop, indexOf) | NOT 0 -- out of the gated claim |
Two honest caveats:
- The gated 0 B/op number is for the CACHED-SINGLETON lane. Register your strategies
as
singleton/singletonFactoryand resolve them once to warm the cache, andresolveridesContainer.js:203at 0 bytes.valueandaliastokens take the container's path-tracking lane, which allocates and can grow -- those are deliberately OUT of the 0 B/op table. - 0 B/op is a JOINT property of the router AND your gate. The router's own body
allocates nothing, but
selectcalls YOUR gate on every op. If the gate allocates (builds a string key, spreads an object, closes over fresh state), that allocation is on the select path and the number degrades. The router cannot make an allocating gate free; it only promises to add nothing of its own.
The declared-keys Map and the frozen keys array are built ONCE at construction. The
_unknownKey error builder and the Levenshtein did-you-mean are COLD -- reached only
on a reject branch, never from a passing select.
Design decisions worth knowing
- The router is a SELECTOR, not a REBINDER (decision 0001). It only ever calls
getandhas. Hot-swap is the container's job viarebind; selection and swap compose without merging. - Fail closed, always.
gate: undefinedis a MISSING gate, not a match-all. An undeclared key throws, never returns a default -- if you want a default, make the gate return a declared key (your policy, in one place). - One options shape only.
strategiesis exactly{ [key]: token }-- no prefix, separator, array sugar, or bitmask. The single=== undefinedcompare is the only hot branch. - Tokens precomputed at construction. The hot body does no string building; every
token is validated
container.has(token) === trueup front.
Testing
Three entry points:
npm test # the node:test smoke suite
npm run torture # node --expose-gc test/torture.mjs -- prints "ok", one GATE line, exit 0
npm run example # the runnable reference app -- self-verifying, exits 0 on OKexamples/billing-dispatch.mjs is a shipped,
self-verifying reference consumer: a billing service that routes each request's tenant
tier to one of three registered plan implementations, then swaps the pro plan live with
container.rebind BESIDE the router (selection and swap compose without merging). It
exercises the whole public surface -- select / resolve / .keys / .size /
VERSION, the fail-closed throw on an undeclared key, and the hot-swap -- and asserts
every contract with node:assert, so npm run example is part of verify and
prepublishOnly. It is the downstream proof that the 1.0.0 API works in anger.
The torture harness runs eight tiers strictly sequentially: laws + exports + an ASCII /
no-console control (fail-closed over every files[] entry); the 16-row construction
matrix; the >= 14-row select/resolve fail-closed matrix (container messages
byte-identical); a boot-lock proof (a million selects leave the registry / resolution
order / singleton cache byte-identical, mutator spies at exactly 0); the 0 B/op
select + resolve lanes; a 10,000-cycle retention soak with lite-leak; a money-tier
composition against the REAL container (three tenants + scope delegation + a rebind
beside the router); and controls that prove every gate above can fail. Three break
switches -- DI_ASCII_BREAK, DI_ALLOC_BREAK, DI_TORTURE_BREAK -- each force a
non-zero exit, so no gate is vacuous.
What this is not
- NOT a rebinder. It never mutates the container. Use
container.rebindfor hot-swap. - NOT a registry. It is a lens onto the container's registrations, not a second store.
- NOT a fallback engine. An undeclared key throws; a default is an explicit gate choice.
- NOT a DI container. It requires a booted
@zakkster/lite-di-container(or any object exposingget/has/isBooted).
Ecosystem
Part of the @zakkster/lite-di-* service-kernel line: the container resolves and
hot-swaps, the supervisor heals, health reports, the orchestrator retires on SIGTERM,
the lock guards a critical section -- and this package dispatches a runtime signal to
the right registered implementation.
License
MIT (c) Zahary Shinikchiev [email protected]
