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:

  • Run initialization code after instance construction
  • Execute cleanup logic before instance disposal
  • Inject dependencies into properties
  • Perform custom lifecycle operations

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__/hooks/OnConstruct.spec.ts
import { Container, HookContext, HookFn, HooksRunner, inject, onConstruct, Registration as R } from '../../lib';

const onConstructHooksRunner = new HooksRunner('onConstruct');
const execute: HookFn = (ctx: HookContext) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

class Car {
  private engine!: string;

  @onConstruct(execute)
  setEngine(@inject('engine') engine: string) {
    this.engine = engine;
  }

  getEngine() {
    return this.engine;
  }
}

describe('onConstruct', function () {
  it('should run methods and resolve arguments from container', function () {
    const root = new Container()
      .addOnConstructHook((instance, scope) => {
        onConstructHooksRunner.execute(instance, { scope });
      })
      .addRegistration(R.fromValue('bmw').bindTo('engine'));

    const car = root.resolve(Car);

    expect(car.getEngine()).toBe('bmw');
  });
});
import { Container, HookContext, HookFn, HooksRunner, inject, onConstruct, Registration as R } from '../../lib';

const onConstructHooksRunner = new HooksRunner('onConstruct');
const execute: HookFn = (ctx: HookContext) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

class Car {
  private engine!: string;

  @onConstruct(execute)
  setEngine(@inject('engine') engine: string) {
    this.engine = engine;
  }

  getEngine() {
    return this.engine;
  }
}

describe('onConstruct', function () {
  it('should run methods and resolve arguments from container', function () {
    const root = new Container()
      .addOnConstructHook((instance, scope) => {
        onConstructHooksRunner.execute(instance, { scope });
      })
      .addRegistration(R.fromValue('bmw').bindTo('engine'));

    const car = root.resolve(Car);

    expect(car.getEngine()).toBe('bmw');
  });
});

Use Cases

  • Post-construction initialization that requires container access
  • Setting up properties that depend on other container services
  • Validation or setup logic that needs to run after all dependencies are injected
  • Initializing relationships or connections

OnDispose Hooks

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

Basic Usage

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

TypeScript __tests__/hooks/OnDispose.spec.ts
import {
  bindTo,
  Container,
  hook,
  type HookFn,
  HooksRunner,
  inject,
  register,
  Registration as R,
  select,
  singleton,
} from '../../lib';

const onDisposeHookRunner = new HooksRunner('onDispose');
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 {
  @hook('onDispose', ({ instance, methodName }) => {
    // @ts-ignore
    instance[methodName].push('world');
  }) // <--- or extract it to @onDispose
  private messages: string[] = [];

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

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

  @hook('onDispose', execute) // <--- or extract it to @onDispose
  save() {
    this.logsRepo.saveLogs(this.messages);
  }
}

describe('onDispose', function () {
  it('should invoke hooks on all instances', function () {
    const container = new Container().addRegistration(R.fromClass(Logger)).addRegistration(R.fromClass(LogsRepo));

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

    for (const instance of select.instances().resolve(container)) {
      onDisposeHookRunner.execute(instance, { scope: container });
    }

    expect(container.resolve<LogsRepo>('logsRepo').savedLogs.join(',')).toBe('Hello,world');
  });
});
import {
  bindTo,
  Container,
  hook,
  type HookFn,
  HooksRunner,
  inject,
  register,
  Registration as R,
  select,
  singleton,
} from '../../lib';

const onDisposeHookRunner = new HooksRunner('onDispose');
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 {
  @hook('onDispose', ({ instance, methodName }) => {
    // @ts-ignore
    instance[methodName].push('world');
  }) // <--- or extract it to @onDispose
  private messages: string[] = [];

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

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

  @hook('onDispose', execute) // <--- or extract it to @onDispose
  save() {
    this.logsRepo.saveLogs(this.messages);
  }
}

describe('onDispose', function () {
  it('should invoke hooks on all instances', function () {
    const container = new Container().addRegistration(R.fromClass(Logger)).addRegistration(R.fromClass(LogsRepo));

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

    for (const instance of select.instances().resolve(container)) {
      onDisposeHookRunner.execute(instance, { scope: container });
    }

    expect(container.resolve<LogsRepo>('logsRepo').savedLogs.join(',')).toBe('Hello,world');
  });
});

Use Cases

  • Closing database connections or file handles
  • Saving state or flushing buffers
  • Unsubscribing from events or observers
  • Releasing external resources
  • Logging cleanup operations

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 { Container, hook, HooksRunner, injectProp, Registration } from '../../lib';

const onInitHookRunner = new HooksRunner('onInit');
describe('inject property', () => {
  it('should inject property', () => {
    class App {
      @hook('onInit', injectProp('greeting'))
      greeting!: string;
    }
    const expected = 'Hello world!';

    const scope = new Container().addRegistration(Registration.fromValue(expected).bindToKey('greeting'));
    const app = scope.resolve(App);
    onInitHookRunner.execute(app, { scope });

    expect(app.greeting).toBe(expected);
  });
});
import { Container, hook, HooksRunner, injectProp, Registration } from '../../lib';

const onInitHookRunner = new HooksRunner('onInit');
describe('inject property', () => {
  it('should inject property', () => {
    class App {
      @hook('onInit', injectProp('greeting'))
      greeting!: string;
    }
    const expected = 'Hello world!';

    const scope = new Container().addRegistration(Registration.fromValue(expected).bindToKey('greeting'));
    const app = scope.resolve(App);
    onInitHookRunner.execute(app, { scope });

    expect(app.greeting).toBe(expected);
  });
});

When to Use Property Injection

  • Injecting into base class properties
  • Optional dependencies that might not always be available
  • Circular dependency scenarios
  • Framework integration where constructor injection is limited

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 { Container, hook, HooksRunner, type HookFn } from '../../lib';

const customHookRunner = new HooksRunner('customHook');
const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('Custom Hooks', () => {
  it('should create and execute custom hooks', () => {
    class MyService {
      isInitialized = false;

      @hook('customHook', execute)
      initialize() {
        this.isInitialized = true;
      }
    }

    const container = new Container().addOnConstructHook((instance, scope) => {
      customHookRunner.execute(instance, { scope });
    });

    const service = container.resolve(MyService);

    expect(service.isInitialized).toBe(true);
  });
});
import { Container, hook, HooksRunner, type HookFn } from '../../lib';

const customHookRunner = new HooksRunner('customHook');
const execute: HookFn = (ctx) => {
  ctx.invokeMethod({ args: ctx.resolveArgs() });
};

describe('Custom Hooks', () => {
  it('should create and execute custom hooks', () => {
    class MyService {
      isInitialized = false;

      @hook('customHook', execute)
      initialize() {
        this.isInitialized = true;
      }
    }

    const container = new Container().addOnConstructHook((instance, scope) => {
      customHookRunner.execute(instance, { scope });
    });

    const service = container.resolve(MyService);

    expect(service.isInitialized).toBe(true);
  });
});

Synchronous vs Asynchronous Hooks

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

TypeScript __tests__/hooks/hook.spec.ts
import {
  Container,
  hasHooks,
  hook,
  type HookFn,
  HooksRunner,
  inject,
  onConstruct,
  onConstructHooksRunner,
  onDispose,
  onDisposeHooksRunner,
  Registration as R,
  UnexpectedHookResultError,
} from '../../lib';

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

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

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

const beforeHooksRunner = new HooksRunner('syncBefore');
describe('hooks', () => {
  it('should run runHooks only for sync hooks', () => {
    class MyClass {
      isStarted = false;

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

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

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

    expect(instance.isStarted).toBe(true);
  });

  it('should throw an error if runHooks is used for async hooks', async () => {
    class MyClass {
      instanciated = false;

      @hook('syncBefore', executeAsync)
      start() {
        this.instanciated = true;
      }
    }

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

    expect(() => beforeHooksRunner.execute(instance, { scope: root })).toThrowError(UnexpectedHookResultError);
  });

  it('should test hooks', () => {
    class Logger {
      isStarted = false;
      isDisposed = false;

      @onConstruct(execute)
      initialize(): void {
        this.isStarted = true;
      }

      @onDispose(execute)
      destroy(): void {
        this.isDisposed = true;
      }
    }

    const root = new Container({ tags: ['root'] })
      .addOnConstructHook((instance, scope) => {
        onConstructHooksRunner.execute(instance, { scope });
      })
      .addOnDisposeHook((scope) => {
        for (const i of scope.getInstances()) {
          onDisposeHooksRunner.execute(i, { scope });
        }
      });

    const instance = root.resolve(Logger);
    root.dispose();

    expect(instance.isStarted).toBe(true);
    expect(instance.isDisposed).toBe(true);
  });

  const onStartHooksRunner = new HooksRunner('onStart');
  it('should test runHooksAsync', async () => {
    class Logger {
      isStarted = false;

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

      @hook('onStart', 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);
  });
});
import {
  Container,
  hasHooks,
  hook,
  type HookFn,
  HooksRunner,
  inject,
  onConstruct,
  onConstructHooksRunner,
  onDispose,
  onDisposeHooksRunner,
  Registration as R,
  UnexpectedHookResultError,
} from '../../lib';

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

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

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

const beforeHooksRunner = new HooksRunner('syncBefore');
describe('hooks', () => {
  it('should run runHooks only for sync hooks', () => {
    class MyClass {
      isStarted = false;

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

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

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

    expect(instance.isStarted).toBe(true);
  });

  it('should throw an error if runHooks is used for async hooks', async () => {
    class MyClass {
      instanciated = false;

      @hook('syncBefore', executeAsync)
      start() {
        this.instanciated = true;
      }
    }

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

    expect(() => beforeHooksRunner.execute(instance, { scope: root })).toThrowError(UnexpectedHookResultError);
  });

  it('should test hooks', () => {
    class Logger {
      isStarted = false;
      isDisposed = false;

      @onConstruct(execute)
      initialize(): void {
        this.isStarted = true;
      }

      @onDispose(execute)
      destroy(): void {
        this.isDisposed = true;
      }
    }

    const root = new Container({ tags: ['root'] })
      .addOnConstructHook((instance, scope) => {
        onConstructHooksRunner.execute(instance, { scope });
      })
      .addOnDisposeHook((scope) => {
        for (const i of scope.getInstances()) {
          onDisposeHooksRunner.execute(i, { scope });
        }
      });

    const instance = root.resolve(Logger);
    root.dispose();

    expect(instance.isStarted).toBe(true);
    expect(instance.isDisposed).toBe(true);
  });

  const onStartHooksRunner = new HooksRunner('onStart');
  it('should test runHooksAsync', async () => {
    class Logger {
      isStarted = false;

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

      @hook('onStart', 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);
  });
});

Hook Execution Methods

  • execute(instance, context) - Execute synchronous hooks. Throws an error if any hook is asynchronous.
  • executeAsync(instance, context) - Execute hooks asynchronously, supporting both sync and async hooks.

Hook Context

Hooks receive a HookContext that provides access to:

  • instance - The instance being hooked
  • scope - The container scope
  • methodName - The name of the method being executed
  • resolveArgs() - Resolve method arguments from the container
  • invokeMethod(options) - Invoke the hook method with arguments

Best Practices

  • Use OnConstruct for initialization - Initialize dependencies, validate state, or set up connections after construction
  • Use OnDispose for cleanup - Close resources, save state, or unsubscribe from events before disposal
  • Keep hook logic simple - Hooks should perform focused operations, not complex business logic
  • Resolve dependencies in hooks - Use ctx.resolveArgs() to inject dependencies that weren't available at construction time
  • Handle async hooks properly - Use executeAsync when hooks may be asynchronous
  • Register hooks once - Set up onConstruct and onDispose hooks when creating your root container
  • Prefer constructor injection - Use property injection with hooks only when constructor injection isn't possible