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