nestjs-aspect
v0.0.5
Published
Spring-style aspect-oriented programming for NestJS
Maintainers
Readme
nestjs-aspect
Spring-style aspect-oriented programming for NestJS. An aspect is a Nest provider that holds a crosscutting concern, such as retries, auditing or transactions, together with pointcuts that select the methods it applies to. AspectModule weaves the aspects of an application into every class its container manages.
Aspect or enhancer · Quick start · Annotations · Pointcuts · Advice · Recipes · Module options · Ordering · Asynchronous join points · Advisors · Introductions · Inspection · Testing · Validation and errors · Limitations · Spring mapping
What it is
- Aspects are providers. An aspect is a class that holds one behavior shared by many methods, such as a transaction or a retry, apart from the classes that use it.
@Aspect()marks a class as an aspect and as an injectable provider with regular dependency injection. - Pointcuts are independent of the class hierarchy. A join point is one call of a method, or of an accessor, of a class the Nest container manages, and a pointcut is a value that selects join points. A pointcut selects methods by class, name, kind, module, provider token, annotation, Reflect metadata or argument; an annotation is a typed decorator that marks a method or a class for a pointcut and can carry a value. The advised class does not import the aspect and needs no decorator for it.
- Five kinds of advice. Advice is a method of an aspect that runs at the join points a pointcut selects: before the call, after it returns, after it throws, after either outcome, or around it. The decorators are
@Before,@AfterReturning,@AfterThrowing,@Afterand@Around, with the semantics and the ordering rules of Spring AOP. - Typed from the pointcut to the advice. A pointcut carries the target, argument and return types and the annotations it guarantees; the advice decorators check the advice method against them.
- Correct for asynchronous code. Advice follows the settlement of a promise, and of an Observable with the adapter of
nestjs-aspect/rxjs. - Errors instead of silent no-ops. A misconfiguration fails the bootstrap, and a method whose required advice is missing cannot be called.
- Every managed class.
AspectModuleweaves the aspects: it installs the advice on the selected methods while Nest instantiates the classes, and a method with advice is an advised method. Weaving covers providers, controllers, gateways, guards, interceptors, pipes, filters and middleware, in the singleton, request and transient scopes.
When to use an aspect
Nest runs guards, pipes, interceptors and exception filters around the handlers it dispatches to: HTTP route handlers, microservice message handlers, WebSocket gateway handlers and GraphQL resolvers. They receive the ExecutionContext with the request and the response, and they remain the right tool for the request pipeline. Advice sees a method call, its arguments and its outcome, not the request. This package is for the calls that enhancers do not reach.
| Concern | Tool |
| ------------------------------------------------------------------------------------------------------------------------------ | ---------------- |
| Authentication and authorization of a handler | Guard |
| Validation and transformation of the arguments of a handler | Pipe |
| Logging, timing, a timeout, response mapping or response caching (CacheInterceptor) of a handler | Interceptor |
| Mapping an error to a response | Exception filter |
| Behavior around a call from one provider to another: service to service, service to repository, a client of a remote system | Aspect |
| Behavior around a method that no handler dispatch reaches: one that a timer, a job worker, an event emitter or a command calls | Aspect |
| One behavior for many methods selected by module, class, name or provider token, without a decorator on each of them | Aspect |
- An enhancer binds to a handler.
@UseGuards(),@UseInterceptors(),@UsePipes()and@UseFilters()on a method that Nest does not dispatch to as a handler have no effect, unless an integration runs the enhancer chain around it withExternalContextCreator. A global enhancer applies to every handler, not to every provider. - An interceptor surrounds the handler as a whole. It cannot delimit a transaction at one service method or retry one call of a remote client.
- A call that starts outside a handler has no enhancer chain. An error a job throws reaches no exception filter.
- Advice has no
ExecutionContext. A concern that reads the request, sets a response header or depends on the route belongs in an enhancer. On a route handler both apply: advice runs inside the interceptors.
Behind the handlers, two plain techniques cover a provider method without this package:
- An explicit call through an injected dependency inside the method, such as
this.database.transactional(() => ...)orwrap(key, () => ...)of the cache that@nestjs/cache-managerprovides. It works for every provider method, in a request and outside one, and is the simpler choice for one or two call sites. - A TypeScript method decorator that replaces
descriptor.valuewith a wrapper. It needs no library, but it runs when the class is defined, outside the container: it cannot inject a provider and reaches one only through global or static state.
An aspect adds four things to them. When a concern needs none of the four, use the explicit call.
- Advice is a provider: it injects the database client, the error reporter or the configuration it works with.
- A pointcut selects methods without touching them, the methods of classes written later included.
- Several concerns on one method run in a defined order.
requiresAdvicefails the bootstrap when the behavior an annotation promises is not registered.
Installation
pnpm add nestjs-aspectRequirements:
- Node.js 24 or later and NestJS 12.
- Peer dependencies:
@nestjs/commonand@nestjs/core^12.0.0,reflect-metadata^0.2.2,rxjs^7.8.2. - Legacy decorators with emitted metadata, as Nest itself requires:
"experimentalDecorators": trueand"emitDecoratorMetadata": trueintsconfig.json.
The package ships ES module and CommonJS builds with TypeScript declarations. It has two entry points: nestjs-aspect and nestjs-aspect/rxjs. The types of nestjs-aspect/rxjs resolve through the exports field of the package, which TypeScript reads with moduleResolution node16, nodenext or bundler.
Quick start
An annotation marks the methods, an aspect holds the behavior, and AspectModule.forRoot() weaves the aspect into the application. Pointcut is the namespace of the pointcut designators, the functions that build pointcuts; the examples import it as P for brevity. This application records who changed what: every audited method leaves an entry with its actor, whether it succeeds or fails.
import { AsyncLocalStorage } from "node:async_hooks";
import { Injectable, Module, NotFoundException } from "@nestjs/common";
import {
AfterReturning,
AfterThrowing,
Aspect,
AspectModule,
createAnnotation,
type JoinPointOf,
Pointcut as P,
} from "nestjs-aspect";
export const Audited = createAnnotation<
"Audited",
string,
(subject: string, ...rest: any[]) => Promise<unknown>
>("Audited");
const audited = P.annotated(Audited);
@Injectable()
export class Actor {
private readonly storage = new AsyncLocalStorage<string>();
run<T>(actor: string, work: () => T): T {
return this.storage.run(actor, work);
}
current(): string | undefined {
return this.storage.getStore();
}
}
export interface AuditEntry {
action: string;
actor: string;
subject: string;
outcome: "ok" | "failed";
error?: string;
}
@Injectable()
export class AuditLog {
readonly entries: AuditEntry[] = [];
async record(entry: AuditEntry): Promise<void> {
this.entries.push(entry);
}
}
@Aspect()
export class AuditAspect {
constructor(
private readonly actor: Actor,
private readonly log: AuditLog,
) {}
@AfterReturning(audited)
async succeeded(joinPoint: JoinPointOf<typeof audited>): Promise<void> {
await this.log.record(this.entry(joinPoint, "ok"));
}
@AfterThrowing(audited)
async failed(
joinPoint: JoinPointOf<typeof audited>,
error: unknown,
): Promise<void> {
await this.log.record({
...this.entry(joinPoint, "failed"),
error: error instanceof Error ? error.name : typeof error,
});
}
private entry(
joinPoint: JoinPointOf<typeof audited>,
outcome: AuditEntry["outcome"],
): AuditEntry {
return {
action: joinPoint.annotation(Audited),
actor: this.actor.current() ?? "anonymous",
subject: joinPoint.args[0],
outcome,
};
}
}
@Injectable()
export class MemberService {
private readonly roles = new Map<string, string>();
@Audited("member.invite")
async invite(memberId: string, role: string): Promise<void> {
this.roles.set(memberId, role);
}
@Audited("member.remove")
async remove(memberId: string): Promise<void> {
if (!this.roles.delete(memberId)) {
throw new NotFoundException(`Unknown member ${memberId}`);
}
}
}
@Injectable()
export class ApiKeyService {
private readonly revoked = new Set<string>();
@Audited("api-key.revoke")
async revoke(keyId: string): Promise<void> {
this.revoked.add(keyId);
}
}
@Module({
imports: [AspectModule.forRoot()],
providers: [Actor, AuditLog, AuditAspect, MemberService, ApiKeyService],
})
export class AppModule {}Actorcarries the identity of the caller in anAsyncLocalStorage. A middleware or a guard callsactor.run()for a request and a job for its run, so the advice finds the actor wherever the call starts.Auditedtakes the name of the action. Its method contract states that an audited method returns a promise and names what it changes in its first argument: the annotation on any other method is a compile error, and the advice readsjoinPoint.args[0]as astring. An entry holds the fields the advice names and no other argument of the call.AuditLogstands in for the audit table, and the collections of the services for their tables.
The services carry the annotation and no audit code. The advice runs when the promise of a call settles:
import { NestFactory } from "@nestjs/core";
const app = await NestFactory.createApplicationContext(AppModule);
const members = app.get(MemberService);
await app.get(Actor).run("alice", async () => {
await members.invite("member-7", "editor");
await app.get(ApiKeyService).revoke("key-3");
});
await members.remove("member-9").catch(() => undefined);
const { entries } = app.get(AuditLog);
// [
// { action: "member.invite", actor: "alice", subject: "member-7", outcome: "ok" },
// { action: "api-key.revoke", actor: "alice", subject: "key-3", outcome: "ok" },
// {
// action: "member.remove",
// actor: "anonymous",
// subject: "member-9",
// outcome: "failed",
// error: "NotFoundException",
// },
// ]Without the aspect, each of the three methods wraps its body in a try/catch that calls this.log.record(...) on both paths. Import AspectModule.forRoot() once, as the first import of the root module; AspectModule lists its options.
A complete HTTP application with transaction, retry and call statistics aspects around the calls between its providers, and a worker that makes those calls without a request, is in examples/basic. Its README has the commands that run it.
Annotations
createAnnotation(name, options?) creates a typed metadata decorator for methods and classes. Its type parameters are the name literal, the value type and the method contract the annotation accepts.
import { createAnnotation } from "nestjs-aspect";
export interface RetryOptions {
attempts: number;
backoffMilliseconds: number;
}
export class TransientError extends Error {}
export const Retryable = createAnnotation<
"Retryable",
RetryOptions,
(...args: any[]) => Promise<unknown>
>("Retryable", { requiresAdvice: true });
export const RequiresPermission = createAnnotation<
"RequiresPermission",
string
>("RequiresPermission", { requiresAdvice: true });
export const Evicts = createAnnotation<
"Evicts",
(...args: any[]) => string,
(...args: any[]) => Promise<unknown>
>("Evicts");Retryabletakes aRetryOptionsvalue and applies only to methods that return a promise;@Retryable()without a value or on a synchronous method is a compile error. It marks a call that is safe to repeat: a read, a write that carries an idempotency key, or a transaction that has rolled back.TransientErroris the class of the errors after which another attempt can succeed.RequiresPermissiontakes the name of a permission.Evictstakes a function from the arguments of the method to a cache key.- An annotation created without a value type, as
createAnnotation<"Draft">("Draft"), is applied without an argument:@Draft().
The example application applies the three, and Advice has the aspect of each.
- The name is the identity of an annotation: annotations created with the same name read and write the same metadata.
- A method annotation belongs to the method that declares it; an override without the annotation does not carry it. A class annotation is inherited by subclasses. An annotation without a method contract also applies to accessors.
requiresAdvicestates that an annotated member must be advised by an advisor that handles the annotation: one whose pointcut refers to it withP.annotated,P.withinAnnotatedorP.targetAnnotated, in any composition, or whose pointcut comes fromP.matchingor is hand-written. When no such advisor exists, for example because the aspect is not registered,init()fails, and in every mode a call of the member raisesAspectConfigurationErrorinstead of running without the advice. The same holds after the application has closed or has failed to initialize: on an instance its container created, the call of a member that requires advice raisesAspectConfigurationError.isAnnotation(value)tells an annotation from other functions.- Decorators created with Nest's
Reflector.createDecorator()are accepted byP.annotated,P.withinAnnotated,P.targetAnnotatedandjoinPoint.findAnnotation.
The example application
The sections on pointcuts, advice, ordering and inspection use one module: two clients of remote services, a repository and two services. The module reaches its infrastructure through abstract classes, which are also the injection tokens:
export class TransportError extends Error {}
export abstract class Transport {
abstract send<T>(operation: string, payload: object): Promise<T>;
}
export abstract class Cache {
abstract wrap<T>(key: string, load: () => Promise<T>): Promise<T>;
abstract del(key: string): Promise<void>;
}Transport sends a request to a remote service and rejects with TransportError when no answer arrives. Cache has the wrap and del of the cache that @nestjs/cache-manager provides. InfrastructureModule binds the abstract classes of this README to the adapters of the application; the tests that run these examples bind in-memory ones.
import { Injectable, Module, NotFoundException } from "@nestjs/common";
import { defer, mergeAll, type Observable } from "rxjs";
@Injectable()
export class PaymentClient {
constructor(private readonly transport: Transport) {}
@Retryable({ attempts: 3, backoffMilliseconds: 200 })
async charge(idempotencyKey: string, amount: number): Promise<string> {
return this.transport.send("payments/charge", { idempotencyKey, amount });
}
@Retryable({ attempts: 3, backoffMilliseconds: 200 })
async refund(idempotencyKey: string, chargeId: string): Promise<string> {
return this.transport.send("payments/refund", { idempotencyKey, chargeId });
}
async *settlements(day: string): AsyncGenerator<string> {
let cursor: string | undefined;
do {
const page = await this.transport.send<{
items: string[];
next?: string;
}>("payments/settlements", { day, cursor });
yield* page.items;
cursor = page.next;
} while (cursor);
}
}
@Injectable()
export class ShippingClient {
constructor(private readonly transport: Transport) {}
@Retryable({ attempts: 2, backoffMilliseconds: 200 })
async quote(destination: string): Promise<number> {
return this.transport.send("shipping/quote", { destination });
}
async book(orderId: string, destination: string): Promise<string> {
return this.transport.send("shipping/book", { orderId, destination });
}
track(shipmentId: string): Observable<string> {
return defer(() =>
this.transport.send<string[]>("shipping/track", { shipmentId }),
).pipe(mergeAll());
}
}
@Injectable()
export class ProductRepository {
private readonly prices = new Map<string, number>();
async findPrice(sku: string): Promise<number | undefined> {
return this.prices.get(sku);
}
async savePrice(sku: string, price: number): Promise<void> {
this.prices.set(sku, price);
}
async remove(sku: string): Promise<void> {
this.prices.delete(sku);
}
}
const priceKey = (sku: string) => `price:${sku}`;
@Injectable()
export class CatalogService {
constructor(
private readonly products: ProductRepository,
private readonly cache: Cache,
) {}
async price(sku: string): Promise<number | undefined> {
return this.cache.wrap(priceKey(sku), () => this.products.findPrice(sku));
}
@RequiresPermission("catalog:write")
@Evicts(priceKey)
async reprice(sku: string, price: number): Promise<void> {
await this.products.savePrice(sku, price);
}
@RequiresPermission("catalog:write")
@Evicts(priceKey)
async discontinue(sku: string): Promise<void> {
await this.products.remove(sku);
}
}
@Injectable()
export class CheckoutService {
constructor(
private readonly catalog: CatalogService,
private readonly payments: PaymentClient,
private readonly shipping: ShippingClient,
) {}
async pay(
orderId: string,
sku: string,
destination: string,
): Promise<string> {
const price = await this.catalog.price(sku);
if (price === undefined) {
throw new NotFoundException(`Unknown product ${sku}`);
}
const delivery = await this.shipping.quote(destination);
return this.payments.charge(`charge:${orderId}`, price + delivery);
}
@RequiresPermission("payments:refund")
async refund(orderId: string, chargeId: string): Promise<string> {
return this.payments.refund(`refund:${orderId}`, chargeId);
}
@RequiresPermission("orders:ship")
async ship(orderId: string, destination: string): Promise<string> {
return this.shipping.book(orderId, destination);
}
}
@Module({
imports: [InfrastructureModule],
providers: [
PaymentClient,
ShippingClient,
ProductRepository,
CatalogService,
CheckoutService,
],
exports: [
PaymentClient,
ShippingClient,
ProductRepository,
CatalogService,
CheckoutService,
],
})
export class ShopModule {}chargeandrefundsend an idempotency key andquoteis a read, so the three are@Retryable.bookhas no key: a second attempt could book a second shipment, and the method carries no annotation.settlementsrequests the pages of a remote list while its caller iterates, andtrackreturns a cold Observable, as a client built onHttpServicedoes. Asynchronous join points covers both.- The collection of
ProductRepositorystands in for a table.
Pointcuts
The designators of Pointcut, imported as P, build typed pointcuts. Pointcut<Shape> is also the type of a pointcut, so the same import names both. A pointcut is a value: export it as a constant and share it between aspects.
| Selects by | Designator | Matches |
| ---------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Class | P.within(...Types) | Members declared on one of the classes. |
| Class | P.target(Type) | Members of Type and its subclasses. |
| Class | P.bean("token*" \| /Service$/ \| Type) | Classes registered under a matching provider token. |
| Class | P.inModule(Module, { transitive }) | Providers and controllers a Nest module declares; with transitive, also those of the modules it imports. |
| Class | P.withinAnnotated(A) | Members declared on a class that carries A. |
| Class | P.targetAnnotated(A) | Members of a class that carries A. |
| Member | P.method(Type, "name") | That method of Type and its subclasses. Carries its exact signature. The only designator for lifecycle hooks. |
| Member | P.name("find*" \| /^find/) | Members by name; * in a string matches any run of characters. |
| Member | P.kind("method" \| "getter" \| "setter") | Members by kind. The only designator for accessors. |
| Member | P.annotated(A) | Members that carry A. Carries the method contract of A and guarantees A. |
| Member | P.metadata(key, predicate?) | Members with Reflect metadata key, such as metadata stored by SetMetadata. |
| Call | P.args(String, P.ANY, P.REST) | Calls by their arguments, checked on every call. Types joinPoint.args. |
| Custom | P.matching({ classFilter, methodMatcher }) | Members a hand-written class filter and method matcher accept. |
Compose pointcuts with .and(), .or() and .not(), or fold a list with P.all(...) and P.any(...). These are the pointcuts of the aspects in Advice:
import { Pointcut as P } from "nestjs-aspect";
export const retryable = P.annotated(Retryable);
export const guarded = P.annotated(RequiresPermission);
export const evicting = P.annotated(Evicts);
export const asynchronous = P.matching({
methodMatcher: { isRuntime: false, matches: (method) => method.isAsync },
});
export const clients = P.inModule(ShopModule).and(P.bean("*Client"));
export const clientCalls = clients.and(asynchronous);retryable,guardedandevictingselect the methods that carry an annotation.clientsselects every method of every provider ofShopModulewhose token ends inClient: the six methods ofPaymentClientandShippingClient, which carry no decorator for it, and those of a client that joins the module later.asynchronousselects the methods known to return a promise.MethodRef.isAsyncis true for a method declaredasync, and for a decorated method whose declared return type isPromise; a method that returns a promise without either is not selected.clientCallsis the part ofclientsthat returns a promise. It leaves outsettlementsandtrack, whose results settle after the method has returned, if they are consumed at all.
Further rules:
- Annotation or structure. Select by annotation when a method opts in and carries a value for the advice, as
@Retryable({ attempts: 3, backoffMilliseconds: 200 })does. Select by module or provider token when the rule holds for a group of classes and must also hold for the classes added to it later. - Names. A selection by name, such as
P.name("find*"), holds as long as the naming convention does: a method that departs from the convention leaves or joins the selection without an error..not()alone matches almost every method of the application; it belongs behind.and(). - Checking a selection. No method names the aspect that a structural pointcut applies to it. Inspection pins the selected members in a test, the
report: "log"option prints them as the weaving report, and a pointcut that matches no member is a warning of the bootstrap validation. P.withinandP.target.P.within(CatalogService)selects the membersCatalogServiceitself declares.P.target(CatalogService)selects every member ofCatalogServiceand of its subclasses, the members a subclass adds or overrides included.- Types.
retryablecarries the method contract and the value type ofRetryable.P.method(ShippingClient, "quote")carries the signature of that method: its advice readsjoinPoint.argsas[string]and the result of after-returning advice asnumber.P.args(String, Number)typesjoinPoint.argsas[string, number].andguarantees the annotations of both sides,orthose common to both,notnone. Broad pointcuts such asP.withinandP.nametype arguments and results asunknown. - Default join points. Broad pointcuts match methods only, and leave out the Nest lifecycle hooks
onModuleInit,onApplicationBootstrap,onModuleDestroy,beforeApplicationShutdownandonApplicationShutdown.P.methodselects a lifecycle hook, andP.kind("getter")orP.kind("setter")selects accessors. A constructor is never a join point. - Static and dynamic matching. Every designator except
P.argsis evaluated once per class member when the class is woven.P.argsis evaluated on every call of a member the rest of the pointcut matches; combine it with a static designator. InP.args, a class matches an argument that isinstanceofit,String,Number,Boolean,BigIntandSymbolmatch their primitives,P.ANYmatches one argument andP.RESTthe remaining ones. P.beanmatches a class when one of its registrations has a matching token,useClasstokens included. A string or a regular expression is matched against the name of a class token, the text of a string token and the description of a symbol token. AuseExistingtoken and the instance of a singletonuseFactoryoruseValueprovider are matched when the application initializes, and the instance of a request-scoped or transientuseFactoryprovider when the container first resolves one.P.beanselects the class: its advice then applies to every instance of that class.P.inModule(Module, { transitive: true })follows the imports of the module. A global module counts only when the@Module()decorator of the importing module lists it.P.matchingselects by a property no designator covers.classFilter.matches(classRef)receives the class (type) and its registrations (beans).methodMatcher.matches(method)receives aMethodRef:name,kind,declaringType,targetType,original(the function itself) andisAsync.isRuntime: falsestates that this decision, made once when the class is woven, is final; withisRuntime: true,matchesRuntime(method, args)also runs on every call.
Advice
@Aspect({ order?, instantiation? }) marks a class as an aspect and as a Nest provider; list it in the providers of a module. Each advice decorator takes a pointcut and checks the signature of the method it decorates against it. JoinPointOf<typeof pointcut> and ProceedingJoinPointOf<typeof pointcut> are the join point types of a pointcut.
| Decorator | Advice signature | Semantics |
| ----------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| @Before(pointcut) | (joinPoint: JoinPointOf<typeof pointcut>) | Runs before the join point. An error prevents the join point and propagates. |
| @AfterReturning(pointcut) | (joinPoint: JoinPointOf<typeof pointcut>, result) | Runs after a normal completion with the result. It cannot replace the result. |
| @AfterThrowing(pointcut, { throwing? }) | (joinPoint: JoinPointOf<typeof pointcut>, error) | Runs when the join point fails, with throwing only for errors of that class. The error propagates afterwards. |
| @After(pointcut) | (joinPoint: JoinPointOf<typeof pointcut>) | Runs after the join point, whatever its outcome. |
| @Around(pointcut) | (joinPoint: ProceedingJoinPointOf<typeof pointcut>) returning the result type | Surrounds the join point: it decides whether and how often the join point runs, and its return value is the result. |
Use the least powerful advice that does the job: an after-returning advice cannot forget to call proceed(). The aspects below advise the example application with the pointcuts of Pointcuts; Ordering explains their order values.
Before
Before advice runs ahead of the join point and can prevent it only by throwing. This aspect checks a permission at the service method. A guard checks it at a route handler; the same method called by a job, a queue consumer or another service passes no guard. Actor is the provider of the Quick start, and Permissions stands in for the store of the granted permissions.
import { ForbiddenException, Injectable } from "@nestjs/common";
import { Aspect, Before, type JoinPointOf } from "nestjs-aspect";
@Injectable()
export class Permissions {
private readonly granted = new Map<string, Set<string>>();
grant(actor: string, permission: string): void {
const permissions = this.granted.get(actor) ?? new Set<string>();
this.granted.set(actor, permissions.add(permission));
}
allows(actor: string | undefined, permission: string): boolean {
return (
actor !== undefined && (this.granted.get(actor)?.has(permission) ?? false)
);
}
}
@Aspect({ order: 0 })
export class PermissionAspect {
constructor(
private readonly actor: Actor,
private readonly permissions: Permissions,
) {}
@Before(guarded)
check(joinPoint: JoinPointOf<typeof guarded>): void {
const actor = this.actor.current();
const permission = joinPoint.annotation(RequiresPermission);
if (!this.permissions.allows(actor, permission)) {
throw new ForbiddenException(
`${actor ?? "anonymous"} lacks ${permission}`,
);
}
}
}A call without an actor is denied. On an asynchronous method, the error of before advice rejects the promise of the call:
const checkout = app.get(CheckoutService);
app.get(Permissions).grant("support-agent", "payments:refund");
const refund = await actor.run("support-agent", () =>
checkout.refund("order-1", "charge-1"),
);
const denied = await checkout
.refund("order-1", "charge-1")
.catch((error) => error);
// refund: the result of PaymentClient.refund
// denied: ForbiddenException "anonymous lacks payments:refund"; PaymentClient.refund is not calledThe alternative is a this.permissions.require("payments:refund") line at the top of each method. The annotation adds two checks to it: requiresAdvice keeps an application that lacks PermissionAspect from starting with its guarded methods open, and the inspector lists every guarded member.
After returning
After-returning advice receives the result of a normal completion as its second parameter: for a method that returns a promise, the value the promise resolves to. An error the advice throws propagates instead of the result. This aspect evicts a cached value after the write that changes it has succeeded; a failed write evicts nothing.
import { Logger } from "@nestjs/common";
import { AfterReturning, Aspect, type JoinPointOf } from "nestjs-aspect";
@Aspect({ order: 50 })
export class CacheEvictionAspect {
private readonly logger = new Logger(CacheEvictionAspect.name);
constructor(private readonly cache: Cache) {}
@AfterReturning(evicting)
async evict(joinPoint: JoinPointOf<typeof evicting>): Promise<void> {
const key = joinPoint.annotation(Evicts)(...joinPoint.args);
try {
await this.cache.del(key);
} catch (error) {
this.logger.warn(`Cannot evict ${key}: ${error}`);
}
}
}- The advice catches a failure of the cache: the write has succeeded, and its caller receives no error for the eviction.
- With
order: 50the aspect is outside a transaction aspect of order 100, so the eviction follows the commit. Athis.cache.del(key)line inside a transactional method runs before the commit, and a concurrent reader caches the old value again. CacheInterceptorcaches the responses of GET route handlers. It does not evict when a service method writes.
After throwing
After-throwing advice receives the error of the join point: a thrown error or the rejection of its promise. With throwing, it runs only for errors that are instanceof that class. The same error propagates after the advice; an error the advice throws replaces it. It is not a catch block: the caller still receives an error. This aspect gives every method of the clients one error class for a remote service that did not answer.
import { AfterThrowing, Aspect, type JoinPointOf } from "nestjs-aspect";
export class UpstreamUnavailableError extends TransientError {}
@Aspect({ order: 30 })
export class UpstreamErrorAspect {
@AfterThrowing(clients, { throwing: TransportError })
translate(
joinPoint: JoinPointOf<typeof clients>,
error: TransportError,
): never {
const { targetType, name } = joinPoint.signature;
throw new UpstreamUnavailableError(
`${targetType.name}.${String(name)} did not answer`,
{ cause: error },
);
}
}UpstreamUnavailableErrornames the client method and keeps the transport error as itscause. It is aTransientError, soRetryAspectrepeats the call.- Every other error of a client, such as a rejection by the remote service, propagates unchanged.
- An exception filter maps an error to a response at a handler. It does not translate an error between two providers.
The alternative is a try/catch with the same mapping in every client method.
After
After advice runs when the join point has finished, whether it returned or failed, like a finally block. For an asynchronous join point it runs when the promise settles. An error the advice throws replaces the outcome. This aspect counts the client calls in flight, and the provider that holds the count delays the shutdown of the application until they have settled. Without it, a deployment that stops the process cuts off the remote call a job has started.
import { type BeforeApplicationShutdown, Injectable } from "@nestjs/common";
import { After, Aspect, Before } from "nestjs-aspect";
@Injectable()
export class InFlightCalls implements BeforeApplicationShutdown {
private count = 0;
private drained?: () => void;
enter(): void {
this.count += 1;
}
leave(): void {
this.count -= 1;
if (this.count === 0) {
this.drained?.();
}
}
async beforeApplicationShutdown(): Promise<void> {
if (this.count > 0) {
await new Promise<void>((resolve) => {
this.drained = resolve;
});
}
}
}
@Aspect({ order: 10 })
export class DrainAspect {
constructor(private readonly calls: InFlightCalls) {}
@Before(clientCalls)
enter(): void {
this.calls.enter();
}
@After(clientCalls)
leave(): void {
this.calls.leave();
}
}- Within one aspect, before advice is outside after advice: every
enteris followed by itsleave, also when the call fails. - The pointcut is
clientCalls, notclients. A pair of before and after advice needs a result that settles exactly once, and the after advice of an Observable that is never subscribed does not run. - With
order: 10the aspect is outsideRetryAspect: a call counts once and stays in flight through its retries. - The wait has no time limit of its own; the termination grace period of the platform bounds it.
The alternative is a try/finally with the two calls in every client method.
Around
Around advice surrounds the join point. joinPoint.proceed() runs the rest of the chain and the join point, and can be called zero times, once or several times; joinPoint.proceedWith(...args) runs it with other arguments. The value the advice returns is the result of the call. retryable guarantees the Retryable annotation, so joinPoint.annotation(Retryable) is typed as RetryOptions.
import { Around, Aspect, type ProceedingJoinPointOf } from "nestjs-aspect";
@Aspect({ order: 20 })
export class RetryAspect {
@Around(retryable)
async retry(
joinPoint: ProceedingJoinPointOf<typeof retryable>,
): Promise<unknown> {
const { attempts, backoffMilliseconds } = joinPoint.annotation(Retryable);
for (let attempt = 1; ; attempt += 1) {
try {
return await joinPoint.proceed();
} catch (error) {
if (!(error instanceof TransientError) || attempt >= attempts) {
throw error;
}
const ceiling = backoffMilliseconds * 2 ** (attempt - 1);
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * ceiling),
);
}
}
}
}- The aspect repeats a call only after a
TransientError. A programming error, a validation error or a rejection by the remote service is not repeated. - Before each further attempt it waits a random time below a ceiling that doubles with every attempt, which spreads the retries of concurrent callers.
- A repeated call must be safe to repeat.
PaymentClient.chargesends the same idempotency key on every attempt;ShippingClient.bookhas no key and no annotation. - A
retry(() => ...)helper at the call site is equivalent for a single call. The aspect is the simpler form where the retry has to surround other advice: the transaction recipe repeats a whole transaction.
The join point
| Member | Meaning |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| joinPoint.target | The object the member is called on. |
| joinPoint.args | The arguments, frozen. proceedWith runs the join point with other arguments. |
| joinPoint.signature | name, declaringType (the class that defines the member) and targetType (the woven class). |
| joinPoint.kind | "method", "getter" or "setter". |
| joinPoint.isAsync | True for an async method and for a decorated method whose declared return type is Promise. |
| joinPoint.annotation(A) | The value of an annotation the pointcut guarantees. A compile error when the pointcut does not guarantee A. |
| joinPoint.findAnnotation(A) | The value of an annotation, or undefined when it is absent. |
| joinPoint.introduced(Token) | The delegate of an introduction for the target. |
| joinPoint.toString() | 'ShippingClient.quote("Lisbon")': the member and every argument of the call. |
| joinPoint.proceed(), joinPoint.proceedWith(...args) | Around advice only. |
Annotation lookups read the method, then the declaring class, then the target class.
The compiler rejects an annotation on a method outside its contract, a missing or mistyped annotation value, around advice with the wrong return type, proceedWith arguments that do not fit the method, an after-returning result or after-throwing error parameter of the wrong type, asynchronous advice on a join point known to be synchronous, and P.method with a name that is not a method of the class.
Recipes
Each recipe advises calls that no Nest enhancer surrounds. The recipes use the annotations and RetryAspect of the sections above.
A transaction around a service method
An interceptor can open a transaction only around a whole route handler, and not at all for a method that a job or another service calls. The aspect delimits the transaction at the service method, and an AsyncLocalStorage carries it to the repositories the method calls.
DatabaseClient stands for the database client of the application and is provided by InfrastructureModule. Its transaction is the transaction callback of the client, such as $transaction of Prisma, transaction of a TypeORM DataSource or transaction of Drizzle, and a Connection is what a repository runs its statements on.
export interface TransactionOptions {
isolation?: "read committed" | "serializable";
}
export interface Connection {
get(key: string): Promise<number | undefined>;
set(key: string, value: number): Promise<void>;
}
export abstract class DatabaseClient implements Connection {
abstract get(key: string): Promise<number | undefined>;
abstract set(key: string, value: number): Promise<void>;
abstract transaction<T>(
work: (transaction: Connection) => Promise<T>,
options?: TransactionOptions,
): Promise<T>;
}Database is the provider an application writes around its client. transactional joins the active transaction or begins one, and connection is the transaction of the current call, or the client outside a transaction. A transaction that the database has ended because of a conflict fails with a TransientError: 40001 and 40P01 are the PostgreSQL codes of a serialization failure and of a deadlock.
import { AsyncLocalStorage } from "node:async_hooks";
import { Injectable } from "@nestjs/common";
const conflicts = new Set(["40001", "40P01"]);
@Injectable()
export class Database {
private readonly active = new AsyncLocalStorage<Connection>();
constructor(private readonly client: DatabaseClient) {}
get connection(): Connection {
return this.active.getStore() ?? this.client;
}
async transactional<T>(
work: () => Promise<T>,
options?: TransactionOptions,
): Promise<T> {
if (this.active.getStore()) {
return work();
}
try {
return await this.client.transaction(
(transaction) => this.active.run(transaction, work),
options,
);
} catch (error) {
const code = (error as { code?: unknown } | null)?.code;
if (typeof code === "string" && conflicts.has(code)) {
throw new TransientError("The transaction lost a conflict", {
cause: error,
});
}
throw error;
}
}
}The annotation carries the options of the transaction, and the aspect passes them on:
import {
Around,
Aspect,
createAnnotation,
Pointcut as P,
type ProceedingJoinPointOf,
} from "nestjs-aspect";
export const Transactional = createAnnotation<
"Transactional",
TransactionOptions,
(...args: any[]) => Promise<unknown>
>("Transactional", { requiresAdvice: true });
export const transactional = P.annotated(Transactional).or(
P.withinAnnotated(Transactional).and(asynchronous),
);
@Aspect({ order: 100 })
export class TransactionAspect {
constructor(private readonly database: Database) {}
@Around(transactional)
run(
joinPoint: ProceedingJoinPointOf<typeof transactional>,
): Promise<unknown> {
return this.database.transactional(
async () => joinPoint.proceed(),
joinPoint.findAnnotation(Transactional),
);
}
}transactionalselects the methods that carry@Transactional({}), a transaction with the defaults of the database, and the asynchronous methods of a class that carries the annotation.joinPoint.findAnnotation(Transactional)reads the options from the method, then from its class.- An annotated class begins a transaction for each of its asynchronous methods, its reads included, and each holds a connection for the length of the call. Annotate a class whose methods are all writes; annotate the methods otherwise.
requiresAdvicekeeps an annotated method from running without a transaction: whenTransactionAspectis not registered, the application fails to initialize, and a call of the method raisesAspectConfigurationErrorin every mode.- A call inside an active transaction joins it.
- The
AsyncLocalStoragefollows the asynchronous work the method awaits. Work it does not await, such as a promise it leaves pending or a listener that runs later, still finds the connection of a transaction that has ended.
The repository obtains its connection from Database and knows nothing of the transaction. add reads a balance and writes the sum, which is correct only when no other transaction changes the row in between. WalletService therefore asks for serializable isolation, under which the database fails one of two transactions that have read the same balance, and @Retryable has RetryAspect of Around repeat the one that failed.
import { Injectable, Module } from "@nestjs/common";
import { AspectModule } from "nestjs-aspect";
export class InsufficientFundsError extends Error {}
@Injectable()
export class WalletRepository {
constructor(private readonly database: Database) {}
async balance(owner: string): Promise<number> {
return (await this.database.connection.get(owner)) ?? 0;
}
async add(owner: string, amount: number): Promise<void> {
const balance = (await this.balance(owner)) + amount;
if (balance < 0) {
throw new InsufficientFundsError(`${owner} cannot pay ${-amount}`);
}
await this.database.connection.set(owner, balance);
}
}
@Injectable()
@Transactional({ isolation: "serializable" })
export class WalletService {
constructor(private readonly wallets: WalletRepository) {}
@Retryable({ attempts: 3, backoffMilliseconds: 50 })
async deposit(owner: string, amount: number): Promise<void> {
await this.wallets.add(owner, amount);
}
@Retryable({ attempts: 3, backoffMilliseconds: 50 })
async move(from: string, to: string, amount: number): Promise<void> {
await this.wallets.add(from, -amount);
await this.wallets.add(to, amount);
}
}
@Module({
imports: [AspectModule.forRoot(), InfrastructureModule],
providers: [
Database,
TransactionAspect,
RetryAspect,
WalletRepository,
WalletService,
],
})
export class WalletModule {}RetryAspect has order 20 and TransactionAspect order 100. The lower order is the outer advice, so each attempt runs in a new transaction. Two concurrent transfers read the balance of alice: the first to commit wins, the second fails its commit, rolls back and runs again in a third transaction, which reads the new balance.
const app = await NestFactory.createApplicationContext(WalletModule);
const service = app.get(WalletService);
const wallets = app.get(WalletRepository);
await service.deposit("alice", 25);
await Promise.all([
service.move("alice", "bob", 10),
service.move("alice", "carol", 10),
]);
const balances = [
await wallets.balance("alice"),
await wallets.balance("bob"),
await wallets.balance("carol"),
];
// balances: [5, 10, 10]
// transactions of the two transfers: begin, begin, commit, rollback, begin, commitWithout the aspects, each method wraps its body in retry(() => this.database.transactional(() => ...)). For one or two methods that is the simpler form. Nothing checks that every method nests the two helpers in this order: with the retry inside, each attempt runs in the transaction that has already failed. Ordering pins the order of the two aspects in a test.
Jobs outside the request pipeline
A method that a timer, a scheduler, an event emitter or a queue worker calls is no handler: no guard, no interceptor and no exception filter surrounds it. The aspect gives every scheduled method what the request pipeline gives a handler and what a job needs besides: an identity, one running instance among the replicas of the application, and a report of its failure. It selects the methods by the decorator of the scheduler, so a job carries no second decorator.
export abstract class JobLocks {
abstract acquire(name: string): Promise<(() => Promise<void>) | undefined>;
}
export abstract class ErrorReporter {
abstract report(source: string, error: unknown): Promise<void>;
}JobLocks stands for a lock the replicas share, such as an advisory lock of the database: acquire resolves to the function that releases the lock, or to undefined when another replica holds it. ErrorReporter stands for the client of an error tracker. InfrastructureModule provides both.
import { Logger } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import {
Around,
Aspect,
Pointcut as P,
type ProceedingJoinPointOf,
} from "nestjs-aspect";
export const Scheduled = Reflector.createDecorator<{
name: string;
everyMilliseconds: number;
}>();
export const scheduled = P.annotated(Scheduled);
@Aspect()
export class JobAspect {
private readonly logger = new Logger(JobAspect.name);
constructor(
private readonly actor: Actor,
private readonly locks: JobLocks,
private readonly reporter: ErrorReporter,
) {}
@Around(scheduled)
async run(
joinPoint: ProceedingJoinPointOf<typeof scheduled>,
): Promise<unknown> {
const name =
joinPoint.findAnnotation(Scheduled)?.name ??
String(joinPoint.signature.name);
const release = await this.locks.acquire(name);
if (!release) {
return undefined;
}
try {
return await this.actor.run(`job:${name}`, async () =>
joinPoint.proceed(),
);
} catch (error) {
await this.reporter
.report(name, error)
.catch((failure) =>
this.logger.warn(`Cannot report the failure of ${name}: ${failure}`),
);
throw error;
} finally {
await release();
}
}
}Scheduledstands in for the decorator of a scheduling package, andSchedulerofSchedulerModulefor its scheduler, which calls every method that carries the decorator on a timer.P.annotatedaccepts a decorator made withReflector.createDecorator();P.metadata(key)selects the methods of a decorator that stores its metadata under another key.- A replica that does not get the lock skips the run.
- The job runs as the actor
job:<name>.PermissionAspectof Before checks the permissions of that actor on the service methods the job calls, andAuditAspectof the Quick start records it. - The advice reports the failure and rethrows it, so the scheduler, or the queue worker in its place, still sees the rejection. A failure of the reporter is logged and does not replace the error of the job.
The job itself holds its work and nothing else. It stores the prices of a supplier through CatalogService.reprice of the example application, which requires the permission catalog:write:
import { Injectable, Module } from "@nestjs/common";
import { AspectModule } from "nestjs-aspect";
@Injectable()
export class PriceSyncJob {
constructor(
private readonly transport: Transport,
private readonly catalog: CatalogService,
) {}
@Scheduled({ name: "price-sync", everyMilliseconds: 60_000 })
async sync(): Promise<void> {
const prices = await this.transport.send<Record<string, number>>(
"supplier/prices",
{},
);
for (const [sku, price] of Object.entries(prices)) {
await this.catalog.reprice(sku, price);
}
}
}
@Module({
imports: [
AspectModule.forRoot(),
ShopModule,
SchedulerModule,
InfrastructureModule,
],
providers: [
Actor,
Permissions,
PermissionAspect,
RetryAspect,
CacheEvictionAspect,
JobAspect,
PriceSyncJob,
],
})
export class JobsModule {}scheduler.fire(name) runs a job as its timer does. The supplier answers the first request and not the second:
const app = await NestFactory.createApplicationContext(JobsModule);
const scheduler = app.get(Scheduler);
app.get(Permissions).grant("job:price-sync", "catalog:write");
await scheduler.fire("price-sync");
const price = await app.get(CatalogService).price("widget");
const release = await app.get(JobLocks).acquire("price-sync");
await scheduler.fire("price-sync");
await release?.();
const failure = await scheduler.fire("price-sync").catch((error) => error);
// price: 12, stored by the actor "job:price-sync"
// the second run finds the lock held and sends no request
// failure: the TransportError of the third run, which the reporter has receivedA plain method decorator cannot do this: it runs outside the container and reaches the lock, the reporter and the actor only through global state. The explicit form is a runJob(name, () => ...) call of an injected service in every job, which the job added next can omit.
A message handler of a Nest microservice (@MessagePattern, @EventPattern) is a handler: it has the enhancer chain, and an exception filter is the tool there.
Timing every repository of a module
An interceptor times a handler. No enhancer reaches the repositories behind it, a decorator on every repository method leaves out the method added next, and a Proxy around each repository replaces its class provider with a factory and misses the calls a repository makes on itself. The pointcut selects the asynchronous methods of every provider of PersistenceModule whose token ends in Repository; the repositories carry no decorator. asynchronous of Pointcuts keeps the advice, which awaits proceed(), away from synchronous methods. The recipe measures the repositories an application writes; the queries of a database driver are measured by the instrumentation of that driver.
import { Injectable, Module } from "@nestjs/common";
import {
Around,
Aspect,
AspectModule,
Pointcut as P,
type ProceedingJoinPointOf,
} from "nestjs-aspect";
@Injectable()
export class UserRepository {
private readonly names = new Map<number, string>();
async findName(id: number): Promise<string | undefined> {
return this.names.get(id);
}
async rename(id: number, name: string): Promise<void> {
if (name === "") {
throw new RangeError("A name is required");
}
this.names.set(id, name);
}
size(): number {
return this.names.size;
}
}
@Injectable()
export class OrderRepository {
async findByUser(userId: number): Promise<string[]> {
return userId === 1 ? ["order-1"] : [];
}
}
@Injectable()
export class IdGenerator {
private last = 0;
async next(): Promise<number> {
this.last += 1;
return this.last;
}
}
@Module({
providers: [UserRepository, OrderRepository, IdGenerator],
exports: [UserRepository, OrderRepository, IdGenerator],
})
export class PersistenceModule {}
export const repositoryCalls = P.all(
P.inModule(PersistenceModule),
P.bean("*Repository"),
asynchronous,
);
export interface CallTotals {
calls: number;
errors: number;
milliseconds: number;
}
@Injectable()
export class CallStatistics {
private readonly totals = new Map<string, CallTotals>();
record(member: string, failed: boolean, milliseconds: number): void {
const totals = this.totals.get(member) ?? {
calls: 0,
errors: 0,
milliseconds: 0,
};
totals.calls += 1;
totals.errors += failed ? 1 : 0;
totals.milliseconds += milliseconds;
this.totals.set(member, totals);
}
summary(): Record<string, CallTotals> {
return Object.fromEntries(this.totals);
}
}
@Aspect()
export class RepositoryTimingAspect {
constructor(private readonly statistics: CallStatistics) {}
@Around(repositoryCalls)
async time(
joinPoint: ProceedingJoinPointOf<typeof repositoryCalls>,
): Promise<unknown> {
const { targetType, name } = joinPoint.signature;
const member = `${targetType.name}.${String(name)}`;
const started = performance.now();
let failed = true;
try {
const result = await joinPoint.proceed();
failed = false;
return result;
} finally {
this.statistics.record(member, failed, performance.now() - started);
}
}
}
@Module({
imports: [AspectModule.forRoot(), PersistenceModule],
providers: [CallStatistics, RepositoryTimingAspect],
})
export class ReportingModule {}CallStatistics keeps one total per repository method, so it does not grow with the number of calls; an application records into the histogram of its metrics client instead. UserRepository.size is synchronous and IdGenerator is no repository, so neither is recorded:
const app = await NestFactory.createApplicationContext(ReportingModule);
const users = app.get(UserRepository);
await users.rename(1, "Ada");
await users.rename(1, "").catch(() => undefined);
await users.findName(1);
users.size();
await app.get(OrderRepository).findByUser(1);
await app.get(IdGenerator).next();
const summary = app.get(CallStatistics).summary();
// {
// "UserRepository.rename": { calls: 2, errors: 1, milliseconds: ... },
// "UserRepository.findName": { calls: 1, errors: 0, milliseconds: ... },
// "OrderRepository.findByUser": { calls: 1, errors: 0, milliseconds: ... },
// }AspectModule
Import AspectModule.forRoot() once, as the first import of the root module. The module is global. Register aspects and advisors as providers of any module.
Weaving replaces each matched method on its class prototype with a dispatcher that runs the advice of the application the called object belongs to. It happens while Nest instantiates the providers, so advice is in place before the router binds the route handlers and before the first lifecycle hook runs.
| Option | Default | Purpose |
| --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| returnValueAdapters | [] | ReturnValueAdapters that let advice follow asynchronous results other than promises, consulted in order before the built-in promiseAdapter. |
| adviceAdapters | [] | AdviceAdapters that convert the advice of an @Advisor into a MethodInterceptor, which adds advice kinds. Consulted in order before the built-in adapters. |
| report | "debug" | Logger level ("verbose", "debug" or "log") of the weaving report; false logs none. |
| strict | true | With true, the bootstrap validation fails init(). With false, its findings are warnings and the calls they concern fail instead. |
| exclude | [] | Classes that are never woven: a class excludes itself and its subclasses, a ClassFilter excludes the classes it matches. |
AspectModule.forRootAsync() takes the options from a factory, a class or an existing provider. This module registers the providers of the Quick start:
import { Injectable, Module } from "@nestjs/common";
import { AspectModule, type AspectReportLevel } from "nestjs-aspect";
@Injectable()
export class Settings {
readonly aspectReport: AspectReportLevel | false = "log";
}
@Module({
providers: [Settings],
exports: [Settings],
})
export class SettingsModule {}
@Module({
imports: [
AspectModule.forRootAsync({
imports: [SettingsModule],
inject: [Settings],
useFactory: (settings: Settings) => ({ report: settings.aspectReport }),
}),
],
providers: [Actor, AuditLog, AuditAspect, MemberService, ApiKeyService],
})
export class ConfiguredAppModule {}The options come from singleton providers. The weaver waits for them inside the instantiation phase; a provider factory that runs before they are resolved calls its dependencies without advice.
Ordering
- Across aspects and advisors, a lower
orderhas the higher precedence: its advice runs first on the way in and last on the way out. One without an order has the lowest precedence. Aspects with equal orders, or without one, nest in registration order: the one registered first is the outer one. The weaver reports this as a warning when they advise the same member. - Within one aspect, the advice kinds nest in this order from the outside in: around, before, after, after returning, after throwing. After advice therefore runs after the after-returning and after-throwing advice of its aspect. Advice of the same kind nests in declaration order: the first declared is the outermost, so before advice runs in declaration order, and after, after-returning and after-throwing advice in reverse declaration order.
The aspects of this README, from the outside in:
| Order | Aspect | Reason for its place |
| ----- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| 0 | PermissionAspect | A call without the permission runs no other advice. |
| 10 | DrainAspect | A call is in flight once, through its retries. |
| 20 | RetryAspect | Each attempt passes the circuit breaker, the error translation and the transaction again. |
| 25 | [CircuitBreakerAspect](#instantiation-models
