Skip to content

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:

typescript
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' }):

typescript
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.

typescript
@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

  1. Named registrations do NOT participate in unnamed last-wins. The default is decided exclusively by unnamed preferences entries. If you want both default and named, register the driver twice — once plain, once Named().

  2. Duplicate (contract, slot) is a boot error. Two preferences declaring the same provide-token in the same slot fail at createApp().

  3. Missing slot target is a boot error. @Inject(X, { named: 'Y' }) with no slot-bound contract fails at createApp() 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.

  4. Each Named registration produces an independent instance. Two CacheRedisModule.forRoot({...}) calls — one plain, one Named('l2', ...) — produce two distinct config instances, two distinct Redis connections, two independent onInit / onShutdown runs.

  5. Lifecycle hooks fire per slot in declaration order. onInit for the default, then for each named slot in the order they appear in modules: [...]. Reverse on shutdown.

  6. Pool contributions from slotted modules are slot-isolated. A Named('admin', AuthJwtModule.forRoot({...})) whose metadata includes pools: [{ 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.

  7. A custom hand-rolled containerModule cannot be Named()-wrapped. The wirer can only auto-slot modules that use the metadata-driven providers / preferences path. Wrapping a custom containerModule in Named() is a boot error.

How it works under the hood

The qualifier is purely framework-level — no DI driver changes are required.

  1. Decorator metadata. @Inject(StorageService, { named: 'staging' }) records { token: StorageService, qualifier: 'staging' } on the consumer class.

  2. Synthetic slot token. The framework computes a stable slotToken(StorageService, 'staging') symbol via Symbol.for('modularityjs:slot:class:StorageService:staging'). Same input always produces the same symbol — across both DI driver packages.

  3. DI driver bridge. Each driver's metadata-bridge rewrites the inject token: when entry.qualifier is present, the driver injects slotToken(entry.token, entry.qualifier) instead of entry.token. From the driver's perspective, every injection is just a plain token resolution.

  4. Wirer. For each Named() registration, the wirer binds the module's providers under slotToken(providerClass, slot), applies activations to slot-scoped tokens, and aliases the contract via bind(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

NeedUse
"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:

typescript
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.