Hooks

Hooks provide a way to execute code at specific lifecycle points of an instance. They enable initialization logic after construction, cleanup logic before disposal, property injection, and custom lifecycle management. The hook system is flexible and extensible, allowing you to create custom hooks for your specific needs.

What are Hooks?

Hooks are methods decorated with special decorators that execute at specific points in an instance’s lifecycle. They can:

The hook system uses HooksRunner to execute hooks, which can be synchronous or asynchronous. Hooks can resolve dependencies from the container, making them powerful tools for managing complex initialization scenarios.

OnConstruct Hooks

OnConstruct hooks execute after an instance is created. This is useful for initialization logic that needs access to the container or other dependencies that can’t be injected via the constructor.

Basic Usage

Register an onConstruct hook on your container, then decorate methods with @onConstruct:

TypeScript __tests__/readme/onConstruct.spec.ts
import 'reflect-metadata';
import {
  AddOnConstructHookModule,
  Container,
  type ExecutionContext,
  type HookFn,
  type IContainer,
  inject,
  onConstruct,
  Registration as R,
} from 'ts-ioc-container';

const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('onConstruct', function () {
  it('should run initialization method after dependencies are resolved', function () {
    class DatabaseConnection {
      isConnected = false;
      connectionString = '';

      @onConstruct(execute)
      connect(@inject('ConnectionString') connectionString: string) {
        this.connectionString = connectionString;
        this.isConnected = true;
      }
    }

    const container = new Container()
      .useModule(new AddOnConstructHookModule())
      .addRegistration(R.fromValue('postgres://localhost:5432').bindTo('ConnectionString'));

    const db = container.resolve(DatabaseConnection);

    expect(db.isConnected).toBe(true);
    expect(db.connectionString).toBe('postgres://localhost:5432');
  });

  it('should forward hook exceptions to the onException handler with the execution context', function () {
    const failure = new Error('boom');

    class BrokenService {
      @onConstruct(() => {
        throw failure;
      })
      init() {}
    }

    let captured: { ex: unknown; context: ExecutionContext } | undefined;
    const container = new Container().useModule(
      new AddOnConstructHookModule((ex, context) => {
        captured = { ex, context };
      }),
    );

    expect(() => container.resolve(BrokenService)).not.toThrow();
    expect(captured?.ex).toBe(failure);
    expect(captured?.context.scope).toBe(container);
  });

  it('should rethrow hook exceptions when no onException handler is provided', function () {
    const failure = new Error('boom');

    class BrokenService {
      @onConstruct(() => {
        throw failure;
      })
      init() {}
    }

    const container = new Container().useModule(new AddOnConstructHookModule());

    expect(() => container.resolve(BrokenService)).toThrow(failure);
  });

  it('should expose the resolving scope through the execution context', function () {
    class BrokenService {
      @onConstruct(() => {
        throw new Error('boom');
      })
      init() {}
    }

    let scope: IContainer | undefined;
    const container = new Container().useModule(
      new AddOnConstructHookModule((_ex, context) => {
        scope = context.scope;
      }),
    );
    const child = container.createScope();

    child.resolve(BrokenService);

    expect(scope).toBe(child);
  });
});
import 'reflect-metadata';
import {
  AddOnConstructHookModule,
  Container,
  type ExecutionContext,
  type HookFn,
  type IContainer,
  inject,
  onConstruct,
  Registration as R,
} from 'ts-ioc-container';

const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('onConstruct', function () {
  it('should run initialization method after dependencies are resolved', function () {
    class DatabaseConnection {
      isConnected = false;
      connectionString = '';

      @onConstruct(execute)
      connect(@inject('ConnectionString') connectionString: string) {
        this.connectionString = connectionString;
        this.isConnected = true;
      }
    }

    const container = new Container()
      .useModule(new AddOnConstructHookModule())
      .addRegistration(R.fromValue('postgres://localhost:5432').bindTo('ConnectionString'));

    const db = container.resolve(DatabaseConnection);

    expect(db.isConnected).toBe(true);
    expect(db.connectionString).toBe('postgres://localhost:5432');
  });

  it('should forward hook exceptions to the onException handler with the execution context', function () {
    const failure = new Error('boom');

    class BrokenService {
      @onConstruct(() => {
        throw failure;
      })
      init() {}
    }

    let captured: { ex: unknown; context: ExecutionContext } | undefined;
    const container = new Container().useModule(
      new AddOnConstructHookModule((ex, context) => {
        captured = { ex, context };
      }),
    );

    expect(() => container.resolve(BrokenService)).not.toThrow();
    expect(captured?.ex).toBe(failure);
    expect(captured?.context.scope).toBe(container);
  });

  it('should rethrow hook exceptions when no onException handler is provided', function () {
    const failure = new Error('boom');

    class BrokenService {
      @onConstruct(() => {
        throw failure;
      })
      init() {}
    }

    const container = new Container().useModule(new AddOnConstructHookModule());

    expect(() => container.resolve(BrokenService)).toThrow(failure);
  });

  it('should expose the resolving scope through the execution context', function () {
    class BrokenService {
      @onConstruct(() => {
        throw new Error('boom');
      })
      init() {}
    }

    let scope: IContainer | undefined;
    const container = new Container().useModule(
      new AddOnConstructHookModule((_ex, context) => {
        scope = context.scope;
      }),
    );
    const child = container.createScope();

    child.resolve(BrokenService);

    expect(scope).toBe(child);
  });
});

Use Cases

OnConstructAsync Hooks

OnConstructAsync hooks are the promise-aware counterpart of @onConstruct. Use them when initialization has to await something — opening a connection, loading remote configuration, warming a cache.

Instance creation is synchronous, so async hooks cannot block it. AddOnConstructAsyncHookModule starts them when the instance is created and lets them settle afterwards: resolve returns before initialization completes. Instances that need to expose readiness should publish it themselves, for example by storing the pending promise on the instance.

Basic Usage

Register AddOnConstructAsyncHookModule on your container, then decorate methods with @onConstructAsync:

TypeScript __tests__/readme/onConstructAsync.spec.ts
import 'reflect-metadata';
import {
  AddOnConstructAsyncHookModule,
  Container,
  type ExecutionContext,
  type HookFn,
  inject,
  onConstructAsync,
  Registration as R,
} from 'ts-ioc-container';

const execute: HookFn = async (ctx) => {
  await ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('onConstructAsync', function () {
  it('should run an async initialization method after the instance is created', async function () {
    class DatabaseConnection {
      isConnected = false;
      connectionString = '';
      readonly ready: Promise<void>;

      private markReady!: () => void;

      constructor() {
        this.ready = new Promise((resolve) => {
          this.markReady = resolve;
        });
      }

      @onConstructAsync(execute)
      async connect(@inject('ConnectionString') connectionString: string) {
        await Promise.resolve();
        this.connectionString = connectionString;
        this.isConnected = true;
        this.markReady();
      }
    }

    const container = new Container()
      .useModule(new AddOnConstructAsyncHookModule())
      .addRegistration(R.fromValue('postgres://localhost:5432').bindTo('ConnectionString'));

    const db = container.resolve(DatabaseConnection);

    // resolution does not wait for async hooks
    expect(db.isConnected).toBe(false);

    await db.ready;

    expect(db.isConnected).toBe(true);
    expect(db.connectionString).toBe('postgres://localhost:5432');
  });

  it('should forward rejected hooks to the onException handler with the execution context', async function () {
    const failure = new Error('boom');

    class BrokenService {
      @onConstructAsync(() => Promise.reject(failure))
      init() {}
    }

    let captured: { ex: unknown; context: ExecutionContext } | undefined;
    const container = new Container().useModule(
      new AddOnConstructAsyncHookModule((ex, context) => {
        captured = { ex, context };
      }),
    );

    const child = container.createScope();
    child.resolve(BrokenService);

    await vi.waitFor(() => expect(captured).toBeDefined());

    expect(captured?.ex).toBe(failure);
    expect(captured?.context.scope).toBe(child);
  });
});
import 'reflect-metadata';
import {
  AddOnConstructAsyncHookModule,
  Container,
  type ExecutionContext,
  type HookFn,
  inject,
  onConstructAsync,
  Registration as R,
} from 'ts-ioc-container';

const execute: HookFn = async (ctx) => {
  await ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('onConstructAsync', function () {
  it('should run an async initialization method after the instance is created', async function () {
    class DatabaseConnection {
      isConnected = false;
      connectionString = '';
      readonly ready: Promise<void>;

      private markReady!: () => void;

      constructor() {
        this.ready = new Promise((resolve) => {
          this.markReady = resolve;
        });
      }

      @onConstructAsync(execute)
      async connect(@inject('ConnectionString') connectionString: string) {
        await Promise.resolve();
        this.connectionString = connectionString;
        this.isConnected = true;
        this.markReady();
      }
    }

    const container = new Container()
      .useModule(new AddOnConstructAsyncHookModule())
      .addRegistration(R.fromValue('postgres://localhost:5432').bindTo('ConnectionString'));

    const db = container.resolve(DatabaseConnection);

    // resolution does not wait for async hooks
    expect(db.isConnected).toBe(false);

    await db.ready;

    expect(db.isConnected).toBe(true);
    expect(db.connectionString).toBe('postgres://localhost:5432');
  });

  it('should forward rejected hooks to the onException handler with the execution context', async function () {
    const failure = new Error('boom');

    class BrokenService {
      @onConstructAsync(() => Promise.reject(failure))
      init() {}
    }

    let captured: { ex: unknown; context: ExecutionContext } | undefined;
    const container = new Container().useModule(
      new AddOnConstructAsyncHookModule((ex, context) => {
        captured = { ex, context };
      }),
    );

    const child = container.createScope();
    child.resolve(BrokenService);

    await vi.waitFor(() => expect(captured).toBeDefined());

    expect(captured?.ex).toBe(failure);
    expect(captured?.context.scope).toBe(child);
  });
});

Both modules can be registered together: @onConstruct and @onConstructAsync use separate metadata keys, so synchronous hooks keep failing fast through UnexpectedHookResultError while async hooks run on the async path.

Rejections are reported to the optional onException handler passed to the module. Without a handler the rejection is rethrown and surfaces as an unhandled rejection, since there is no caller left to catch it.

OnContainerDisposed Hooks

OnContainerDisposed hooks execute before an instance is disposed, allowing you to clean up resources, save state, or perform other cleanup operations.

Basic Usage

Register an onContainerDisposed hook on your container, then decorate methods with @onContainerDisposed:

TypeScript __tests__/readme/onContainerDisposed.spec.ts
import 'reflect-metadata';
import {
  AddOnDisposeHookModule,
  bindTo,
  Container,
  type HookFn,
  inject,
  onContainerDisposed,
  register,
  Registration as R,
  singleton,
} from 'ts-ioc-container';

const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

@register(bindTo('logsRepo'), singleton())
class LogsRepo {
  savedLogs: string[] = [];

  saveLogs(messages: string[]) {
    this.savedLogs.push(...messages);
  }
}

@register(bindTo('logger'))
class Logger {
  private messages: string[] = [];

  constructor(@inject('logsRepo') private logsRepo: LogsRepo) {}

  log(message: string): void {
    this.messages.push(message);
  }

  @onContainerDisposed(execute)
  save() {
    this.logsRepo.saveLogs(this.messages);
  }
}

describe('onContainerDisposed', function () {
  it('should invoke hooks on all instances when container is disposed', function () {
    const container = new Container()
      .useModule(new AddOnDisposeHookModule())
      .addRegistration(R.fromClass(Logger))
      .addRegistration(R.fromClass(LogsRepo));

    const logger = container.resolve<Logger>('logger');
    logger.log('Hello');
    const logsRepo = container.resolve<LogsRepo>('logsRepo');

    container.dispose();

    expect(logsRepo.savedLogs).toEqual(['Hello']);
  });
});
import 'reflect-metadata';
import {
  AddOnDisposeHookModule,
  bindTo,
  Container,
  type HookFn,
  inject,
  onContainerDisposed,
  register,
  Registration as R,
  singleton,
} from 'ts-ioc-container';

const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

@register(bindTo('logsRepo'), singleton())
class LogsRepo {
  savedLogs: string[] = [];

  saveLogs(messages: string[]) {
    this.savedLogs.push(...messages);
  }
}

@register(bindTo('logger'))
class Logger {
  private messages: string[] = [];

  constructor(@inject('logsRepo') private logsRepo: LogsRepo) {}

  log(message: string): void {
    this.messages.push(message);
  }

  @onContainerDisposed(execute)
  save() {
    this.logsRepo.saveLogs(this.messages);
  }
}

describe('onContainerDisposed', function () {
  it('should invoke hooks on all instances when container is disposed', function () {
    const container = new Container()
      .useModule(new AddOnDisposeHookModule())
      .addRegistration(R.fromClass(Logger))
      .addRegistration(R.fromClass(LogsRepo));

    const logger = container.resolve<Logger>('logger');
    logger.log('Hello');
    const logsRepo = container.resolve<LogsRepo>('logsRepo');

    container.dispose();

    expect(logsRepo.savedLogs).toEqual(['Hello']);
  });
});

Use Cases

Property Injection

Property injection allows you to inject dependencies into properties using hooks. This is useful when constructor injection isn’t possible or when you need to inject into base class properties.

Using injectProp with Hooks

The injectProp helper can be used with the @hook decorator to inject dependencies into properties:

TypeScript __tests__/readme/injectProp.spec.ts
import 'reflect-metadata';
import { append, Container, hook, HooksRunner, injectProp, Registration } from 'ts-ioc-container';

/**
 * UI Components - Property Injection
 *
 * Property injection is useful when you don't control the class instantiation
 * (like in some UI frameworks, Web Components, or legacy systems) or when
 * you want to avoid massive constructors in base classes.
 *
 * This example demonstrates a ViewModel that gets dependencies injected
 * AFTER construction via an initialization hook.
 */

describe('inject property', () => {
  it('should inject property', () => {
    // Runner for the 'onInit' lifecycle hook
    const onInitHookRunner = new HooksRunner('onInit');

    class UserViewModel {
      // Inject 'GreetingService' into 'greeting' property during 'onInit'
      @hook('onInit', append(injectProp('GreetingService')))
      greetingService!: string;

      display(): string {
        return `${this.greetingService} User`;
      }
    }

    const container = new Container().addRegistration(Registration.fromValue('Hello').bindToKey('GreetingService'));

    // 1. Create instance (dependencies not yet injected)
    const viewModel = container.resolve(UserViewModel);

    // 2. Run lifecycle hooks to inject properties
    onInitHookRunner.execute(viewModel, { scope: container });

    expect(viewModel.greetingService).toBe('Hello');
    expect(viewModel.display()).toBe('Hello User');
  });

  it('should read the applied instance property via getProperty', () => {
    const onInitHookRunner = new HooksRunner('onInit');

    let injectedValue: unknown;

    class UserViewModel {
      @hook(
        'onInit',
        append(injectProp('GreetingService'), (context) => {
          injectedValue = context.getProperty();
        }),
      )
      greetingService!: string;
    }

    const container = new Container().addRegistration(Registration.fromValue('Hello').bindToKey('GreetingService'));

    const viewModel = container.resolve(UserViewModel);
    onInitHookRunner.execute(viewModel, { scope: container });

    expect(injectedValue).toBe('Hello');
  });
});
import 'reflect-metadata';
import { append, Container, hook, HooksRunner, injectProp, Registration } from 'ts-ioc-container';

/**
 * UI Components - Property Injection
 *
 * Property injection is useful when you don't control the class instantiation
 * (like in some UI frameworks, Web Components, or legacy systems) or when
 * you want to avoid massive constructors in base classes.
 *
 * This example demonstrates a ViewModel that gets dependencies injected
 * AFTER construction via an initialization hook.
 */

describe('inject property', () => {
  it('should inject property', () => {
    // Runner for the 'onInit' lifecycle hook
    const onInitHookRunner = new HooksRunner('onInit');

    class UserViewModel {
      // Inject 'GreetingService' into 'greeting' property during 'onInit'
      @hook('onInit', append(injectProp('GreetingService')))
      greetingService!: string;

      display(): string {
        return `${this.greetingService} User`;
      }
    }

    const container = new Container().addRegistration(Registration.fromValue('Hello').bindToKey('GreetingService'));

    // 1. Create instance (dependencies not yet injected)
    const viewModel = container.resolve(UserViewModel);

    // 2. Run lifecycle hooks to inject properties
    onInitHookRunner.execute(viewModel, { scope: container });

    expect(viewModel.greetingService).toBe('Hello');
    expect(viewModel.display()).toBe('Hello User');
  });

  it('should read the applied instance property via getProperty', () => {
    const onInitHookRunner = new HooksRunner('onInit');

    let injectedValue: unknown;

    class UserViewModel {
      @hook(
        'onInit',
        append(injectProp('GreetingService'), (context) => {
          injectedValue = context.getProperty();
        }),
      )
      greetingService!: string;
    }

    const container = new Container().addRegistration(Registration.fromValue('Hello').bindToKey('GreetingService'));

    const viewModel = container.resolve(UserViewModel);
    onInitHookRunner.execute(viewModel, { scope: container });

    expect(injectedValue).toBe('Hello');
  });
});

When to Use Property Injection

Custom Hooks

You can create custom hooks for any lifecycle event or application-specific scenario. Custom hooks use the same HooksRunner system but with your own hook names.

Creating Custom Hooks

Create a custom hook by instantiating a HooksRunner with a unique hook name:

TypeScript __tests__/readme/customHooks.spec.ts
import { append, Container, hook, HooksRunner, type HookFn } from 'ts-ioc-container';

/**
 * User Management Domain - Custom Lifecycle Hooks
 *
 * Custom hooks extend the container's lifecycle management beyond
 * the built-in @onConstruct and @onContainerDisposed hooks.
 *
 * Use cases:
 * - @validateConfig: Validate service configuration after construction
 * - @warmCache: Pre-populate caches when service is created
 * - @registerMetrics: Register service with monitoring system
 * - @auditCreation: Log service creation for compliance
 *
 * How it works:
 * 1. Define a HooksRunner with a unique hook name
 * 2. Create methods decorated with @hook('hookName', append(executor))
 * 3. Register the hook runner via addOnConstructHook
 * 4. Methods are automatically called when instances are created
 */

// Create a custom hook runner for initialization
const initializeHookRunner = new HooksRunner('initialize');

// Hook executor - defines what happens when the hook fires
const executeInitialize: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('Custom Hooks', () => {
  it('should execute custom initialization hook after construction', () => {
    class CacheService {
      isWarmedUp = false;

      // Custom hook - called automatically after construction
      @hook('initialize', append(executeInitialize))
      warmCache() {
        this.isWarmedUp = true;
      }
    }

    const container = new Container({ tags: ['application'] }).addOnConstructHook((instance, scope) => {
      // Run all 'initialize' hooks on newly created instances
      initializeHookRunner.execute(instance, { scope });
    });

    const cacheService = container.resolve(CacheService);

    // Hook was automatically executed
    expect(cacheService.isWarmedUp).toBe(true);
  });
});
import { append, Container, hook, HooksRunner, type HookFn } from 'ts-ioc-container';

/**
 * User Management Domain - Custom Lifecycle Hooks
 *
 * Custom hooks extend the container's lifecycle management beyond
 * the built-in @onConstruct and @onContainerDisposed hooks.
 *
 * Use cases:
 * - @validateConfig: Validate service configuration after construction
 * - @warmCache: Pre-populate caches when service is created
 * - @registerMetrics: Register service with monitoring system
 * - @auditCreation: Log service creation for compliance
 *
 * How it works:
 * 1. Define a HooksRunner with a unique hook name
 * 2. Create methods decorated with @hook('hookName', append(executor))
 * 3. Register the hook runner via addOnConstructHook
 * 4. Methods are automatically called when instances are created
 */

// Create a custom hook runner for initialization
const initializeHookRunner = new HooksRunner('initialize');

// Hook executor - defines what happens when the hook fires
const executeInitialize: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('Custom Hooks', () => {
  it('should execute custom initialization hook after construction', () => {
    class CacheService {
      isWarmedUp = false;

      // Custom hook - called automatically after construction
      @hook('initialize', append(executeInitialize))
      warmCache() {
        this.isWarmedUp = true;
      }
    }

    const container = new Container({ tags: ['application'] }).addOnConstructHook((instance, scope) => {
      // Run all 'initialize' hooks on newly created instances
      initializeHookRunner.execute(instance, { scope });
    });

    const cacheService = container.resolve(CacheService);

    // Hook was automatically executed
    expect(cacheService.isWarmedUp).toBe(true);
  });
});

Composing Hooks

@hook takes the hook key and a map function:

hook(key: string | symbol, mapFn: (...prev: HookType[]) => HookType[])

mapFn receives the hooks already registered on the class for the decorated member and returns the resulting list, so you decide where new hooks land. appendHooks and prependHooks (also exported as the shorter append and prepend) cover the common cases:

import { append, appendHooks, hook, prepend, prependHooks } from 'ts-ioc-container';

class OrderService {
  @hook('actions', append(validate, persist)) // runs after hooks already registered
  @hook('actions', prepend(authorize)) // runs before them
  submit() {}
}

Decorator expressions are evaluated top-to-bottom, but the decorators themselves are applied bottom-up — so the lowest @hook registers first and the ones above it map over the list it produced. In the example above the final order is authorize, validate, persist.

Because mapFn is just a function, you can also reorder or replace the accumulated hooks:

class Reversed {
  @hook('actions', (...prev) => [...prev].reverse())
  @hook('actions', appendHooks(first, second))
  submit() {}
}

class Replaced {
  @hook('actions', () => [onlyThisOne]) // ignores prev, dropping earlier hooks
  @hook('actions', prependHooks(discarded))
  submit() {}
}

prev only contains hooks declared on the same class — hooks inherited from a parent class are merged separately when the runner collects them.

The built-in decorators (@onConstruct, @onConstructAsync, @onContainerDisposed) keep their variadic signature. They prepend internally to compensate for the bottom-up application order, so stacked decorators run in declaration order:

class Resource {
  @onConstruct(h1)
  @onConstruct(h2)
  initialize() {} // runs h1, then h2
}

Synchronous vs Asynchronous Hooks

Hooks can be synchronous or asynchronous. Use the appropriate execution method:

TypeScript __tests__/hooks/hook.spec.ts
import 'reflect-metadata';
import {
  append,
  appendHooks,
  args,
  bindTo,
  Container,
  GroupAliasToken,
  hasHooks,
  hook,
  type HookClass,
  HookContext,
  type HookFn,
  HooksRunner,
  inject,
  prepend,
  prependHooks,
  register,
  Registration as R,
} from 'ts-ioc-container';

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const execute: HookFn = (ctx) => {
  ctx.invokeMethod();
};

const executeAsync: HookFn = async (ctx) => {
  await ctx.invokeMethod();
};

describe('hooks', () => {
  it('should return the same context from setInitialArgs', () => {
    const root = new Container({ tags: ['root'] });
    const context = new HookContext({}, root, 'constructor');

    expect(context.setInitialArgs('arg1')).toBe(context);
  });

  it('should return the args passed to setInitialArgs from getInitialArgs', () => {
    const root = new Container({ tags: ['root'] });
    const context = new HookContext({}, root, 'constructor');

    context.setInitialArgs('arg1', 'arg2');

    expect(context.getInitialArgs()).toEqual(['arg1', 'arg2']);
  });

  it('should return an empty array from getInitialArgs when no initial args were set', () => {
    const root = new Container({ tags: ['root'] });
    const context = new HookContext({}, root, 'constructor');

    expect(context.getInitialArgs()).toEqual([]);
  });

  it('should prepend initial args when resolving hook method arguments', () => {
    const beforeHooksRunner = new HooksRunner('syncBefore');

    class MyClass {
      receivedArgs: unknown[] = [];

      @hook(
        'syncBefore',
        append((ctx) => {
          ctx.invokeMethod();
        }),
      )
      start(@inject(args(0)) firstArg: string, @inject('suffix') suffix: string, runtimeArg: string) {
        this.receivedArgs = [firstArg, suffix, runtimeArg];
      }
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromValue('injected').bindTo('suffix'));
    const instance = root.resolve(MyClass);

    beforeHooksRunner.execute(instance, {
      scope: root,
      createContext: (Target, scope, methodName) =>
        new HookContext(Target, scope, methodName).setInitialArgs('initial'),
    });

    expect(instance.receivedArgs).toEqual(['initial', 'injected', undefined]);
  });

  it('should map the hook context with mapContext when running execute', () => {
    const beforeHooksRunner = new HooksRunner('syncBefore');

    class MyClass {
      receivedArgs: unknown[] = [];

      @hook(
        'syncBefore',
        append((ctx) => {
          ctx.invokeMethod();
        }),
      )
      start(@inject(args(0)) firstArg: string, @inject('suffix') suffix: string) {
        this.receivedArgs = [firstArg, suffix];
      }
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromValue('injected').bindTo('suffix'));
    const instance = root.resolve(MyClass);

    beforeHooksRunner.execute(instance, {
      scope: root,
      mapContext: (context) => context.setInitialArgs('mapped'),
    });

    expect(instance.receivedArgs).toEqual(['mapped', 'injected']);
  });

  it('should map the hook context with mapContext when running executeAsync', async () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class MyClass {
      receivedArgs: unknown[] = [];

      @hook(
        'onStart',
        append(async (ctx) => {
          await ctx.invokeMethod();
        }),
      )
      async start(@inject(args(0)) firstArg: string) {
        this.receivedArgs = [firstArg];
      }
    }

    const root = new Container({ tags: ['root'] });
    const instance = root.resolve(MyClass);

    await onStartHooksRunner.executeAsync(instance, {
      scope: root,
      mapContext: (context) => context.setInitialArgs('mapped'),
    });

    expect(instance.receivedArgs).toEqual(['mapped']);
  });

  it('should run executeAsync for async hooks', async () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class Logger {
      isStarted = false;

      @hook('onStart', append(executeAsync))
      async initialize(@inject('TimeToSleep') timeToSleep: number) {
        await sleep(timeToSleep);
        this.isStarted = true;
      }

      @hook('onStart', append(executeAsync))
      async dispose(@inject('TimeToSleep') timeToSleep: number) {
        await sleep(timeToSleep);
        this.isStarted = false;
      }
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromValue(100).bindTo('TimeToSleep'));
    const instance = root.resolve(Logger);

    await onStartHooksRunner.executeAsync(instance, {
      scope: root,
      predicate: (methodName) => methodName === 'initialize',
    });

    expect(instance.isStarted).toBe(true);
    expect(hasHooks(instance, 'onStart')).toBe(true);
  });

  it('should report whether a target has hooks for the runner key', () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class WithHooks {
      @hook('onStart', append(execute))
      start() {}
    }

    class WithoutHooks {
      start() {}
    }

    const root = new Container({ tags: ['root'] });

    expect(onStartHooksRunner.hasHooks(root.resolve(WithHooks))).toBe(true);
    expect(onStartHooksRunner.hasHooks(root.resolve(WithoutHooks))).toBe(false);
  });

  it('should report no hooks when the target has hooks under a different key only', () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class MyClass {
      @hook('onDispose', append(execute))
      stop() {}
    }

    const root = new Container({ tags: ['root'] });

    expect(onStartHooksRunner.hasHooks(root.resolve(MyClass))).toBe(false);
  });

  it('should run hooks declared on a parent (extended-from) class', () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class Base {
      baseStarted = false;

      @hook('onStart', append(execute))
      startBase() {
        this.baseStarted = true;
      }
    }

    class Derived extends Base {
      derivedStarted = false;

      @hook('onStart', append(execute))
      startDerived() {
        this.derivedStarted = true;
      }
    }

    const root = new Container({ tags: ['root'] });
    const instance = root.resolve(Derived);

    onStartHooksRunner.execute(instance, { scope: root });

    expect(instance.baseStarted).toBe(true);
    expect(instance.derivedStarted).toBe(true);
  });

  it('should run parent hooks before child hooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class Base {
      @hook(
        'onStart',
        append((ctx) => {
          invoked.push('startBase');
          ctx.invokeMethod();
        }),
      )
      startBase() {}
    }

    class Derived extends Base {
      @hook(
        'onStart',
        append((ctx) => {
          invoked.push('startDerived');
          ctx.invokeMethod();
        }),
      )
      startDerived() {}
    }

    const root = new Container({ tags: ['root'] });

    onStartHooksRunner.execute(root.resolve(Derived), { scope: root });

    expect(invoked).toEqual(['startBase', 'startDerived']);
  });

  it('should not leak child hooks into parent instances', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class Base {
      @hook(
        'onStart',
        append((ctx) => {
          invoked.push('startBase');
          ctx.invokeMethod();
        }),
      )
      startBase() {}
    }

    class Derived extends Base {
      @hook(
        'onStart',
        append((ctx) => {
          invoked.push('startDerived');
          ctx.invokeMethod();
        }),
      )
      startDerived() {}
    }

    const root = new Container({ tags: ['root'] });

    onStartHooksRunner.execute(root.resolve(Base), { scope: root });

    expect(invoked).toEqual(['startBase']);
    expect(Derived).toBeDefined();
  });

  it('should execute plugin hooks for lazily injected plugins', () => {
    const onPluginStartHooksRunner = new HooksRunner('onPluginStart');
    const PluginToken = new GroupAliasToken<Plugin>('Plugin');

    interface Plugin {
      isStarted: boolean;
    }

    @register(bindTo(PluginToken))
    class FirstPlugin implements Plugin {
      isStarted = false;

      @hook('onPluginStart', append(execute))
      start() {
        this.isStarted = true;
      }
    }

    @register(bindTo(PluginToken))
    class SecondPlugin implements Plugin {
      isStarted = false;

      @hook('onPluginStart', append(execute))
      start() {
        this.isStarted = true;
      }
    }

    class App {
      constructor(@inject(PluginToken.lazy()) private readonly plugins: Plugin[]) {}

      runPlugins(scope: Container) {
        this.plugins.forEach((plugin) => onPluginStartHooksRunner.execute(plugin, { scope }));
      }

      getPlugins() {
        return this.plugins;
      }
    }

    const container = new Container()
      .addRegistration(R.fromClass(FirstPlugin))
      .addRegistration(R.fromClass(SecondPlugin));

    const app = container.resolve(App);

    app.runPlugins(container);

    expect(app.getPlugins().every((plugin) => plugin.isStarted)).toBe(true);
  });

  it('should run hooks passed to appendHooks in declaration order', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      @hook(
        'onStart',
        appendHooks(
          () => {
            invoked.push('first');
          },
          () => {
            invoked.push('second');
          },
        ),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['first', 'second']);
  });

  it('should add hooks after the already registered ones with appendHooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      // Decorators are applied bottom-up, so 'declared' is registered first
      @hook(
        'onStart',
        appendHooks(() => {
          invoked.push('appended');
        }),
      )
      @hook(
        'onStart',
        appendHooks(() => {
          invoked.push('declared');
        }),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['declared', 'appended']);
  });

  it('should add hooks before the already registered ones with prependHooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      @hook(
        'onStart',
        prependHooks(() => {
          invoked.push('prepended');
        }),
      )
      @hook(
        'onStart',
        appendHooks(() => {
          invoked.push('declared');
        }),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['prepended', 'declared']);
  });

  it('should expose append and prepend as aliases of appendHooks and prependHooks', () => {
    expect(append).toBe(appendHooks);
    expect(prepend).toBe(prependHooks);
  });

  it('should let a custom mapFn reorder the already registered hooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      @hook('onStart', (...prev) => [...prev].reverse())
      @hook(
        'onStart',
        appendHooks(
          () => {
            invoked.push('first');
          },
          () => {
            invoked.push('second');
          },
        ),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['second', 'first']);
  });

  it('should replace the already registered hooks when mapFn ignores them', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      @hook('onStart', () => [
        () => {
          invoked.push('replacement');
        },
      ])
      @hook(
        'onStart',
        appendHooks(() => {
          invoked.push('replaced');
        }),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['replacement']);
  });

  it('should accept hook classes in appendHooks and prependHooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class AppendedHook implements HookClass {
      execute() {
        invoked.push('appended');
      }
    }

    class PrependedHook implements HookClass {
      execute() {
        invoked.push('prepended');
      }
    }

    class MyClass {
      @hook('onStart', prependHooks(PrependedHook))
      @hook('onStart', appendHooks(AppendedHook))
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['prepended', 'appended']);
  });
});
import 'reflect-metadata';
import {
  append,
  appendHooks,
  args,
  bindTo,
  Container,
  GroupAliasToken,
  hasHooks,
  hook,
  type HookClass,
  HookContext,
  type HookFn,
  HooksRunner,
  inject,
  prepend,
  prependHooks,
  register,
  Registration as R,
} from 'ts-ioc-container';

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const execute: HookFn = (ctx) => {
  ctx.invokeMethod();
};

const executeAsync: HookFn = async (ctx) => {
  await ctx.invokeMethod();
};

describe('hooks', () => {
  it('should return the same context from setInitialArgs', () => {
    const root = new Container({ tags: ['root'] });
    const context = new HookContext({}, root, 'constructor');

    expect(context.setInitialArgs('arg1')).toBe(context);
  });

  it('should return the args passed to setInitialArgs from getInitialArgs', () => {
    const root = new Container({ tags: ['root'] });
    const context = new HookContext({}, root, 'constructor');

    context.setInitialArgs('arg1', 'arg2');

    expect(context.getInitialArgs()).toEqual(['arg1', 'arg2']);
  });

  it('should return an empty array from getInitialArgs when no initial args were set', () => {
    const root = new Container({ tags: ['root'] });
    const context = new HookContext({}, root, 'constructor');

    expect(context.getInitialArgs()).toEqual([]);
  });

  it('should prepend initial args when resolving hook method arguments', () => {
    const beforeHooksRunner = new HooksRunner('syncBefore');

    class MyClass {
      receivedArgs: unknown[] = [];

      @hook(
        'syncBefore',
        append((ctx) => {
          ctx.invokeMethod();
        }),
      )
      start(@inject(args(0)) firstArg: string, @inject('suffix') suffix: string, runtimeArg: string) {
        this.receivedArgs = [firstArg, suffix, runtimeArg];
      }
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromValue('injected').bindTo('suffix'));
    const instance = root.resolve(MyClass);

    beforeHooksRunner.execute(instance, {
      scope: root,
      createContext: (Target, scope, methodName) =>
        new HookContext(Target, scope, methodName).setInitialArgs('initial'),
    });

    expect(instance.receivedArgs).toEqual(['initial', 'injected', undefined]);
  });

  it('should map the hook context with mapContext when running execute', () => {
    const beforeHooksRunner = new HooksRunner('syncBefore');

    class MyClass {
      receivedArgs: unknown[] = [];

      @hook(
        'syncBefore',
        append((ctx) => {
          ctx.invokeMethod();
        }),
      )
      start(@inject(args(0)) firstArg: string, @inject('suffix') suffix: string) {
        this.receivedArgs = [firstArg, suffix];
      }
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromValue('injected').bindTo('suffix'));
    const instance = root.resolve(MyClass);

    beforeHooksRunner.execute(instance, {
      scope: root,
      mapContext: (context) => context.setInitialArgs('mapped'),
    });

    expect(instance.receivedArgs).toEqual(['mapped', 'injected']);
  });

  it('should map the hook context with mapContext when running executeAsync', async () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class MyClass {
      receivedArgs: unknown[] = [];

      @hook(
        'onStart',
        append(async (ctx) => {
          await ctx.invokeMethod();
        }),
      )
      async start(@inject(args(0)) firstArg: string) {
        this.receivedArgs = [firstArg];
      }
    }

    const root = new Container({ tags: ['root'] });
    const instance = root.resolve(MyClass);

    await onStartHooksRunner.executeAsync(instance, {
      scope: root,
      mapContext: (context) => context.setInitialArgs('mapped'),
    });

    expect(instance.receivedArgs).toEqual(['mapped']);
  });

  it('should run executeAsync for async hooks', async () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class Logger {
      isStarted = false;

      @hook('onStart', append(executeAsync))
      async initialize(@inject('TimeToSleep') timeToSleep: number) {
        await sleep(timeToSleep);
        this.isStarted = true;
      }

      @hook('onStart', append(executeAsync))
      async dispose(@inject('TimeToSleep') timeToSleep: number) {
        await sleep(timeToSleep);
        this.isStarted = false;
      }
    }

    const root = new Container({ tags: ['root'] }).addRegistration(R.fromValue(100).bindTo('TimeToSleep'));
    const instance = root.resolve(Logger);

    await onStartHooksRunner.executeAsync(instance, {
      scope: root,
      predicate: (methodName) => methodName === 'initialize',
    });

    expect(instance.isStarted).toBe(true);
    expect(hasHooks(instance, 'onStart')).toBe(true);
  });

  it('should report whether a target has hooks for the runner key', () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class WithHooks {
      @hook('onStart', append(execute))
      start() {}
    }

    class WithoutHooks {
      start() {}
    }

    const root = new Container({ tags: ['root'] });

    expect(onStartHooksRunner.hasHooks(root.resolve(WithHooks))).toBe(true);
    expect(onStartHooksRunner.hasHooks(root.resolve(WithoutHooks))).toBe(false);
  });

  it('should report no hooks when the target has hooks under a different key only', () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class MyClass {
      @hook('onDispose', append(execute))
      stop() {}
    }

    const root = new Container({ tags: ['root'] });

    expect(onStartHooksRunner.hasHooks(root.resolve(MyClass))).toBe(false);
  });

  it('should run hooks declared on a parent (extended-from) class', () => {
    const onStartHooksRunner = new HooksRunner('onStart');

    class Base {
      baseStarted = false;

      @hook('onStart', append(execute))
      startBase() {
        this.baseStarted = true;
      }
    }

    class Derived extends Base {
      derivedStarted = false;

      @hook('onStart', append(execute))
      startDerived() {
        this.derivedStarted = true;
      }
    }

    const root = new Container({ tags: ['root'] });
    const instance = root.resolve(Derived);

    onStartHooksRunner.execute(instance, { scope: root });

    expect(instance.baseStarted).toBe(true);
    expect(instance.derivedStarted).toBe(true);
  });

  it('should run parent hooks before child hooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class Base {
      @hook(
        'onStart',
        append((ctx) => {
          invoked.push('startBase');
          ctx.invokeMethod();
        }),
      )
      startBase() {}
    }

    class Derived extends Base {
      @hook(
        'onStart',
        append((ctx) => {
          invoked.push('startDerived');
          ctx.invokeMethod();
        }),
      )
      startDerived() {}
    }

    const root = new Container({ tags: ['root'] });

    onStartHooksRunner.execute(root.resolve(Derived), { scope: root });

    expect(invoked).toEqual(['startBase', 'startDerived']);
  });

  it('should not leak child hooks into parent instances', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class Base {
      @hook(
        'onStart',
        append((ctx) => {
          invoked.push('startBase');
          ctx.invokeMethod();
        }),
      )
      startBase() {}
    }

    class Derived extends Base {
      @hook(
        'onStart',
        append((ctx) => {
          invoked.push('startDerived');
          ctx.invokeMethod();
        }),
      )
      startDerived() {}
    }

    const root = new Container({ tags: ['root'] });

    onStartHooksRunner.execute(root.resolve(Base), { scope: root });

    expect(invoked).toEqual(['startBase']);
    expect(Derived).toBeDefined();
  });

  it('should execute plugin hooks for lazily injected plugins', () => {
    const onPluginStartHooksRunner = new HooksRunner('onPluginStart');
    const PluginToken = new GroupAliasToken<Plugin>('Plugin');

    interface Plugin {
      isStarted: boolean;
    }

    @register(bindTo(PluginToken))
    class FirstPlugin implements Plugin {
      isStarted = false;

      @hook('onPluginStart', append(execute))
      start() {
        this.isStarted = true;
      }
    }

    @register(bindTo(PluginToken))
    class SecondPlugin implements Plugin {
      isStarted = false;

      @hook('onPluginStart', append(execute))
      start() {
        this.isStarted = true;
      }
    }

    class App {
      constructor(@inject(PluginToken.lazy()) private readonly plugins: Plugin[]) {}

      runPlugins(scope: Container) {
        this.plugins.forEach((plugin) => onPluginStartHooksRunner.execute(plugin, { scope }));
      }

      getPlugins() {
        return this.plugins;
      }
    }

    const container = new Container()
      .addRegistration(R.fromClass(FirstPlugin))
      .addRegistration(R.fromClass(SecondPlugin));

    const app = container.resolve(App);

    app.runPlugins(container);

    expect(app.getPlugins().every((plugin) => plugin.isStarted)).toBe(true);
  });

  it('should run hooks passed to appendHooks in declaration order', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      @hook(
        'onStart',
        appendHooks(
          () => {
            invoked.push('first');
          },
          () => {
            invoked.push('second');
          },
        ),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['first', 'second']);
  });

  it('should add hooks after the already registered ones with appendHooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      // Decorators are applied bottom-up, so 'declared' is registered first
      @hook(
        'onStart',
        appendHooks(() => {
          invoked.push('appended');
        }),
      )
      @hook(
        'onStart',
        appendHooks(() => {
          invoked.push('declared');
        }),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['declared', 'appended']);
  });

  it('should add hooks before the already registered ones with prependHooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      @hook(
        'onStart',
        prependHooks(() => {
          invoked.push('prepended');
        }),
      )
      @hook(
        'onStart',
        appendHooks(() => {
          invoked.push('declared');
        }),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['prepended', 'declared']);
  });

  it('should expose append and prepend as aliases of appendHooks and prependHooks', () => {
    expect(append).toBe(appendHooks);
    expect(prepend).toBe(prependHooks);
  });

  it('should let a custom mapFn reorder the already registered hooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      @hook('onStart', (...prev) => [...prev].reverse())
      @hook(
        'onStart',
        appendHooks(
          () => {
            invoked.push('first');
          },
          () => {
            invoked.push('second');
          },
        ),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['second', 'first']);
  });

  it('should replace the already registered hooks when mapFn ignores them', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class MyClass {
      @hook('onStart', () => [
        () => {
          invoked.push('replacement');
        },
      ])
      @hook(
        'onStart',
        appendHooks(() => {
          invoked.push('replaced');
        }),
      )
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['replacement']);
  });

  it('should accept hook classes in appendHooks and prependHooks', () => {
    const onStartHooksRunner = new HooksRunner('onStart');
    const invoked: string[] = [];

    class AppendedHook implements HookClass {
      execute() {
        invoked.push('appended');
      }
    }

    class PrependedHook implements HookClass {
      execute() {
        invoked.push('prepended');
      }
    }

    class MyClass {
      @hook('onStart', prependHooks(PrependedHook))
      @hook('onStart', appendHooks(AppendedHook))
      start() {}
    }

    const root = new Container({ tags: ['root'] });
    onStartHooksRunner.execute(root.resolve(MyClass), { scope: root });

    expect(invoked).toEqual(['prepended', 'appended']);
  });
});

Hook Execution Methods

Hook Context

Hooks receive a HookContext that provides access to:

Preloading Hook Arguments

Use setInitialArgs() when a hook should supply fixed positional arguments before the container resolves decorated parameters. This is useful with custom HooksRunner flows where part of the argument list is known by the caller and the rest should still come from @inject.

const beforeHooksRunner = new HooksRunner('syncBefore');

class MyClass {
  @hook('syncBefore', append((ctx) => {
    ctx.invokeMethod();
  }))
  start(firstArg: string, @inject('suffix') suffix: string) {
    console.log(firstArg, suffix);
  }
}

beforeHooksRunner.execute(instance, {
  scope: root,
  createContext: (target, scope, methodName) =>
    new HookContext(target, scope, methodName).setInitialArgs('initial'),
});

In this example, firstArg receives 'initial' and suffix is still resolved from the container.

Alternatively, pass mapContext to transform the context the runner already created. This keeps the default createContext factory in place while letting you tweak the context per run — for example to preload arguments:

beforeHooksRunner.execute(instance, {
  scope: root,
  mapContext: (context) => context.setInitialArgs('initial'),
});

mapContext works the same way for both execute and executeAsync, and runs once per hooked method, after createContext.

Best Practices

[!IMPORTANT]

Lifecycle hooks are opt-in. Register the construct/dispose modules, or add equivalent hooks manually, before expecting decorated lifecycle methods to run.

[!WARNING]

Disposing a parent scope does not run child-scope dispose hooks. Dispose each child scope explicitly when its lifecycle ends.