Named Bindings
Multiple, isolated instances of the same contract —
Named('staging', ...)plus@Inject(C, { named: 'staging' }).
By default, every preference-bound contract has exactly one active implementation in a ModularityJS app — last-wins ordering at the modules: [...] boundary. Named bindings add a second axis: a contract can have one default plus zero or more named alternates, each fully isolated with its own config, dependencies, and lifecycle.
This is the same primitive as Magento 2's virtual types, factored cleanly across TypeScript decorators.
When to use Named bindings
Use them when an app needs more than one instance of the same contract, each configured differently:
- Storage — local for ephemeral uploads + S3 for permanent assets.
- Cache — L1 in-memory + L2 Redis.
- Database — read-replica vs. write-primary; analytics vs. OLTP.
- Queue — high-priority vs. low-priority lanes.
- Mailer — transactional (Postmark) vs. marketing (SendGrid) — different sender reputation.
- HTTP client — internal (no auth, fast retries) vs. external (auth, conservative retries).
- Encryption — at-rest, in-transit, and token-signing keys — same primitive, different key material.
Use a plain preferences registration when there is only one of something. Use a pool when iteration is the consumer contract. Use a Named() registration when selection is the consumer contract.
Registration
Two equivalent forms in the app's modules: [...] array:
import { Named } from '@modularityjs/modularity';
const app = await createApp({
di: inversify,
modules: [
StorageModule,
CacheModule,
// Default drivers — unnamed, last-wins competition.
StorageS3Module.forRoot({ bucket: 'public-assets' }),
CacheRedisModule.forRoot({ host: 'redis-primary' }),
// Named alternates — function-wrapper form
Named('staging', StorageLocalModule.forRoot({ path: '/var/tmp' })),
// Named alternates — object form (friendlier for programmatic composition)
{
named: 'archive',
module: StorageS3Module.forRoot({ bucket: 'cold-store' }),
},
{ named: 'l2', module: CacheRedisModule.forRoot({ host: 'redis-l2' }) },
],
});Both forms produce the same internal representation. Named() reads cleaner inline; the object form is easier for build-time conditionals or generated module lists.
Injection
Decorate parameters with @Inject(Contract, { named: 'slot' }):
import { Inject, Injectable, InjectOptional } from '@modularityjs/di';
@Injectable()
class UploadService {
constructor(
@Inject(StorageService) private readonly storage: StorageService, // default → S3 public
@Inject(StorageService, { named: 'staging' })
private readonly staging: StorageService, // local
@Inject(StorageService, { named: 'archive' })
private readonly archive: StorageService, // S3 cold-store
@InjectOptional(CacheService, { named: 'l2' })
private readonly l2?: CacheService, // optional named slot
) {}
}Slot-filtered pool iteration
@InjectPool(Pool, { named: 'slot' }) (and the raw @InjectAll(Pool.symbol, { named })) iterate only the slot's contributions to a pool. The global pool entries are NOT included — named = isolated on the pool axis the same way it is on the preference axis.
@Injectable()
class AuthFacade {
constructor(
// All resolvers contributed by unnamed modules — the global auth chain.
@InjectPool(AuthResolversPool)
private readonly publicResolvers: AuthResolver[],
// Only resolvers contributed by modules under Named('admin', ...).
@InjectPool(AuthResolversPool, { named: 'admin' })
private readonly adminResolvers: AuthResolver[],
) {}
}To iterate across both default and a named slot, declare two separate parameters and concatenate at the call site. This keeps the consumer in control of cross-slot composition.
Rules
Named registrations do NOT participate in unnamed last-wins. The default is decided exclusively by unnamed
preferencesentries. If you want both default and named, register the driver twice — once plain, onceNamed().Duplicate
(contract, slot)is a boot error. Two preferences declaring the same provide-token in the same slot fail atcreateApp().Missing slot target is a boot error.
@Inject(X, { named: 'Y' })with no slot-bound contract fails atcreateApp()with a clear message naming the consumer class and the missing(contract, slot).@InjectPool(P, { named: 'Y' })fails at boot if'Y'isn't registered anywhere — but it's fine for the slot to register and contribute zero entries to this particular pool (empty pool is valid, same as the default slot). Either way, no silent runtime resolution failures.Each Named registration produces an independent instance. Two
CacheRedisModule.forRoot({...})calls — one plain, oneNamed('l2', ...)— produce two distinct config instances, two distinct Redis connections, two independentonInit/onShutdownruns.Lifecycle hooks fire per slot in declaration order.
onInitfor the default, then for each named slot in the order they appear inmodules: [...]. Reverse on shutdown.Pool contributions from slotted modules are slot-isolated. A
Named('admin', AuthJwtModule.forRoot({...}))whose metadata includespools: [{ pool: AuthResolversPool, ... }]registers its contribution under a slot-namespaced pool token.@InjectPool(AuthResolversPool)(no qualifier) sees only the default-slot contributions;@InjectPool(AuthResolversPool, { named: 'admin' })returns only the admin slot's. This keeps "named = isolated" consistent across preferences and pools.A custom hand-rolled
containerModulecannot beNamed()-wrapped. The wirer can only auto-slot modules that use the metadata-drivenproviders/preferencespath. Wrapping a custom containerModule inNamed()is a boot error.
How it works under the hood
The qualifier is purely framework-level — no DI driver changes are required.
Decorator metadata.
@Inject(StorageService, { named: 'staging' })records{ token: StorageService, qualifier: 'staging' }on the consumer class.Synthetic slot token. The framework computes a stable
slotToken(StorageService, 'staging')symbol viaSymbol.for('modularityjs:slot:class:StorageService:staging'). Same input always produces the same symbol — across both DI driver packages.DI driver bridge. Each driver's
metadata-bridgerewrites the inject token: whenentry.qualifieris present, the driver injectsslotToken(entry.token, entry.qualifier)instead ofentry.token. From the driver's perspective, every injection is just a plain token resolution.Wirer. For each
Named()registration, the wirer binds the module's providers underslotToken(providerClass, slot), applies activations to slot-scoped tokens, and aliases the contract viabind(slotToken(Contract, slot)).toDynamicValue(ctx => ctx.get(slotToken(useClass, slot))). Dependencies that aren't slot-scoped (logger, telemetry, etc.) resolve from the global container as usual.
This is the same idea as Magento 2's <virtualType> declarations — a contract can have a named alternate registered alongside the default — but expressed with TypeScript decorators and zero XML.
Pool vs. Preference vs. Named — quick decision rule
| Need | Use |
|---|---|
| "Pick one of these" (one implementation wins) | Preference |
| "All of these run" (iteration is the contract) | Pool |
| "Pick this specific one by name" (multi-instance) | Named |
Pools and Named are orthogonal axes. They compose: @InjectPool(P, { named: 'X' }) iterates only the named slot's contributions to P — useful when an isolated chain needs its own set of contributors (admin auth resolvers, an internal log transport pool, a tenant-specific notification channel chain).
Testing named bindings
@modularityjs/testing provides overrideNamed(app, contract, name, impl) for swapping a slot's binding in tests:
import { overrideNamed } from '@modularityjs/testing';
const app = await harness.boot({ start: false });
overrideNamed(app, StorageService, 'staging', new InMemoryStorage());
await app.start();Internally rebinds slotToken(contract, name) on the container — works identically under inversify or awilix.