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.
Method-interceptor decorators
The plugin system also powers a family of self-targeting method decorators — @Cacheable / @CacheEvict (@modularityjs/cache-plugins), @Retryable (@modularityjs/retry-plugins), @WithCircuitBreaker (@modularityjs/circuit-breaker-plugins), @WithLock (@modularityjs/lock-plugins), @RateLimited (@modularityjs/rate-limit-plugins), and @Transactional (@modularityjs/database-plugins). Instead of a separate plugin class targeting someone else's method, the decorator sits directly on the method it wraps:
import { WithCircuitBreaker } from '@modularityjs/circuit-breaker-plugins';
import { Retryable } from '@modularityjs/retry-plugins';
@Injectable()
class PaymentGateway {
@Retryable({ policy: { maxAttempts: 5 } })
@WithCircuitBreaker()
async charge(order: Order): Promise<Receipt> {
/* ... */
}
}The MethodInterceptorSourcesPool seam
Each decorator is built with createMethodInterceptorDecorator(sourceId, defaultOrder) — decoration writes metadata only (no container at import time). The semantics live in a MethodInterceptorSource subclass contributed to MethodInterceptorSourcesPool; at boot, PluginsModule registers a synthetic around-plugin per decorated method that delegates to the pool source with the matching sourceId. Every decorator therefore shares the plugin proxy, the order model, and the boot-time validation.
A decorated method whose source is not wired fails boot loudly — e.g. @Cacheable without CachePluginsModule in the app's modules throws a StateException naming the class, method, and missing module, rather than silently not intercepting.
Because the decorators are transparent pass-throughs and every shipped source is async, they only accept Promise-returning methods (enforced at compile time via the AsyncMethodDecorator descriptor constraint).
Self-invocation caveat
Interception happens on the proxy, so it only applies to calls that go through an injected (proxied) reference. A method calling a sibling method on the same instance — this.otherMethod() — runs the original directly with this bound to the raw instance, bypassing the proxy and that sibling's own interceptors (@Cacheable / @Retryable / @WithCircuitBreaker / @Transactional). This is the standard Spring/Nest proxy limitation. If an internally-called method must be intercepted, move it onto a separate injected collaborator so the call crosses the proxy boundary.
Ordering and stacking
All method-interceptor decorators share the @Plugin order model: lower = outermost, and every decorator accepts an order override. The defaults are chosen so stacking composes correctly:
| Decorator | Default order | Package |
|---|---|---|
@Cacheable | 100 | @modularityjs/cache-plugins |
@CacheEvict | 150 | @modularityjs/cache-plugins |
@Retryable | 200 | @modularityjs/retry-plugins |
@WithCircuitBreaker | 300 | @modularityjs/circuit-breaker-plugins |
@WithLock | 350 | @modularityjs/lock-plugins |
@Transactional | 400 | @modularityjs/database-plugins |
Stacked on one method, the defaults yield retry outside breaker outside lock outside transaction: a cache hit short-circuits everything below it; each retry attempt is individually admitted and recorded by the circuit breaker; each attempt takes and releases its own lock; and each attempt opens a fresh transaction (the failed attempt's work is rolled back before the next attempt runs).
@Retryable
Retries the method per a @modularityjs/retry policy, sleeping computeDelay between attempts. Requires RetryPluginsModule (which imports PluginsModule).
| Option | Type | Description |
|---|---|---|
policy | Partial<RetryPolicy> | Merged over the defaults { maxAttempts: 3, strategy: 'exponential', delayMs: 100, maxDelayMs: 5000 }. |
retryOn | (error: unknown) => boolean | Which errors retry. Default: everything except CircuitOpenException — never back off past an open circuit. |
order | number | Chain position; lower = outermost. Default 200. |
@WithCircuitBreaker
Guards the method with a circuit breaker: while open, calls are refused with CircuitOpenException (503 at HTTP boundaries) without invoking the method. Requires CircuitBreakerPluginsModule.
| Option | Type | Description |
|---|---|---|
policy | CircuitBreakerPolicy | Pass-through to the CircuitBreaker primitive (failureThreshold, resetTimeoutMs, successThreshold, isFailure, …). |
key | (args: unknown[]) => string | Partitions breakers per call via CircuitBreakerGroup — each distinct key gets its own breaker (e.g. per-host isolation). |
name | string | Breaker name carried by CircuitOpenException. Default <Class>.<method>. |
order | number | Chain position; lower = outermost. Default 300 (inside @Retryable). |
Breaker state is shared across instances of the class — failure accounting is per decorated method (or per key), keyed on the stable decoration site, so transient providers don't each get a private breaker.
@WithCircuitBreaker({
policy: { failureThreshold: 3, resetTimeoutMs: 10_000 },
key: (args) => new URL(args[0] as string).host, // one breaker per host
})
async fetchFeed(url: string): Promise<Feed> { /* ... */ }@WithLock
Runs the method under a cross-instance lock through LockService: acquire before the call, release after it returns or throws. Requires LockPluginsModule and a lock driver.
| Option | Type | Description |
|---|---|---|
ttlMs | number (required) | Lease length. No default — the right value is the method's worst-case runtime plus a margin, and a guess fails silently. |
key | string | ((args: unknown[]) => string) | Lock key. A function receives the call arguments, so one method can lock per entity. Default <Class>.<method> (global lock). |
order | number | Chain position; lower = outermost. Default 350. |
import { WithLock } from '@modularityjs/lock-plugins';
@WithLock({ ttlMs: 30_000, key: (args) => `payout:${String(args[0])}` })
async settle(accountId: string): Promise<void> { /* ... */ }Contention throws. LockService.acquire returns undefined when the key is held, and the decorator turns that into a ConflictException without invoking the method — a blocking poll hidden behind a plain method call would be an unbounded wait the caller never asked for. Stack @Retryable outside it when waiting is what you want; the default orders put the retry loop around the acquisition, so each attempt re-acquires.
Why 350. Outside @Transactional (400) because the lease must still be held at COMMIT: a lock released inside the transaction body frees the key before the commit lands, so another instance can acquire it and read pre-commit state — exactly the interleaving the lock was added to prevent. Inside @WithCircuitBreaker (300) so an open circuit refuses before the call pays for a round-trip to Redis and takes a cluster-wide lease it is only going to drop.
The token acquire returns authorizes the release and nothing else — it is not a fencing token, so it cannot stop a holder whose lease already expired from writing. If the protected resource needs that guarantee, give it its own compare-and-set. The lock is also not reentrant: a decorated method that re-enters itself with the same key deadlocks against its own lease until the TTL expires.
Release is best-effort: the method has already produced its result, so a failing release warns (ModularityJsWithLockReleaseFailed) instead of replacing that outcome with a lock-store error, and the lease expires on its own within ttlMs.
@RateLimited
Rate-limits the method through RateLimiterService. Over-limit calls throw RateLimitExceededException (429 at HTTP boundaries) without invoking the method. Requires RateLimitPluginsModule and a rate-limit driver.
| Option | Type | Description |
|---|---|---|
limit | number (required) | Max points admitted per key within windowMs. Positive integer. |
windowMs | number (required) | Window length in milliseconds. Positive integer. |
points | number | Points one call costs. Default 1 — weight expensive calls against the same budget. |
key | string | ((args: unknown[]) => string) | Bucket key, namespaced under method:. Default <Class>.<method>. |
failOpen | boolean | Admit calls when the limiter backend itself fails. Default false — a limiter outage must not disable the protection. |
order | number | Chain position; lower = outermost. Default 50. |
import { RateLimited } from '@modularityjs/rate-limit-plugins';
@Injectable()
class VendorSync {
// 100 calls/minute into the vendor's quota, per tenant
@RateLimited({
limit: 100,
windowMs: 60_000,
key: (args) => `vendor:${String(args[0])}`,
})
async pull(tenantId: string): Promise<void> {
/* ... */
}
}Rate limiting was HTTP-only before this — http-rate-limit gates the request pipeline, which leaves a queue consumer, a scheduled job, and a CLI command with no way to be limited at all, even though those are exactly the callers that hammer a metered third-party API. The two are deliberately coherent and deliberately uncoupled: same option names, same fail-closed default, same exceptions, no dependency between the packages. Keys are prefixed method: so a method bucket cannot collide with http-rate-limit's global bucket or its route: buckets when both share one limiter driver.
Why 50. Outside every other decorator, @Cacheable (100) included. Admission control a cache can bypass is not admission control: with the limiter inside the cache, the effective limit would depend on the hit ratio — not a number an operator can reason about, nor one an attacker is obliged to respect. It matches http-rate-limit, which enforces in the onRequest gate, the earliest point in the request. If what you want is to pace only the real downstream calls, put @RateLimited on the method that makes the call rather than on the cached wrapper around it.
Like the per-route @RateLimit, the bucket replaces the app-wide RateLimitConfig defaults rather than stacking on them, and both limit and windowMs are required — a half-specified bucket (tightened limit, inherited window) is almost always a mistake. A limiter-backend failure warns as ModularityJsRateLimitedError and then throws ServiceUnavailableException unless failOpen is set. A missing driver binding is not a backend failure: it propagates as broken assembly regardless of failOpen.
Writing your own decorator
Contribute a MethodInterceptorSource to the pool and expose a decorator built from the same sourceId:
import { Injectable } from '@modularityjs/di';
import { Module } from '@modularityjs/modularity';
import {
type AsyncMethodDecorator,
createMethodInterceptorDecorator,
MethodInterceptorSource,
MethodInterceptorSourcesPool,
type MethodInvocation,
PluginsModule,
} from '@modularityjs/plugins';
interface AuditedOptions {
order?: number | undefined;
}
@Injectable()
class AuditedInterceptorSource extends MethodInterceptorSource<AuditedOptions> {
readonly sourceId = 'audited';
async intercept(
invocation: MethodInvocation<AuditedOptions>,
): Promise<unknown> {
console.log(`${invocation.className}.${invocation.methodName} called`);
return invocation.proceed(...invocation.args);
}
}
export const Audited: (options?: AuditedOptions) => AsyncMethodDecorator =
createMethodInterceptorDecorator<AuditedOptions>('audited', 250);
@Module({
name: 'audit-plugins',
imports: [PluginsModule],
providers: [AuditedInterceptorSource],
pools: [
{
pool: MethodInterceptorSourcesPool,
key: 'audited',
useClass: AuditedInterceptorSource,
},
],
})
export class AuditPluginsModule {}Sources must resolve framework services lazily (inject Container, get on first use) — injecting a service directly can early-materialize it before plugin proxies attach, which the boot validator rejects.
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 / createDriverConfig.
CLI (@modularityjs/plugins-cli)
List all registered plugins:
plugin:list