Plugins
Overview
The plugin system (@modularityjs/plugins) provides method-level interception for services. Plugins can execute logic before, after, or around any injectable method — enabling cross-cutting concerns like logging, caching, validation, or metrics without modifying the target service.
Picking the right tool
Plugins, HTTP middleware, and HTTP interceptors all "wrap something" but at different scopes. Plugins wrap a method on a class anywhere in the DI graph (HTTP, CLI, scheduled jobs — all the same); middleware and interceptors only fire inside the HTTP request pipeline. See the comparison in HTTP — Middleware vs. Interceptor vs. Plugin before deciding which to use.
Defining a Plugin
Plugins are @Injectable() classes with methods decorated by @Plugin():
import { Inject, Injectable } from '@modularityjs/di';
import { Plugin } from '@modularityjs/plugins';
@Injectable()
class CachePlugin {
constructor(@Inject(CacheService) private readonly cache: CacheService) {}
@Plugin({
target: UserService,
method: 'findById',
type: 'around',
name: 'user-cache',
order: 0,
})
async cacheUsers(
_subject: UserService,
proceed: (...args: unknown[]) => Promise<User>,
args: unknown[],
): Promise<User> {
const [id] = args as [string];
const cached = await this.cache.get<User>(`user:${id}`);
if (cached) return cached;
const result = await proceed(...args);
await this.cache.set(`user:${id}`, result);
return result;
}
}Plugin Types
Every handler receives the intercepted instance as its first argument (subject). The rest depends on type.
| Type | Signature | Description |
|---|---|---|
before | (subject, args) => args[] | Runs before the original method. Must return the (possibly modified) args array — synchronously. |
after | (subject, result, args) => result | Runs after the original method. Returned value replaces the flowing result for subsequent afters. |
around | (subject, proceed, args) => result | Wraps the original method. Call proceed(...args) to advance; whatever you return is the final value. |
Before
@Plugin({ target: OrderService, method: 'create', type: 'before', name: 'validate-order' })
validateOrder(_subject: OrderService, args: unknown[]): unknown[] {
const [order] = args as [CreateOrderDto];
if (order.total <= 0) {
throw new ValidationException([{ field: 'total', message: 'Must be positive' }]);
}
return args;
}After
@Plugin({ target: OrderService, method: 'create', type: 'after', name: 'log-order' })
logOrder(_subject: OrderService, result: Order, _args: unknown[]): Order {
this.logger.info(`Order created: ${result.id}`);
return result;
}Around
@Plugin({ target: UserService, method: 'findById', type: 'around', name: 'metrics' })
async trackMetrics(
_subject: UserService,
proceed: (...args: unknown[]) => Promise<User>,
args: unknown[],
): Promise<User> {
const start = Date.now();
try {
return await proceed(...args);
} finally {
this.metrics.record('user.findById', Date.now() - start);
}
}Registration
Register plugin classes in PluginsPool:
import { PluginsModule, PluginsPool } from '@modularityjs/plugins';
@Module({
name: 'my-plugins',
imports: [PluginsModule],
providers: [CachePlugin],
pools: [{ pool: PluginsPool, key: 'user-cache', useClass: CachePlugin }],
})
class MyPluginsModule {}Plugin Options
| Option | Type | Description |
|---|---|---|
target | Class | The service class to intercept |
method | string | The method name to intercept |
type | 'before' | 'after' | 'around' | Execution timing |
name | string | Unique plugin name |
order | number | Execution priority (lower runs first, default: 0) |
Ordering
When multiple plugins target the same method, they execute in order (ascending). For around plugins, lower-order wraps outer — the lowest order plugin's proceed() calls the next plugin, forming a chain.
Order is global across declarations: a plugin against an abstract base and a plugin against a concrete subclass on the same method are merged into one sorted chain at proxy time, so order controls the actual execution sequence regardless of which class the plugin targets.
Targeting Abstract Classes
A plugin's target can be an abstract base class. The framework discovers every concrete subclass bound in the DI container at boot and wires the plugin onto each — Magento-style "interface plugins" without compile-time codegen. The mechanism is runtime: when PluginSystem.applyAll runs, it walks the registered providers and, for each concrete class whose prototype chain includes a plugin target, registers a single container.onActivation hook with the merged plugin list.
@Injectable()
abstract class Notification {
abstract send(payload: unknown): Promise<void>;
}
@Injectable()
class EmailNotification extends Notification {
async send(payload: unknown) {
/* ... */
}
}
@Injectable()
class SmsNotification extends Notification {
async send(payload: unknown) {
/* ... */
}
}
@Injectable()
class TraceNotificationsPlugin {
@Plugin({
target: Notification, // abstract — applies to every concrete subclass
method: 'send',
type: 'around',
name: 'trace-notifications',
})
async around(
subject: Notification,
proceed: (...args: unknown[]) => Promise<void>,
args: unknown[],
): Promise<void> {
const start = Date.now();
try {
await proceed(...args);
} finally {
console.log(`${subject.constructor.name}.send: ${Date.now() - start}ms`);
}
}
}One plugin, one declaration; both EmailNotification.send and SmsNotification.send get traced. New subclasses pick up the trace automatically when added.
Multi-level hierarchies work the same way — a plugin against AbstractTop fires on ConcreteLeaf even when there's a MidLevel between them. Plugins on the abstract and on a concrete (e.g. logging on Notification plus retry on SmsNotification) coexist on the concrete proxy and execute by global order.
Module Boundaries
Plugins respect module dependency boundaries. A plugin can only target services from modules that its own module imports (directly or transitively). This is validated at boot time.
For abstract-class plugins, the boundary check applies to the target itself, not to every concrete subclass that hierarchy resolution might match. The contract is the boundary; implementers are details. A plugin module that targets Notification only needs to import the module owning Notification — concrete subclasses like EmailNotification and SmsNotification can live in any module without forcing the plugin module to depend on them. Mirrors Magento: a plugin against an interface depends on the interface's module, not on every concrete implementation. Abstract classes are typically not registered as providers, so validation silently skips them — the TypeScript compiler still requires the plugin module to import the file that exports the abstract (so the type is reachable), and apps wire the contract module so the abstract is loaded into the module graph anyway.
Lint enforcement
The plugin proxy is return-type transparent: whatever an after or around handler returns becomes the value the caller sees from the intercepted method. An async handler returning Promise<T> for a method declared T | Promise<T> would silently widen the contract for downstream callers, and TypeScript can't catch this on its own — the decorator is a method decorator on the handler, with no type relationship to target.method.
@modularityjs/coding-standard ships a custom ESLint rule @modularityjs/plugins/plugin-handler-return-type that uses the TypeScript checker to compare both sides:
- Bidirectional assignability between the handler's declared return type and the target method's declared return type. One-way isn't enough —
Promise<T>is assignable toT | Promise<T>, so unidirectional misses the widening case. Both directions catch any deviation. - Applies to
type: 'after'andtype: 'around'handlers. type: 'before'is exempt — its handler returnsargs[], a different contract.- Hierarchy targets check against the abstract base's declared return type. Concrete subclasses can return narrower (covariant) types; those don't matter — the plugin's contract is the abstract.
If you hit a mismatch diagnostic, the fix is usually to align the contract: tighten T | Promise<T> to Promise<T> on the target method (so handlers and callers agree the method is async) rather than loosen the handler. The rule is enabled at error severity by default in every package using createConfig / createFrameworkConfig / createDriverConfig.
CLI (@modularityjs/plugins-cli)
List all registered plugins:
plugin:list