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:
import 'reflect-metadata';
import { AddOnConstructHookModule, Container, type HookFn, 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');
});
});
import 'reflect-metadata';
import { AddOnConstructHookModule, Container, type HookFn, 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');
});
});
Use Cases
- DatabaseConnection - Establish connection after service is created
- SessionManager - Load user session from Redis after construction
- CacheService - Warm up cache with frequently accessed data
- EmailNotifier - Validate SMTP configuration before first use
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:
import 'reflect-metadata';
import {
AddOnDisposeHookModule,
bindTo,
Container,
type HookFn,
inject,
onDispose,
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);
}
@onDispose(execute)
save() {
this.logsRepo.saveLogs(this.messages);
}
}
describe('onDispose', 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,
onDispose,
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);
}
@onDispose(execute)
save() {
this.logsRepo.saveLogs(this.messages);
}
}
describe('onDispose', 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
- DatabaseConnection - Return connection to pool when request ends
- SessionManager - Persist session changes to Redis before disposal
- FileUploadService - Clean up temporary uploaded files
- WebSocketHandler - Close WebSocket connections gracefully
- AuditLogger - Flush pending audit log entries
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:
import 'reflect-metadata';
import { 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', 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');
});
});
import 'reflect-metadata';
import { 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', 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');
});
});
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:
import { 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 @onDispose 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', 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', 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 { 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 @onDispose 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', 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', 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);
});
});
Synchronous vs Asynchronous Hooks
Hooks can be synchronous or asynchronous. Use the appropriate execution method:
import 'reflect-metadata';
import {
args,
bindTo,
Container,
GroupAliasToken,
hasHooks,
hook,
HookContext,
type HookFn,
HooksRunner,
inject,
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 prepend initial args when resolving hook method arguments', () => {
const beforeHooksRunner = new HooksRunner('syncBefore');
class MyClass {
receivedArgs: unknown[] = [];
@hook('syncBefore', (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 run executeAsync for async hooks', async () => {
const onStartHooksRunner = new HooksRunner('onStart');
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);
});
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', execute)
start() {
this.isStarted = true;
}
}
@register(bindTo(PluginToken))
class SecondPlugin implements Plugin {
isStarted = false;
@hook('onPluginStart', 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);
});
});
import 'reflect-metadata';
import {
args,
bindTo,
Container,
GroupAliasToken,
hasHooks,
hook,
HookContext,
type HookFn,
HooksRunner,
inject,
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 prepend initial args when resolving hook method arguments', () => {
const beforeHooksRunner = new HooksRunner('syncBefore');
class MyClass {
receivedArgs: unknown[] = [];
@hook('syncBefore', (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 run executeAsync for async hooks', async () => {
const onStartHooksRunner = new HooksRunner('onStart');
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);
});
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', execute)
start() {
this.isStarted = true;
}
}
@register(bindTo(PluginToken))
class SecondPlugin implements Plugin {
isStarted = false;
@hook('onPluginStart', 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);
});
});
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 hookedscope- The container scopemethodName- The name of the method being executedresolveArgs()- Resolve method arguments from the containerinvokeMethod(options)- Invoke the hook method with argumentssetInitialArgs(...args)- Prepend explicit values before dependency-resolved hook arguments
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', (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.
Best Practices
Lifecycle hooks are opt-in. Register the construct/dispose modules, or add equivalent hooks manually, before expecting decorated lifecycle methods to run.
Disposing a parent scope does not run child-scope dispose hooks. Dispose each child scope explicitly when its lifecycle ends.
- 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
executeAsyncwhen 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