@ddd-ts-kit/aspects

中文文档
0.106.0发布于 2026/11/01MITTypeScriptES Module构建来源已验证

Cross-cutting behaviour applied from outside: logging, metrics, retry, timeout, circuit breaker and caching as composable wrappers at the composition root, as bus middlewares, or as standard decorators on adapters. The domain stays clean.

原始使用文档
文档目录16 节
  1. Where it sits in an application
  2. Three forms, one implementation
  3. 1. At the composition root, on one binding (preferred)
  4. 2. At the composition root, on every binding
  5. 3. As a decorator, on an adapter
  6. Which form
  7. Aspects
  8. Selecting what is wrapped
  9. Order
  10. The aspect runtime
  11. Use cases: bus middlewares
  12. StdoutLogger
  13. What wrapping guarantees
  14. Testing
  15. Decorator modes
  16. Limits

English · Français

@ddd-ts-kit/aspects

Logging, metrics, retries, timeouts, circuit breakers and caching are not business logic, so they are never written inside it. Each one is an aspect: one implementation, put around an adapter from outside, the way @ddd-ts-kit/tracing puts spans around it. The wrapped class imports nothing, and domain/ and application/ never see this package.

npm i @ddd-ts-kit/aspects

Where it sits in an application

A call leaves a use case through a port. What answers it is the adapter bound to that port, wrapped by the aspects the composition root put around it. The use case itself is wrapped too, but by the buses: their middlewares log and measure every command and query.

A route calls the command bus, whose logging and metrics middlewares run around the handler; the handler calls the PaymentGateway port; the call goes through logged, timed, retry and timeout before it reaches the StripeGateway adapter, which calls the Stripe API; the composition root put the aspects and the middlewares in place, and neither domain nor application imports the package

Instead of writing the policy into each adapter, write the adapter alone and put the policy around it:

import { compose, retry, timeout } from '@ddd-ts-kit/aspects';

interface Quote {
  readonly pair: string;
  readonly rate: number;
}

declare function fetchQuote(pair: string): Promise<Quote>;

export abstract class ExchangeRates {
  abstract quoteOf(pair: string): Promise<Quote>;
}

// Instead of: the retry loop, its waits and its limit written inside the adapter,
// once more in every adapter, and invisible from the composition root.
export class TangledRates implements ExchangeRates {
  async quoteOf(pair: string): Promise<Quote> {
    for (let attempt = 1; ; attempt += 1) {
      try {
        return await fetchQuote(pair);
      } catch (error) {
        if (attempt === 3) {
          throw error;
        }

        await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
      }
    }
  }
}

// Write: the adapter does its one job...
export class HttpRates implements ExchangeRates {
  async quoteOf(pair: string): Promise<Quote> {
    return fetchQuote(pair);
  }
}

// ...and the composition root puts the policy around it: the same object, still an HttpRates.
export const rates: ExchangeRates = compose(retry({ attempts: 3 }), timeout(2000))(new HttpRates(), { kind: 'external' });

Three forms, one implementation

The same policy, written three ways. All three run the same code.

1. At the composition root, on one binding (preferred)

import { retry, timeout } from '@ddd-ts-kit/aspects';
import { defineContextModule } from '@ddd-ts-kit/di';

interface Payment {
  readonly orderId: string;
  readonly amountCents: number;
}

interface Receipt {
  readonly chargeId: string;
}

declare function post(url: string, body: Payment, options: { apiKey: string; signal?: AbortSignal | undefined }): Promise<Receipt>;
declare const config: { readonly stripe: { readonly url: string; readonly key: string } };

// contexts/billing/application/ports/payment-gateway.port.ts
export abstract class PaymentGateway {
  abstract charge(payment: Payment, signal?: AbortSignal): Promise<Receipt>;
}

// contexts/billing/application/ports/payment-settings.port.ts
export abstract class PaymentSettings {
  abstract readonly baseUrl: string;

  abstract readonly apiKey: string;
}

// contexts/billing/infrastructure/external/stripe.gateway.ts
export class StripeGateway implements PaymentGateway {
  constructor(private readonly settings: PaymentSettings) {}

  async charge(payment: Payment, signal?: AbortSignal): Promise<Receipt> {
    return post(`${this.settings.baseUrl}/charges`, payment, { apiKey: this.settings.apiKey, signal });
  }
}

// contexts/billing/billing.module.ts
export const billingModule = defineContextModule({
  name: 'billing',
  register(builder) {
    builder.bind(PaymentSettings).toValue({ baseUrl: config.stripe.url, apiKey: config.stripe.key });
    builder
      .bind(PaymentGateway)
      .toClass(StripeGateway, [PaymentSettings])
      .as('external')
      .with(retry({ attempts: 3 }), timeout(2000, { signal: true }));
  },
});

The port is an abstract class and the class is what is bound. StripeGateway has no import from this package and no knowledge of the policy. In the declared style, the default of a generated project, there is no module file and the same policy is stated in the declaration of the adapter: @Adapter(PaymentGateway, { kind: 'external', aspects: [retry({ attempts: 3 }), timeout(2000)] }). Without a container an aspect is a function: retry({ attempts: 3 })(new StripeGateway(settings), { kind: 'external' }).

2. At the composition root, on every binding

// main.ts
import { Cache, Clock, Logger, Metrics } from '@ddd-ts-kit/kernel';
import { kernelModule, type ContextModule } from '@ddd-ts-kit/di';
import { createApplication } from '@ddd-ts-kit/di/tsyringe';
import { StdoutLogger, aspects, forKinds, installAspectRuntime, logged, retry, timed, timeout, when, type Aspect } from '@ddd-ts-kit/aspects';

declare const tracing: Aspect; // below
declare const platformModule: ContextModule;
declare const billingModule: ContextModule;

const app = createApplication({
  modules: [kernelModule({ logger: new StdoutLogger() }), platformModule, billingModule],
  decorate: aspects(
    tracing,
    when(forKinds('repository', 'gateway', 'external'), logged(), timed()),
    when(forKinds('external'), retry({ attempts: 3 }), timeout(2000)),
  ),
});

installAspectRuntime({
  logger: app.resolve(Logger),
  metrics: app.resolve(Metrics),
  cache: app.resolve(Cache),
  clock: app.resolve(Clock),
});

tracing is the hook of the tracing package written as an aspect:

import type { Aspect } from '@ddd-ts-kit/aspects';
import { traceDependency } from '@ddd-ts-kit/tracing';

export const tracing: Aspect = (instance, info) =>
  info?.kind === undefined ? instance : traceDependency(instance, { kind: info.kind, ...(info.context === undefined ? {} : { context: info.context }) });

An application on kernelModule({ instrumentation: traceApplication() }), which is what the CLI generates, does not write this tracing aspect: the kernel module traces the bindings itself. Its span is inside whatever the decorate hook adds and outside the aspects of a binding (see "Order"). A generated project does not use the decorate hook either: its platform module puts the default policy around the adapters that leave the process with builder.around(['external', 'gateway'], () => when(forKinds('external', 'gateway'), retry(...), timeout(...))), and an adapter that states a policy of its own wraps it in ownPolicy(...) of platform/resilience/, which the default policy then leaves alone.

3. As a decorator, on an adapter

// contexts/billing/infrastructure/external/stripe.gateway.ts
import { Retry, Timeout } from '@ddd-ts-kit/aspects/decorators';

interface Payment {
  readonly orderId: string;
  readonly amountCents: number;
}

interface Receipt {
  readonly chargeId: string;
}

declare function post(url: string, body: Payment, options: { apiKey: string; signal?: AbortSignal | undefined }): Promise<Receipt>;

abstract class PaymentGateway {
  abstract charge(payment: Payment, signal?: AbortSignal): Promise<Receipt>;
}

abstract class PaymentSettings {
  abstract readonly baseUrl: string;

  abstract readonly apiKey: string;
}

export class StripeGateway implements PaymentGateway {
  constructor(private readonly settings: PaymentSettings) {}

  @Retry({ attempts: 3 })
  @Timeout(2000, { signal: true })
  async charge(payment: Payment, signal?: AbortSignal): Promise<Receipt> {
    return post(`${this.settings.baseUrl}/charges`, payment, { apiKey: this.settings.apiKey, signal });
  }
}

@Retry, @Timeout, @Cached, @Logged, @Timed, @CircuitBreaker, @Bulkhead, and decoratorOf(adviceFactory) for an aspect of the project. Several on one method nest as they read: the first written is outermost. The class takes no logger, metrics or cache in its constructor: the decorators read them from the aspect runtime when a call is made.

Which form

  • Prefer the composition root (forms 1 and 2). The adapter stays free of any import, the policy of a whole application is read in one place, and it changes without touching a class.
  • A decorator is acceptable on an adapter in infrastructure/ or platform/ whose policy is part of what the adapter is: a client of a provider that documents its own retry and timeout rules. Do not apply the same aspect in both forms: it would run twice.
  • Never in domain/ or application/, in any form: layer/domain-purity and layer/application-inward forbid the import. A use case gets its cross-cutting behaviour from a bus middleware, below.

Aspects

AspectWhat it doesDefault policyNever records
logged({ level?, fields?, logger?, clock? })entries <Class>.<method> started (debug), then succeeded (level, default info) or faileda DomainError fails at warn, a fault at error; fields class, method, kind, context, durationMs, errorType, errorCodearguments, results, error messages
timed({ name?, metrics?, clock? })histogram <name>_duration_ms, counter <name>_totalname is dependency_call; labels class, method, kind, context, outcome, error_codearguments, results, error messages, identifiers
retry({ attempts, backoff?, retryOn?, sleep? })calls again after a failurenever after a DomainError, an open circuit or a full bulkhead (isTransient); 100 ms doubling up to 30 s with full jitternothing: it records nothing
timeout(ms, { signal?, scheduler? })rejects with TimeoutError (TIMEOUT)the underlying call keeps running unless signal: truethe message names the class and method only
circuitBreaker({ failureThreshold, resetAfterMs, halfOpenMax?, scope?, isFailure?, onStateChange?, clock? })refuses calls with CircuitOpenError (CIRCUIT_OPEN) after consecutive failures, then lets trial calls throughone circuit per instance and method; a DomainError is not a failure; one trial callonStateChange gets class, method, context and the two states
cached({ key, ttlMs, cache?, allowKinds? })answers a repeated read from the Cache portrefuses kinds repository and use-case; a call whose key is undefined is not cached; failures are not cached; concurrent identical calls share one call; a failing cache is a missthe key is built by the project from the arguments and goes to the cache only
bulkhead({ maxConcurrent, maxQueued?, scope? })bounds the calls running at once, refuses beyond with BulkheadFullError (BULKHEAD_FULL)no queue; one compartment per instance and methodnothing

Privacy. The rule is the one of tracing: an entry, a label or an error raised by this package carries names from the code (class, method, context, kind), a duration, the class of an error and its short code. Never an argument, a result or the message of a caught error: they quote data. Errors are rethrown untouched.

timeout and cancellation. With signal: true the method is handed an AbortSignal as its last declared parameter, aborted when the time is up. Declare signal?: AbortSignal last, without a default value, and pass it to the underlying call. A signal the caller passed there is kept: either one aborts. A method without parameter receives none.

retry and jitter. backoff: { baseMs, factor, maxMs, jitter }. jitter is a function (delayMs, attempt) => delayMs: fullJitter() by default, fullJitter(seededRandom) or noJitter for a deterministic schedule. sleep replaces the wait altogether. The wait after the failed attempt n is baseMs x factor^(n - 1), capped at maxMs, then jittered: delayBeforeRetry(n, backoff) computes it.

circuitBreaker and metrics. onStateChange: circuitStateGauge() publishes the state as the gauge circuit_breaker_state: 0 closed, 1 half-open, 2 open.

The three states of a circuit: closed lets every call through and counts consecutive failures; after failureThreshold of them it opens and refuses every call with CircuitOpenError; after resetAfterMs it is half-open and lets halfOpenMax trial calls through; a trial that answers closes it, a trial that fails opens it again

A business refusal (a DomainError) proves that the dependency answers: it resets the count like a success. A policy for a provider that may go away for a while, in the order the next sections explain:

import { circuitBreaker, circuitStateGauge, compose, retry, timeout } from '@ddd-ts-kit/aspects';

export const paymentProviderPolicy = compose(
  // Outermost: an open circuit ends the retries at once (retry never retries CIRCUIT_OPEN).
  retry({ attempts: 3, backoff: { baseMs: 200 } }),
  // Five consecutive failures open it for 30 s; the gauge circuit_breaker_state follows it.
  circuitBreaker({ failureThreshold: 5, resetAfterMs: 30_000, onStateChange: circuitStateGauge() }),
  // Innermost: each attempt has its own 2 s.
  timeout(2000),
);

cached is for reads of gateways and external adapters. An aggregate served from a cache behind a repository hides that it is stale, and the next command would decide on old state. Wrapping a repository or a use case therefore takes an explicit allowKinds: ['repository'], which is a decision to record.

A project writes its own aspect with defineAspect((joinPoint) => async ({ args, proceed }) => ...). The outer function runs once per method of each wrapped instance and may answer undefined to leave that method alone; the inner one runs at each call, and proceed() calls what is inside:

import { defineAspect, type Aspect } from '@ddd-ts-kit/aspects';
import type { Clock, Logger } from '@ddd-ts-kit/kernel';

/** Warns when a call lasts longer than the threshold: names from the code and a duration, never the arguments. */
export function warnWhenSlow(thresholdMs: number, logger: Logger, clock: Clock): Aspect {
  return defineAspect((joinPoint) => async ({ proceed }) => {
    const startedAt = clock.now().getTime();

    try {
      return await proceed();
    } finally {
      const durationMs = clock.now().getTime() - startedAt;

      if (durationMs > thresholdMs) {
        logger.warn(`${joinPoint.className}.${joinPoint.method} slow`, { durationMs, thresholdMs });
      }
    }
  });
}

Selecting what is wrapped

when(selector, ...aspects) applies aspects to the methods a selector picks:

SelectorPicks
forKinds('repository', 'external')instances bound with .as(kind)
forContext('billing')instances of a module
forClasses('Http*')instances by class name
forMethods('find*', 'get*')methods by name
allOf(...), anyOf(...), not(...)combinations
import { allOf, cached, forKinds, forMethods, when } from '@ddd-ts-kit/aspects';

// The one place arguments are read: build the key from an identifier, never from personal data.
// `undefined` leaves the call uncached. The cache key also holds the class and the method.
const key = (_method: string, [id]: readonly unknown[]): string | undefined => (typeof id === 'string' ? id : undefined);

export const cacheReads = when(allOf(forKinds('gateway', 'external'), forMethods('find*')), cached({ key, ttlMs: 60_000 }));

Order

compose(a, b), .with(a, b), aspects(a, b) and stacked decorators all read the same way: the first is outermost. Around one instance the application nests, outermost first: the decorate hook, the aspects modules put around a kind with builder.around(kinds, ...) in the order of the modules (the span of kernelModule({ instrumentation }) comes first, then for a repository the hand-over of its domain events, then the ones of the platform module), and last the aspects of the binding itself (.with(...), or aspects of a declaration). Decorators on the methods of the class are inside all of them.

Six nested layers around the method of one instance (an adapter or a repository), outermost first: the decorate hook of createApplication; the span of kernelModule instrumentation; for a repository, the hand-over of its domain events; the aspects of the platform module put with builder.around; the aspects of the binding, .with or the aspects of a declaration; the method decorators of the class; then the method itself

Order changes meaning:

  • retry then timeout: each attempt has its own time limit. The other way round, one limit covers all the attempts and the waits between them.
  • timed and logged outside retry: one measurement per call as the caller lived it, retries included. Inside, one per attempt.
  • cached outside retry and circuitBreaker: a hit costs nothing and is not refused by an open circuit.
  • circuitBreaker inside retry: each failed attempt counts, and an open circuit ends the retries at once, since retry does not retry a CircuitOpenError.

Two timelines. With retry outside timeout, each of the three attempts has its own 2 s limit, and the call can last three times 2 s plus the waits. With timeout outside retry, one 2 s limit covers the attempts and the waits together, and the call rejects with TimeoutError at 2 s whatever the attempt in progress

import { compose, retry, timeout } from '@ddd-ts-kit/aspects';

// Each attempt has its own 2 s: the call may last 3 x 2 s, plus the waits between attempts.
export const limitPerAttempt = compose(retry({ attempts: 3 }), timeout(2000));

// One 2 s limit for the whole call: attempts and waits together.
export const limitForTheCall = compose(timeout(2000), retry({ attempts: 3 }));

The order that fits most adapters, outermost first:

tracing, logged, timed, cached, retry, circuitBreaker, bulkhead, timeout, the adapter

With the instrumentation of the kernel module, the tracing span is where the kernel module puts it: logged and timed given to the decorate hook are outside the span, the ones given to builder.around or to a binding are inside it.

A repository has one more wrapper, put by kernelModule() between the decorate hook and the aspects of its binding: the hand-over of the domain events of what it saves, which the unit of work dispatches at its end, before the commit. So the aspects of a binding (.with(retry(...))) are inside it, where a retried save announces its events once. Inside a unit of work a handler of those events no longer runs during the save, so no retry of a save ever covers it. Outside of any, the save dispatches before it resolves: a retry given to the decorate hook would then run the save again after a handler failed, with no event left to dispatch. Put the retry of a repository on its binding.

The aspect runtime

logged, timed and cached need a logger, metrics and a cache. They take them from their options, or from the runtime that the composition root installs once with installAspectRuntime({ logger, metrics, cache, clock, scheduler }). The runtime is read when a call is made, never when a class is decorated or an instance wrapped, so the order of the statements in main.ts does not matter. A missing collaborator raises AspectRuntimeError, which names it. The clock and the scheduler default to the system ones. Scheduler is the port of this package for "run this later" (schedule(work, delayMs)), an abstract class like the ports of the kernel: systemScheduler is its default, ManualScheduler of ./testing the one of a spec.

Use cases: bus middlewares

Handlers are not wrapped one by one; the buses are:

import { loggingMiddleware, metricsMiddleware } from '@ddd-ts-kit/aspects';
import { InMemoryCommandBus, InMemoryQueryBus, type Logger, type Metrics } from '@ddd-ts-kit/kernel';

declare const logger: Logger;
declare const metrics: Metrics;

export const commands = new InMemoryCommandBus().use(loggingMiddleware(logger)).use(metricsMiddleware(metrics));
export const queries = new InMemoryQueryBus().use(loggingMiddleware(logger)).use(metricsMiddleware(metrics));

Same entries and series as logged and timed (prefix use_case), named after the message class, never its content: command ChargeOrder started, then succeeded or failed, with the fields message and messageKind, and the series use_case_duration_ms and use_case_total labelled message and kind. There is no middleware opening an ExecutionScope: a command does not carry a correlation id, and the scope must already be open when the route or the consumer runs, before any bus. Open it at the boundary.

StdoutLogger

A small adapter of the kernel Logger port writing JSON lines:

import { StdoutLogger } from '@ddd-ts-kit/aspects';
import { logFieldsOf, type ExecutionScope } from '@ddd-ts-kit/kernel';
import { traceLogFields } from '@ddd-ts-kit/tracing';

declare const scope: ExecutionScope;

// Without tracing: the correlation id, actor and tenant of the current execution context.
export const logger = new StdoutLogger({
  level: 'info',
  base: { service: 'shop-api' },
  fields: () => {
    const context = scope.current();

    return context === undefined ? {} : logFieldsOf(context);
  },
});

// With @ddd-ts-kit/tracing: the same fields, plus trace_id and span_id of the active span.
export const tracedLogger = new StdoutLogger({ base: { service: 'shop-api' }, fields: () => traceLogFields(scope) });

logger.info('payment accepted', { orderId: 'o-42' });
// {"time":"2026-03-01T10:00:00.000Z","level":"info","message":"payment accepted","service":"shop-api","correlationId":"...","origin":"http","actorId":"...","orderId":"o-42"}

fields is read at each entry. This package depends neither on the tracing package nor on an execution scope: the project chooses what each entry carries. When the same field comes from several places, the later wins: base, then the fields of child(...), then fields(), then the fields of the log call. None can overwrite time, level or message.

It implements the whole port, held by loggerContract like the pino and winston adapters:

  • Errors. An Error passed as a field (logger.error('payment failed', { error })) is written as describeError of the kernel describes it: type, code for a DomainError, message, stack, then cause the same way. scrubMessages: true writes [message removed] for the message of every error that is not a DomainError: turn it on when the logs leave your hands. logged and loggingMiddleware are not concerned: they never pass an error, only errorType and errorCode.
  • isLevelEnabled(level) answers the threshold (level, default info). logged and loggingMiddleware ask it first: below the threshold no field is built and the fields function of logged is not called.
  • flush() resolves once the standard output took every line written so far, and never rejects; the logger stays usable afterwards. app.stop() of the di package calls it last.
  • stream sends the lines to another stream than the standard output, and flush() waits for that stream. A write of the project receives each line instead and wins over stream.
  • A log call never throws. Fields that cannot be read or serialized (a getter that throws, a circular object), on an entry or on a child, give the entry without them, marked logError: "unserializable fields".

It hides credentials as @ddd-ts-kit/pino and @ddd-ts-kit/winston do: the value of every key of CREDENTIAL_KEYS of the kernel, whatever its case (isCredentialKey), is written [redacted] at the top of an entry and one level below (headers.authorization), in the fields of a line, of a child, of base and of fields. Nothing deeper, nor inside a list. The objects passed are never written to.

What wrapping guarantees

  • The instance is wrapped in place: same object, instanceof, this and private members intact. A frozen instance is fronted by a proxy of the same type, and its methods run with that proxy as this.
  • An aspect applied to an instance advises the calls that reach it from outside. private is erased by the compiler, and a name does not say whether a method is public (an underscore is a habit, not a rule), so the public API cannot be picked out reliably: every asynchronous method reachable by name, a private async method and a field holding an asynchronous function included, is wrapped. What tells the calls apart is where they come from. While an advised call is running on an instance, a call that instance makes on itself, to a public or a private method, is the inside of that call and is not advised again by the same aspect: retry({ attempts: 3 }) around a public method that goes through a private step makes three attempts, not nine, and logged() and timed() record one call. The guard is kept per instance and per aspect, and follows the asynchronous flow of the call (AsyncLocalStorage): two calls made from outside at the same time are both advised, and so is whatever an advised call left behind (a timer, a listener) once that call is over.
  • What follows from it: a call that comes back to the instance from a collaborator while the advised call is running is the inside of that call too; cached serves the callers from outside, not a method reading another through this; bulkhead and circuitBreaker count one call. A method decorator is not concerned: it advises the one method it is put on, at every call, the ones made through this included.
  • Tracing differs on purpose: traceDependency of @ddd-ts-kit/tracing opens a child span for a call an instance makes on itself. A span describes a step, where an advice acts on it.
  • A frozen instance stays frozen: its fields, private ones included, cannot be assigned. State it must change lives in an object it holds (a Map, an array).
  • Applying the same aspect twice to an instance is a no-op.
  • Getters, symbols, generators, synchronous methods and lifecycle hooks (LIFECYCLE_HOOKS) are left alone.
  • A method declared async is advised from its first call. A method that merely returns a promise is advised from its second call: declare adapter methods async. A decorated method is always advised.

Testing

Wrappers take their collaborators as options, so a spec passes fakes of the kernel. For decorated classes and for aspects that read the runtime, @ddd-ts-kit/aspects/testing gives createAspectFakes() (an in-memory logger, metrics and cache, a fixed clock and a ManualScheduler on the same clock) and withAspectRuntime(runtime, work):

import { expect, it } from 'vitest';
import { Logged, Timeout } from '@ddd-ts-kit/aspects/decorators';
import { createAspectFakes, withAspectRuntime } from '@ddd-ts-kit/aspects/testing';

const fetchRate = (_pair: string): Promise<number> => new Promise(() => undefined); // a provider that never answers

class SlowRates {
  @Logged()
  @Timeout(2000)
  async rateOf(pair: string): Promise<number> {
    return fetchRate(pair);
  }
}

it('gives up after two seconds, and logs the failure without the pair', async () => {
  const fakes = createAspectFakes(); // logger, metrics, cache, clock, scheduler
  const rates = new SlowRates();

  const pending = withAspectRuntime(fakes, () => rates.rateOf('EUR-USD'));
  fakes.scheduler.advance(2000); // time moves when the test says so

  await expect(pending).rejects.toMatchObject({ code: 'TIMEOUT' });
  expect(fakes.logger.entries.map((entry) => entry.message)).toEqual(['SlowRates.rateOf started', 'SlowRates.rateOf failed']);
});

withAspectRuntime holds for the work and everything it awaits, and concurrent tests do not see each other's runtime. With createTestApplication of the di package the application is the runtime: withAspectRuntime(app, () => ...), then assert app.logger.entries and app.metrics.

Decorator modes

The decorators of ./decorators work under both decorator modes of TypeScript, with the same source line.

Stacktsconfig.json
class-validator, NestJS, TypeORM entities, any library with legacy decorators"experimentalDecorators": true
anything elseleave experimentalDecorators unset: standard (TC39) decorators need no flag
  • How the mode is detected. By the call: the standard one is (method, context) with a context object, the legacy one (prototype, name, descriptor). decoratorOf reads which it received, so one function serves both and an aspect has one implementation.
  • What differs. In the standard mode the method of each instance is wrapped when the instance is constructed. The legacy mode has no initializer, so the method is replaced on the prototype and the advice of each instance is built at its first call. State (a circuit, a compartment, a single flight) is per instance and per method in both, several decorators nest as they read in both, and neither needs emitDecoratorMetadata.
  • Compilers. TypeScript 5, 6 and the native TypeScript 7 compile both modes. Node does not run decorator syntax, so the code is compiled either way.
  • Vitest. It lowers legacy decorators by itself: a project with experimentalDecorators needs nothing. It does not lower standard ones (a spec importing a .ts file that uses @Retry fails with a syntax error): add decorators() of @ddd-ts-kit/testing/vitest to the Vitest config. Files wrapped at the composition root need nothing in either mode.
  • With a declaration. @Adapter(...) on the class and @Retry(...) on its methods compose, with the aspects of the declaration around the decorators of the methods: @Adapter(PaymentGateway, { kind: 'external' }). A static method, a method named by a symbol and a hard-private (#) method are refused.

The ./decorators entry is separate: nothing else in the package imports it.

Limits

  • State (circuits, compartments, single-flight) lives in the process, per wrapped instance. A transient binding gets a new state with each instance.
  • retry repeats the call as it is: only for operations that are safe to repeat.
  • timeout without signal does not stop the underlying call.
  • A wrapped call always answers a promise: do not wrap an object whose methods return a chainable query builder.
  • when selects methods of aspects made by defineAspect, compose or when; a plain function used as an aspect filters by itself.

版本与发布记录

@ddd-ts-kit/aspects 共发布 63 个版本,首次发布于 2026/10/11;过去 12 个月发布了 63 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.106.0latest2026/11/012 天前
0.105.02026/10/312 天前
0.104.02026/10/312 天前
0.103.02026/10/313 天前
0.102.02026/10/313 天前
0.101.02026/10/303 天前

关于 @ddd-ts-kit/aspects 的常见问题

@ddd-ts-kit/aspects 是什么?

从外部应用的横切行为:日志记录、度量、重试、超时、断路器和缓存,作为组合根的可组合包装器,作为总线中间件,或作为适配器上的标准装饰器。域保持干净。

如何安装 @ddd-ts-kit/aspects?

在项目目录运行 npm install @ddd-ts-kit/aspects,或使用 pnpm add / yarn add / bun add。

@ddd-ts-kit/aspects 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ddd-ts-kit/aspects 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@ddd-ts-kit/aspects 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.12.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ddd-ts-kit/aspects 可以商用吗?

@ddd-ts-kit/aspects 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ddd-ts-kit/aspects 还在维护吗?

最新版本 0.106.0 发布于 2026/11/01(2 天前);至今共发布 63 个版本,其中过去 12 个月发布了 63 个;首次发布于 2026/10/11。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。