Skip to content

Lifecycle Hooks

Every stateful Substrate primitive exposes protected lifecycle hooks — one at every stage a consumer might want to observe. If you are debugging and want to inject a trace or log line "right here," there is a hook for "right here."

This is how Substrate keeps observability out of the base classes while still making every internal stage observable. The base class never logs, never references a logger, and never emits metrics. The hooks are the seam.

The idiom

A hook is a protected method with a no-op default. The base class calls it synchronously at the exact stage; a subclass overrides it to observe.

  • No-op by default. A bare (un-subclassed) instance behaves exactly as if the hooks did not exist. Adding hooks is non-breaking.
  • Synchronous and raw. Hooks are called inline at the stage, after the relevant state change, so an override sees committed state.
  • Three hook kinds. Most hooks are void observers — they exist to log, count, and trace. Some hooks are transform hooks: they receive a context object and return a (possibly modified) value to alter behavior. Others are behavioral policy hooks: they classify, schedule, or otherwise participate directly in control flow. @studnicky/fetch's onRequest(context) / onResponse(context) are transform hooks; @studnicky/retry's classifyError(...) is a behavioral hook.
  • Observer hooks are observational. They are not the primary result of the operation. Many classes contain observer-hook failures so committed state, canonical results, or canonical domain errors still win. Behavioral hooks stay in-band and may intentionally redirect or fail the operation.
  • Subclass to observe or override. Override only the hooks you care about. Everything you don't override stays a no-op.

Using a hook

Subclass the primitive and override the hooks you want to trace. This example overrides @studnicky/retry's hooks to print a step-by-step debug trace and to collect telemetry — the base Retry class stays free of any logger:

ts
import type { ErrorClassificationEntity, RetryConfigInterface , RetryContextType} from '../src/index.js';

import { MaxRetriesExceededError, Retry } from '../src/index.js';

class TelemetryRetry extends Retry {
  constructor(config?: Partial<RetryConfigInterface>) {
    super(config ?? {});
  }

  readonly scheduledEvents: { 'attemptNumber': number; 'delayMs': number }[] = [];
  readonly giveUpEvents: { 'attemptNumber': number; 'reason': string }[] = [];

  protected override classifyError(_error: Error): ErrorClassificationEntity.Type {
    return { 'reason': 'always retryable', 'retryable': true };
  }

  protected override onAttempt(attemptNumber: number): void {
    console.log(`[retry] attempt ${attemptNumber} starting`);
  }

  protected override onRetryableError(
    attemptNumber: number,
    error: Error,
    classification: ErrorClassificationEntity.Type
  ): void {
    console.log(`[retry] attempt ${attemptNumber} retryable error: ${error.message} (${classification.reason ?? 'no reason'})`);
  }

  protected override onRetryScheduled(context: RetryContextType): void {
    console.log(`[retry] attempt ${context.attemptNumber} scheduled retry in ${context.delayMs}ms`);
    this.scheduledEvents.push({ 'attemptNumber': context.attemptNumber, 'delayMs': context.delayMs });
  }

  protected override onGiveUp(
    error: Error,
    attemptNumber: number,
    reason: 'aborted' | 'exhausted' | 'nonRetryable'
  ): void {
    console.log(`[retry] give up after ${attemptNumber} attempts: ${reason} — ${error.message}`);
    this.giveUpEvents.push({ 'attemptNumber': attemptNumber, 'reason': reason });
  }

  protected override enterCall(to: string, from: string): void {
    console.log(`[retry] call FSM ${from} → ${to}`);
  }
}

const maxRetries = 2;
const retry = new TelemetryRetry({
  'maxRetries': maxRetries
});

// Operation always fails — exercises scheduled and giveUp hooks
try {
  await retry.execute(() => {
    throw new Error('always fails');
  });
} catch (err) {
  assert.ok(err instanceof MaxRetriesExceededError, 'Expected MaxRetriesExceededError');
}

console.log('Scheduled events:', retry.scheduledEvents);
console.log('GiveUp events:', retry.giveUpEvents);
console.log('Stats:', retry.getStats());

Every package ships an analogous observed<Package>.ts runnable demo under its examples/ directory. Run any of them with npx tsx to watch the trace print:

bash
npx tsx packages/retry/examples/observedRetry.ts

Naming vocabulary

Hook names are consistent across packages so the same concept reads the same everywhere:

DomainHooks
Operation spanonStart · onSuccess · onError · onSettled
Resources & locksonAcquire · onAcquireWait · onContended · onRelease · onTimeout
Queues & streamsonEnqueue · onDequeue · onDrop · onOverflow · onSlowConsumer
CacheonHit · onMiss · onSet · onUpdate · onEvict · onExpire · onDelete · onClear
State machinesonTransition · onEnterState · onExitState · onTransitionRejected · onEffectStart / onEffectSuccess / onEffectError
Circuit & rate limitingonTrip · onOpen · onHalfOpen · onClose · onReject · onTokenAcquired · onTokenDepleted · onRefill
Publish / subscribeonPublish · onSubscribe · onUnsubscribe · onDeliver · onHandlerError

Packages add domain-specific hooks beyond these where a stage has no general name.

Hook reference by package

Each package's own page has a full table describing every hook, its trigger, and its arguments. This index is the at-a-glance map of where each stage lives.

Stateful primitives

PackageLifecycle hooks
batchonBatchStart onItemStart onItemSuccess onItemError onItemSettled onConcurrencySaturated onBatchComplete
cacheonHit onMiss onSet onUpdate onEvict onExpire onDelete onClear
circular-bufferonPush onShift onOverflow onEvict onGrow
clockonNow onHrtime onAdvance onNowMs
concurrencyonAcquire onAcquireWait onContended onRelease onReleaseDelegated onEnqueue onDequeue onClose onPublishDropped onCoalesceStart onCoalesceJoin onCoalesceSettled
contextonInitialize onMissingContext onSet onGet onDelete onEnter onExit onBeforeExecute onAfterExecute onError onTerminate onTerminatedAccess onDispose
event-busonPublish onSubscribe onUnsubscribe onDeliver onEnqueue onDequeue onDrop onOverflow onSlowConsumer onHandlerError onDispose
fetchonRequest (transform) onResponse (transform) onRequestStart onResponseSuccess onResponseError onRequestError onTimeout onAbort onDispatcherDestroy
file-lockonAcquireStart onAcquireWait onContended onAcquire onRelease onStaleDetected onStaleBreak onTimeout onError
flag-evaluatoronEvaluate onDefault onRuleMismatch
fsmonTransition onEnterState onExitState onTransitionRejected onEffectStart onEffectSuccess onEffectError onStart onStop onEnqueue onRegister onUnregister onResolveMiss
idempotency-guardonReplay onCoalesce onConflict onExecute
keyed-rate-limiteronKeyCreated onKeyEvicted onLimitExceeded onTokenAcquired
loggeronLog onDropped onChildCreate onTransportError onFieldSet onBuild onBuildError
memoizeonMemoHit onMemoMiss onMemoCoalesced
mutexbeforeAcquire afterAcquire onAcquireWait onContended onEnterKey beforeRelease onRelease afterRelease onQueueDrain onTimeout
pipelineonRunStart (transform) beforeStage (transform) onStageStart onStageSuccess afterStage (transform) onStageError onRunComplete (transform) onRunError
resilienceonSuccess onFailure onTrip onOpen onHalfOpen onClose onReject onTokenAcquired onTokenDepleted onRefill onWait onEnqueue onDequeue onOverflow onYield onDone onAbort
retryenterCall onAttempt classifyError (behavioral) onRetryableError onRetryScheduled (behavioral) onGiveUp onSuccess
sample-bufferonPush onOverflow onEvict onComputeStart onComputeComplete onPercentile onClear
scheduleronSchedule onFire onFireError onReschedule onCancel onCancelAll onAdvance onRunUntil onDrift onMiss onIdle
throttleonEnter onAcquire onAcquireWait onContended onReject onRelease onWindowSlide onAdaptiveAdjust onDrainStart onDrainComplete onAbortStart
timingonInitialize onEvent onGetEvents onEvict onClear
visible-rangeonRangeChange
worker-poolonMessage onWorkerTimeout onWorkerError

See also

Lifecycle hooks are the sanctioned extensibility mechanism one layer up too: substrate's pattern kits and composition guides never accept an externally-injected callback chain as an extensibility point, only compose primitives that already have hooks like the ones above.

  • Pattern Composition — how the shipped @studnicky/request-executor kit composes hook-covered primitives, and how to compose the same primitives directly.
  • Composition Anti-Patterns — the interceptor-pattern prohibition applied to the pattern-kit layer, plus other orchestration-shaped mistakes to avoid.
  • Dagonizer Boundary — where substrate composition ends and Dagonizer's workflow orchestration begins.