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
voidobservers — 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'sonRequest(context)/onResponse(context)are transform hooks;@studnicky/retry'sclassifyError(...)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:
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:
npx tsx packages/retry/examples/observedRetry.tsNaming vocabulary
Hook names are consistent across packages so the same concept reads the same everywhere:
| Domain | Hooks |
|---|---|
| Operation span | onStart · onSuccess · onError · onSettled |
| Resources & locks | onAcquire · onAcquireWait · onContended · onRelease · onTimeout |
| Queues & streams | onEnqueue · onDequeue · onDrop · onOverflow · onSlowConsumer |
| Cache | onHit · onMiss · onSet · onUpdate · onEvict · onExpire · onDelete · onClear |
| State machines | onTransition · onEnterState · onExitState · onTransitionRejected · onEffectStart / onEffectSuccess / onEffectError |
| Circuit & rate limiting | onTrip · onOpen · onHalfOpen · onClose · onReject · onTokenAcquired · onTokenDepleted · onRefill |
| Publish / subscribe | onPublish · 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
| Package | Lifecycle hooks |
|---|---|
| batch | onBatchStart onItemStart onItemSuccess onItemError onItemSettled onConcurrencySaturated onBatchComplete |
| cache | onHit onMiss onSet onUpdate onEvict onExpire onDelete onClear |
| circular-buffer | onPush onShift onOverflow onEvict onGrow |
| clock | onNow onHrtime onAdvance onNowMs |
| concurrency | onAcquire onAcquireWait onContended onRelease onReleaseDelegated onEnqueue onDequeue onClose onPublishDropped onCoalesceStart onCoalesceJoin onCoalesceSettled |
| context | onInitialize onMissingContext onSet onGet onDelete onEnter onExit onBeforeExecute onAfterExecute onError onTerminate onTerminatedAccess onDispose |
| event-bus | onPublish onSubscribe onUnsubscribe onDeliver onEnqueue onDequeue onDrop onOverflow onSlowConsumer onHandlerError onDispose |
| fetch | onRequest (transform) onResponse (transform) onRequestStart onResponseSuccess onResponseError onRequestError onTimeout onAbort onDispatcherDestroy |
| file-lock | onAcquireStart onAcquireWait onContended onAcquire onRelease onStaleDetected onStaleBreak onTimeout onError |
| flag-evaluator | onEvaluate onDefault onRuleMismatch |
| fsm | onTransition onEnterState onExitState onTransitionRejected onEffectStart onEffectSuccess onEffectError onStart onStop onEnqueue onRegister onUnregister onResolveMiss |
| idempotency-guard | onReplay onCoalesce onConflict onExecute |
| keyed-rate-limiter | onKeyCreated onKeyEvicted onLimitExceeded onTokenAcquired |
| logger | onLog onDropped onChildCreate onTransportError onFieldSet onBuild onBuildError |
| memoize | onMemoHit onMemoMiss onMemoCoalesced |
| mutex | beforeAcquire afterAcquire onAcquireWait onContended onEnterKey beforeRelease onRelease afterRelease onQueueDrain onTimeout |
| pipeline | onRunStart (transform) beforeStage (transform) onStageStart onStageSuccess afterStage (transform) onStageError onRunComplete (transform) onRunError |
| resilience | onSuccess onFailure onTrip onOpen onHalfOpen onClose onReject onTokenAcquired onTokenDepleted onRefill onWait onEnqueue onDequeue onOverflow onYield onDone onAbort |
| retry | enterCall onAttempt classifyError (behavioral) onRetryableError onRetryScheduled (behavioral) onGiveUp onSuccess |
| sample-buffer | onPush onOverflow onEvict onComputeStart onComputeComplete onPercentile onClear |
| scheduler | onSchedule onFire onFireError onReschedule onCancel onCancelAll onAdvance onRunUntil onDrift onMiss onIdle |
| throttle | onEnter onAcquire onAcquireWait onContended onReject onRelease onWindowSlide onAdaptiveAdjust onDrainStart onDrainComplete onAbortStart |
| timing | onInitialize onEvent onGetEvents onEvict onClear |
| visible-range | onRangeChange |
| worker-pool | onMessage 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-executorkit 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.