Overrides
Targeted constructor argument substitution for an arbitrary class —
Override(target, { preferences, args, fields })plus@Module({ overrides: [...] }).
By default, a class's dependencies are resolved through the global preference graph — every consumer that injects Logger gets the same Logger implementation. Overrides add a third axis on top of preferences and named bindings: a specific class can receive different dependencies (or post-construction field values) without changing the class's source, without forking it as a new injectable, and without disturbing other consumers.
This is the same primitive as Magento 2's per-class <type><argument> configuration, factored across TypeScript decorators. Together with named bindings (the M2 virtual-type equivalent) and the default last-wins preferences array, it closes the parity gap with Magento 2's three core DI configuration idioms.
When to use Override
| You want to… | Reach for |
|---|---|
| Swap a contract globally — every consumer gets the new implementation | preferences: [{ provide, useClass }] on a module |
| Iterate multiple contributions to the same contract | A pool |
| Run two or more isolated instances of the same contract side by side | Named('slot', module) |
| Change one constructor argument of one specific class without touching siblings | Override(target, { … }) |
Concrete examples for Override:
- An
OrderProcessorthat logs to the defaultLoggereverywhere, except in an audit pipeline where it should write toAuditLogger— without changing the globalLoggerpreference. - A
RateLimitingMiddlewarethat should use stricter limits for a particularWebhookDispatcherconsumer than the default config in the rest of the app. - A legacy class with a hardcoded scalar parameter you can't refactor right now, where a post-construction field assignment is the cleanest patch.
- A
MetricsCollectorthat needs an aggregator wired in at construction time, but the class lives in a third-party module you can't modify.
If you find yourself reaching for Override for many classes in the same app, the underlying contract probably wants a Named() slot or a Config class instead — Override is the surgical tool, not the everyday one.
Registration
Override(target, entry) is a function call that returns a ModuleDefinition. Place it directly in the app's modules: [...] array alongside any other module:
import { createApp, Override } from '@modularityjs/modularity';
import { inversify } from '@modularityjs/di-inversify';
const app = await createApp({
di: inversify,
modules: [
LoggingModule,
OrderModule,
Override(OrderProcessor, {
preferences: [{ provide: Logger, useClass: AuditLogger }],
}),
],
});The same shape ships inside a module via the new overrides field on @Module({...}) metadata. Module-level overrides travel with the consuming code instead of leaking into the app's modules: [...]:
@Module({
name: 'analytics',
imports: [OrderModule, LoggingModule],
providers: [AnalyticsService],
overrides: [
{
target: OrderProcessor,
preferences: [{ provide: Logger, useClass: AnalyticsLogger }],
},
],
})
class AnalyticsModule {}Unlike preferences and pools, the override dispatcher does not have a dedicated boundary validator today — cross-package overrides are allowed as long as every Newable referenced inside the entry can be resolved at bind time. Keep the declaring module's imports honest anyway: if any useClass or factory dependency comes from another package, that module should still be in imports so the dependency graph and check-boundaries stay consistent.
Four addressing schemes
A single Override(target, {…}) call accepts any combination of four schemes. Each scheme targets a different problem; mixing them in one call is encouraged when they're complementary.
preferences by token (v2a)
The common case. Replace every @Inject(provide) site on the target class with useClass. Sibling classes still resolve the global Logger preference.
Override(OrderProcessor, {
preferences: [{ provide: Logger, useClass: AuditLogger }],
});Works whether logger is a parameter property or a bare constructor parameter — interception happens at the injection site, not on the instance.
preferences by token + qualifier (v2b)
If the target class disambiguates two same-typed parameters with @Inject(Logger, { named: 'audit' }), you can target that specific injection site without touching the unqualified one.
class DualLoggerService {
constructor(
@Inject(Logger) private readonly primary: Logger,
@Inject(Logger, { named: 'audit' }) private readonly audit: Logger,
) {}
}
Override(DualLoggerService, {
preferences: [{ provide: Logger, named: 'audit', useClass: AuditLogger }],
});
// primary → default Logger, audit → AuditLoggerWithout the named qualifier the entry would match both injection sites (the unqualified one) and miss the qualified one — qualifier-aware matching is exact.
args by constructor parameter index (v2c)
The escape hatch when neither token nor qualifier disambiguates — typically a legacy class with two bare @Inject(Logger) parameters that you can't refactor right now.
class Service {
constructor(
@Inject(Logger) private readonly first: Logger,
@Inject(Logger) private readonly second: Logger,
) {}
}
Override(Service, { args: { 1: AuditLogger } });
// first → default Logger, second → AuditLoggerargs keys are constructor positions. Newable values are resolved from the container; everything else (scalars, plain objects) is used as-is. Index out of range or addressing a slot with no @Inject metadata fails boot with a clear error.
Caveat: args couples the override to the target's constructor signature. Inserting a parameter at the start of the constructor will silently shift everyone's positions. Prefer preferences (v2a/b) whenever the target class lets you address by token.
Addressing by parameter name
args also accepts string keys that name a constructor parameter. The framework parses target.toString() at boot and resolves the name to its index — handy when the index would be brittle and the parameter is bare (no parameter-property syntax, so fields won't reach it either):
class Service {
constructor(@Inject(Logger) primary: Logger, @Inject(Logger) audit: Logger) {
/* … */
}
}
Override(Service, { args: { audit: AuditLogger } });
// primary → default Logger, audit → AuditLoggerNumeric and string keys mix freely in one entry. A name that doesn't match any constructor parameter throws at boot with the parsed-parameter list:
Override(Service): args['audyt'] — no constructor parameter named 'audyt'.
Parsed parameters: [primary, audit].Important caveat — minification breaks this: parameter names are recovered from Function.prototype.toString(), which exposes the source as written. Production bundlers that rename identifiers (terser defaults, esbuild --minify-identifiers, etc.) silently rewrite audit to t or similar, and the lookup will fail at boot. For minified builds, use numeric indices or preferences by token; both are stable under any compilation. By-name addressing is best treated as a developer-experience convenience for source-shipped code.
Destructured parameters (constructor({ a, b }: Opts)) and the single-arg destructured config-object pattern can't be addressed by name — there's no parameter name to parse. Address them by index instead.
fields by instance field name (v1)
Post-construction assignment. The hook fires after the constructor runs and writes onto the resolved instance.
Override(MetricsCollector, {
fields: { tag: 'audit-stream' },
});Key constraint: fields keys are instance field names, not constructor parameter names. TypeScript drops parameter names at compile time; the only way fields: { logger: X } reaches a constructor parameter named logger is if the class stores it as this.logger post-construction. Parameter-property syntax (private/public/readonly prefix) does this automatically:
class Good {
constructor(@Inject(Logger) private readonly logger: Logger) {}
// ↑ `private readonly` → `this.logger` exists after construction → fields works
}
class Bad {
constructor(@Inject(Logger) logger: Logger) {}
// ↑ bare param → no instance field → fields silently no-ops
}For bare-parameter classes, use preferences instead — interception at the injection site doesn't care about instance storage.
For scalar configuration (api keys, retry counts, timeouts), the idiomatic ModularityJS path is still a Config class plus configureModule. fields is an escape hatch for legacy classes that didn't follow that pattern, not the primary tool.
strict: true — catch typos in field names
By default, a fields key that doesn't exist on the resolved instance is still assigned — adding a new property — so legacy classes can be patched incrementally without boot failing. Set strict: true on the OverrideEntry to reject this case at construction time:
Override(MetricsCollector, {
strict: true,
fields: { tag: 'audit-stream', retires: 5 }, // ← typo: should be `retries`
});
// container.get(MetricsCollector) throws:
// Override(MetricsCollector, { strict: true }): field 'retires' does not exist
// on the constructed instance — typo, or class is missing parameter-property
// syntax for that constructor arg.strict is per-entry. When multiple OverrideEntry values target the same (target, slot), strict is OR-merged across contributors — opting in propagates and never silently weakens. The check accepts both own properties (set by parameter-property syntax) and prototype-defined accessors (setters), so set value(v) patterns work too.
Layering rules
Override composes naturally with itself and with the other DI mechanisms:
| Combination | Behaviour |
|---|---|
Two Override(X, {…}) for the same target | Composed into one dispatch table; last-wins per key across preferences (same provide + named), args (same index), and fields (same name). |
args[i] and a matching preferences entry both addressing slot i | args wins for that slot — it's the more specific override. The preferences entry continues to apply to any sibling slot of the same provide + named. |
Named('staging', Override(X, {…})) | The override applies only to the slotToken(X, 'staging') resolution — the default X binding is untouched. This is the M2 virtual-type-with-arg-inheritance story: Named rebinds, Override delta-configures. |
Module-level + app-level Override on the same target | Wired in overall registration order during boot; same last-wins semantics across both sources. |
Override on a class that no module provides | The dispatcher binds the target via its factory — useful for introducing a fully-Override-configured class without an enclosing module. |
Worked example — composing all four schemes
import { Override, Named, createApp } from '@modularityjs/modularity';
@Injectable()
class AuditLogger extends Logger {
/* … */
}
@Injectable()
class NullLogger extends Logger {
/* … */
}
@Injectable()
class OrderProcessor {
constructor(
@Inject(Logger) public readonly primary: Logger, // index 0
@Inject(Logger, { named: 'audit' }) public readonly audit: Logger, // index 1
@Inject(MetricsCollector) public readonly metrics: MetricsCollector, // index 2
) {}
tag = 'default';
}
const app = await createApp({
di: inversify,
modules: [
LoggingModule,
OrderModule,
Override(OrderProcessor, {
// v2a — replace every @Inject(Logger) without qualifier. Net here:
// only `primary` (index 0) is targeted; the qualified `audit` slot
// requires a separate qualifier entry.
preferences: [
{ provide: Logger, useClass: AuditLogger },
// v2b — also override the qualified `audit` site explicitly.
{ provide: Logger, named: 'audit', useClass: NullLogger },
],
// v2c — override the metrics dependency by index because (say) we
// want to wire in a different class than what the global metrics
// preference would resolve.
args: { 2: AlternateMetrics },
// v1 — tag is a parameter property on OrderProcessor; assign post-
// construction.
fields: { tag: 'audit-stream' },
}),
// Default OrderProcessor untouched; this slot's binding has the
// overrides above. The default `primary` and `audit` resolve to the
// global Logger and audit-qualified Logger; the slotted one uses
// AuditLogger and NullLogger respectively.
Named(
'staging',
Override(OrderProcessor, {
preferences: [{ provide: Logger, useClass: NullLogger }],
}),
),
],
});
const defaultOp = app.get(OrderProcessor);
// defaultOp.primary → AuditLogger
// defaultOp.audit → NullLogger
// defaultOp.metrics → AlternateMetrics
// defaultOp.tag → 'audit-stream'
import { slotToken } from '@modularityjs/di';
const stagingOp = app.get<OrderProcessor>(slotToken(OrderProcessor, 'staging'));
// stagingOp.primary → NullLogger
// stagingOp.audit → default audit-qualified Logger (NOT NullLogger — the
// slot's Override only declares an unqualified preference)
// stagingOp.metrics → default MetricsCollector (no args override in the slot)
// stagingOp.tag → 'default' (no fields override in the slot)How it works
Under the hood, the framework collects every OverrideEntry from app-level Override(…) anchor modules and from module-level @Module({ overrides: […] }) declarations, groups them by (target, slot), and merges them into a single dispatch table per group.
For each group:
- If any
preferencesorargsare present, the target's binding is replaced by a factory. The factory walks the target's@Injectmetadata at construction time and decides per parameter: explicitargs[i]wins, then a matching scopedpreferencesentry, then the default resolution (qualifier-aware, slot-aware). - If any
fieldsare present, acontainer.onActivationhook is registered that writes each value onto the resolved instance.
Both steps ride primitives both DI drivers already expose (toDynamicValue / asFunction for the factory, onActivation for the hook), so override semantics live in the framework and the same behaviour holds whether you boot the app with inversify or awilix. The test suite asserts driver parity through a mirrored override.awilix.spec.ts.
Out of scope
- Override on factory providers, value providers, or pool contributions — these are not constructable in the same way; the dispatcher only applies to class providers. Documented restriction; Magento 2 doesn't have an equivalent either.
- Recursive overrides —
Override(X, {…})configures the construction of X only. Overrides do not cascade into the override's own dependencies; if you need to override the override target's own deps too, stack a secondOverrideon that class. - Strict mode for
argsandpreferences— these are already strict by construction.argsrejects out-of-range indices, unknown parameter names, and slots without@Injectat boot;preferencesresolves through the container and fails loudly if theuseClassisn't bound. Thestrictflag only applies tofields, which is where silent misuse is possible.