Multi-injection
Multi-injection is one token with many providers. Resolving the token returns an array of all of them. It is the foundation for every plugin or registry pattern in Titan.
The basic pattern
import { createToken } from '@omnitron-dev/titan/nexus';
interface IValidator {
name: string;
validate(input: unknown): boolean;
}
const VALIDATORS = createToken<IValidator[]>('Validators');
@Injectable()
class EmailValidator implements IValidator {
name = 'email';
validate(input: unknown) { return typeof input === 'string' && input.includes('@'); }
}
@Injectable()
class PasswordValidator implements IValidator {
name = 'password';
validate(input: unknown) { return typeof input === 'string' && input.length >= 8; }
}
@Module({
providers: [
EmailValidator,
PasswordValidator,
{ provide: VALIDATORS, useExisting: EmailValidator, multi: true },
{ provide: VALIDATORS, useExisting: PasswordValidator, multi: true },
],
exports: [VALIDATORS],
})
class ValidationModule {}
A consumer injects the array:
@Service('signup@1.0.0')
class SignupService {
constructor(@Inject(VALIDATORS) private readonly validators: IValidator[]) {}
@Public()
async signup(input: unknown) {
for (const v of this.validators) {
if (!v.validate(input)) throw Errors.validation(`${v.name} failed`);
}
// …
}
}
The array contains every provider registered against VALIDATORS,
in the order they were registered. New validators in new modules
slot in without changing the consumer.
Why this matters
The alternative — a registry the consumer maintains — couples consumer and contributor. Adding a new validator means editing the consumer. With multi-injection, the consumer depends on a token; contributors depend on the token. The two never meet.
This is the core of every extensibility seam in Titan:
- Health indicators — modules contribute, the aggregator consumes.
- DI middleware — modules contribute, the container consumes.
- Netron RPC middleware — modules contribute, Netron consumes.
- Lifecycle phase tasks — modules contribute, the coordinator consumes.
- Custom event handlers — modules contribute, the bus consumes.
All of them use multi-injection underneath.
Cross-module multi-tokens
Multi-tokens compose across modules. If module A registers two providers and module B registers one, a consumer that imports both gets all three:
@Module({
providers: [
{ provide: VALIDATORS, useExisting: EmailValidator, multi: true },
{ provide: VALIDATORS, useExisting: PasswordValidator, multi: true },
],
exports: [VALIDATORS],
})
class CoreValidatorsModule {}
@Module({
providers: [
{ provide: VALIDATORS, useExisting: PhoneNumberValidator, multi: true },
],
exports: [VALIDATORS],
})
class ExtraValidatorsModule {}
@Module({
imports: [CoreValidatorsModule, ExtraValidatorsModule],
providers: [SignupService],
})
class AppModule {}
// SignupService gets [EmailValidator, PasswordValidator, PhoneNumberValidator]
The accumulation is the runtime composition of imports. Drop
ExtraValidatorsModule from the root, and SignupService sees only
the core validators — no other code change required.
Order
Providers register in the order their modules are discovered, which
follows the imports graph. Within a module, registration follows
the order in providers. The result is deterministic but tied to
import order, so if order matters (middleware chains, for example),
make it explicit:
interface IMiddleware {
priority: number; // explicit ordering
handle(ctx: any, next: () => any): any;
}
@Service('orders@1.0.0')
class OrdersService {
constructor(@Inject(MIDDLEWARE) private readonly middleware: IMiddleware[]) {
this.middleware.sort((a, b) => a.priority - b.priority);
}
}
Optional empty arrays
If no provider is registered, resolving a multi-token returns an empty
array — not undefined, not throws. This means consumers can write
code that handles the no-contributor case naturally:
const validators = container.resolve(VALIDATORS); // [] if nothing registered
for (const v of validators) v.validate(input); // loop is a no-op
Mixing multi and single
A token is either multi or single. You cannot register one provider
without multi: true and another with multi: true against the same
token — the framework will throw at registration time.
If you need the "first one wins" pattern, use a regular single token
plus useExisting to alias to the chosen impl. If you need the "all
of them" pattern, use multi.
A real example — health indicators
The titan-health ecosystem module uses multi-injection so any
module can contribute an indicator without changing the consumer:
import { Injectable } from '@omnitron-dev/titan';
import { createToken } from '@omnitron-dev/titan/nexus';
const HEALTH_INDICATOR = createToken<IHealthIndicator[]>('HealthIndicator');
@Injectable()
class StripeHealth implements IHealthIndicator {
async check() { /* … */ }
}
@Module({
providers: [
StripeHealth,
{ provide: HEALTH_INDICATOR, useExisting: StripeHealth, multi: true },
],
exports: [HEALTH_INDICATOR],
})
class PaymentsModule {}
Adding PaymentsModule to the root module adds the indicator to
the health aggregator. Removing the module removes the indicator.
No other code changes.
The exact token name and shape are owned by titan-health; the
pattern shown above is the general multi-injection recipe.
Anti-patterns
- Forgetting
multi: true. Without it, the second registration overwrites the first. Symptom: only one provider shows up in the array. - Mutating the resolved array. The container does not defensively copy. Mutating the array mutates the cached resolution. Treat resolved arrays as read-only.
- Order-dependent behaviour without explicit ordering. If your
consumer assumes a specific order, encode that order in the
providers themselves (
priorityfield, sort step) — do not rely on import-order coincidence.
→ Next: Contextual Injection.