Skip to main content
Skip to main content

Finalizers and Cleanup

This guide covers resource cleanup patterns using finalizers and proper disposal.

Understanding Finalizers​

Finalizers are optional cleanup functions that run when containers or scopes are disposed.

const DatabaseAdapter = createAdapter({
provides: DatabasePort,
requires: [],
lifetime: 'singleton',
factory: () => new DatabasePool({ maxConnections: 10 }),
finalizer: async (pool) => {
await pool.close();
console.log('Database pool closed');
}
});

Disposal Order​

Finalizers are called in LIFO order (Last In, First Out):

// Creation order: A → B → C
const a = container.resolve(APort); // Created 1st
const b = container.resolve(BPort); // Created 2nd
const c = container.resolve(CPort); // Created 3rd

await container.dispose();
// Disposal order: C → B → A

This ensures dependencies are still available during cleanup.

Common Finalizer Patterns​

Database Connections​

const DatabasePoolAdapter = createAdapter({
provides: DatabasePoolPort,
requires: [ConfigPort, LoggerPort],
lifetime: 'singleton',
factory: (deps) => {
deps.Logger.log('Creating database pool');
return new Pool({
connectionString: deps.Config.databaseUrl,
max: deps.Config.maxConnections
});
},
finalizer: async (pool) => {
console.log('Closing database pool...');
await pool.end();
console.log('Database pool closed');
}
});

HTTP Clients​

const HttpClientAdapter = createAdapter({
provides: HttpClientPort,
requires: [LoggerPort],
lifetime: 'singleton',
factory: (deps) => {
const client = axios.create({
timeout: 30000
});
deps.Logger.log('HTTP client created');
return client;
},
finalizer: (client) => {
// Cancel any pending requests
client.defaults.cancelToken?.cancel('Client disposed');
}
});

WebSocket Connections​

const WebSocketAdapter = createAdapter({
provides: WebSocketPort,
requires: [ConfigPort],
lifetime: 'singleton',
factory: (deps) => {
const ws = new WebSocket(deps.Config.wsUrl);
return ws;
},
finalizer: (ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'Container disposed');
}
}
});

File Handles​

const LogFileAdapter = createAdapter({
provides: LogFilePort,
requires: [ConfigPort],
lifetime: 'singleton',
factory: (deps) => {
const stream = fs.createWriteStream(deps.Config.logPath, { flags: 'a' });
return {
write: (msg: string) => stream.write(`${msg}\n`)
};
},
finalizer: (logFile) => {
return new Promise<void>((resolve, reject) => {
logFile.stream.end(() => resolve());
logFile.stream.on('error', reject);
});
}
});

Event Listeners​

const EventBusAdapter = createAdapter({
provides: EventBusPort,
requires: [LoggerPort],
lifetime: 'singleton',
factory: (deps) => {
const emitter = new EventEmitter();
const handlers = new Map<string, Function[]>();

return {
on: (event: string, handler: Function) => {
if (!handlers.has(event)) {
handlers.set(event, []);
}
handlers.get(event)?.push(handler);
emitter.on(event, handler);
},
emit: (event: string, data: unknown) => {
emitter.emit(event, data);
},
_handlers: handlers,
_emitter: emitter
};
},
finalizer: (bus) => {
// Remove all listeners
bus._emitter.removeAllListeners();
bus._handlers.clear();
}
});

Cache Cleanup​

const CacheAdapter = createAdapter({
provides: CachePort,
requires: [LoggerPort],
lifetime: 'singleton',
factory: (deps) => {
const cache = new Map<string, CacheEntry>();
const timers = new Map<string, NodeJS.Timeout>();

return {
set: (key: string, value: unknown, ttl: number) => {
cache.set(key, { value, expiry: Date.now() + ttl });
const timer = setTimeout(() => cache.delete(key), ttl);
timers.set(key, timer);
},
get: (key: string) => {
const entry = cache.get(key);
if (!entry || entry.expiry < Date.now()) {
cache.delete(key);
return undefined;
}
return entry.value;
},
_timers: timers
};
},
finalizer: (cache) => {
// Clear all timers
for (const timer of cache._timers.values()) {
clearTimeout(timer);
}
cache._timers.clear();
}
});

Scoped Service Finalizers​

Finalizers also work for scoped services:

const RequestLoggerAdapter = createAdapter({
provides: RequestLoggerPort,
requires: [RequestContextPort],
lifetime: 'scoped',
factory: (deps) => {
const logs: string[] = [];
const { requestId } = deps.RequestContext;

return {
log: (msg: string) => logs.push(`[${requestId}] ${msg}`),
getLogs: () => [...logs],
requestId
};
},
finalizer: async (logger) => {
// Flush logs to storage at end of request
if (logger.getLogs().length > 0) {
await saveRequestLogs(logger.requestId, logger.getLogs());
}
}
});

Async Finalizers​

Finalizers can be async:

finalizer: async (service) => {
// Async cleanup operations
await service.flush();
await service.close();
await service.cleanup();
}

The disposal process waits for all finalizers to complete.

Error Handling in Finalizers​

Errors in finalizers are caught and logged:

const UnsafeAdapter = createAdapter({
provides: UnsafePort,
requires: [],
lifetime: 'singleton',
factory: () => ({}),
finalizer: () => {
throw new Error('Cleanup failed!');
}
});

// Disposal continues despite errors
await container.dispose();
// Error is logged but other finalizers still run

To handle errors explicitly:

import { fromPromise } from '@hex-di/result';

finalizer: async (service) => {
const result = await fromPromise(service.close(), (e) => e);
if (result.isErr()) {
console.error('Failed to close service:', result.error);
// Don't re-throw — allow other finalizers to run
}
}

Container Disposal​

Basic Disposal​

const container = createContainer({ graph, name: "App" });

// Use container...
const serviceResult = container.tryResolve(ServicePort);
if (serviceResult.isOk()) await serviceResult.value.doWork();

// Cleanup
await container.tryDispose();

Disposing with Active Scopes​

Dispose scopes before container:

const container = createContainer({ graph, name: "App" });
const scopes: Scope[] = [];

// Track scopes
function createRequestScope() {
const scope = container.createScope();
scopes.push(scope);
return scope;
}

// Cleanup
async function shutdown() {
// Dispose all scopes first
await Promise.all(scopes.map(s => s.tryDispose()));

// Then dispose container
await container.tryDispose();
}

Graceful Shutdown​

const container = createContainer({ graph, name: "App" });
let isShuttingDown = false;

// Graceful shutdown handler
process.on('SIGTERM', async () => {
if (isShuttingDown) return;
isShuttingDown = true;

console.log('Shutting down...');

// Stop accepting new requests
server.close();

// Wait for in-flight requests (with timeout)
await Promise.race([
waitForInflightRequests(),
delay(30000) // 30 second timeout
]);

// Dispose container
await container.tryDispose();

console.log('Shutdown complete');
process.exit(0);
});

Scope Disposal​

Manual Scope Management​

import { fromPromise } from '@hex-di/result';

async function handleRequest(req: Request) {
const scope = container.createScope();
const result = await scope.tryResolve(RequestServicePort)
.asyncAndThen((service) => fromPromise(service.process(req), (e) => e));
await scope.tryDispose();
return result;
}

Auto-Disposal with Resources​

import { fromPromise } from '@hex-di/result';
import type { Result } from '@hex-di/result';

async function withScope<T, E>(
container: Container,
fn: (scope: Scope) => Promise<Result<T, E>>,
): Promise<Result<T, E>> {
const scope = container.createScope();
const result = await fn(scope);
await scope.tryDispose();
return result;
}

// Usage
const result = await withScope(container, (scope) =>
scope.tryResolve(ServicePort)
.asyncAndThen((service) => fromPromise(service.doWork(), (e) => e)),
);

React Cleanup​

AutoScopeProvider Cleanup​

AutoScopeProvider handles cleanup automatically:

function Feature() {
return (
<AutoScopeProvider>
{/* Scope disposed when Feature unmounts */}
<FeatureContent />
</AutoScopeProvider>
);
}

Manual Hook Cleanup​

function useScopedService<T>(port: Port<T, string>): T {
const container = useContainer();
const scopeRef = useRef<Scope | null>(null);
const serviceRef = useRef<T | null>(null);

useEffect(() => {
const scope = container.createScope();
scopeRef.current = scope;
scope.tryResolve(port).match(
(service) => { serviceRef.current = service; },
(error) => { console.error('Failed to resolve service:', error); },
);

return () => {
void scopeRef.current?.tryDispose();
};
}, [container, port]);

const service = serviceRef.current;
if (service === null) throw new Error('Service not yet initialized');
return service;
}

Finalizer Best Practices​

1. Keep Finalizers Idempotent​

finalizer: async (service) => {
if (service.isClosed) return; // Already cleaned up
service.isClosed = true;
await service.close();
}

2. Don't Block Forever​

finalizer: async (service) => {
// Add timeout to prevent hanging
await Promise.race([
service.close(),
delay(5000).then(() => {
console.warn('Service close timed out');
})
]);
}

3. Log Cleanup Activities​

finalizer: async (service) => {
console.log(`Closing ${service.name}...`);
const start = Date.now();
await service.close();
console.log(`Closed ${service.name} in ${Date.now() - start}ms`);
}

4. Order-Dependent Cleanup​

If services depend on each other for cleanup, rely on LIFO order:

// Created first, disposed last
const DatabaseAdapter = createAdapter({
provides: DatabasePort,
// ...
finalizer: async (db) => {
// Can assume all dependent services are already cleaned up
await db.close();
}
});

// Created second, disposed first
const UserRepositoryAdapter = createAdapter({
provides: UserRepositoryPort,
requires: [DatabasePort],
// ...
finalizer: async (repo) => {
// Runs before DatabaseAdapter finalizer
await repo.flushCache();
}
});

5. Test Finalizers​

describe('DatabaseAdapter finalizer', () => {
it('closes the connection pool', async () => {
const container = createContainer({ graph, name: "App" });
const dbResult = container.tryResolve(DatabasePort);
expect(dbResult.isOk()).toBe(true);
if (!dbResult.isOk()) return;
const db = dbResult.value;

expect(db.pool.ended).toBe(false);

await container.tryDispose();

expect(db.pool.ended).toBe(true);
});
});

Next Steps​