The Effect Handlers system in Effectively provides a powerful pattern for creating testable, modular code by separating the declaration of side effects from their implementation. This allows you to write abstract, composable tasks that can be tested easily and work in different environments.
Effect Handlers implement the "Effects Handler" pattern where:
- Effects are abstract declarations of what your code wants to do (e.g., "log a message", "read a file")
- Handlers are concrete implementations of how those effects should be executed
- The runtime connects effects to their handlers dynamically at execution time
This separation provides several benefits:
- Testability: Replace real implementations with mocks during testing
- Modularity: Swap implementations for different environments (dev, prod, test)
- Composability: Mix and match effects without tight coupling
import { defineEffect, withHandlers } from '@doeixd/effectively/handlers';
import { defineTask, run } from '@doeixd/effectively';
// 1. Define your effects (the "what")
const log = defineEffect<(message: string) => void>('log');
const getUniqueId = defineEffect<() => string>('getUniqueId');
// 2. Use effects in your tasks
const createUser = defineTask(async (name: string) => {
const id = await getUniqueId();
await log(`Creating user ${name} with ID: ${id}`);
return { id, name };
});
// 3. Provide handlers when running (the "how")
const result = await run(createUser, 'Alice', withHandlers({
log: (message) => console.log(message),
getUniqueId: () => crypto.randomUUID(),
}));Use defineEffect to create individual effects:
import { defineEffect } from '@doeixd/effectively/handlers';
// Define an effect with its type signature
const readFile = defineEffect<(path: string) => string>('readFile');
const writeFile = defineEffect<(path: string, content: string) => void>('writeFile');
const httpGet = defineEffect<(url: string) => Promise<string>>('httpGet');
// Effects are callable and return Promises
const content = await readFile('/path/to/file'); // Promise<string>
await writeFile('/path/to/output', content); // Promise<void>
const response = await httpGet('https://api.example.com'); // Promise<string>For convenience, use defineEffects to define multiple effects at once:
import { defineEffects } from '@doeixd/effectively/handlers';
// Define multiple effects with a single call
const effects = defineEffects({
log: (level: 'info' | 'warn' | 'error', message: string) => void,
readConfig: (key: string) => string | undefined,
saveState: (key: string, value: any) => void,
sendNotification: (recipient: string, message: string) => void,
});
// Use the effects object
const processUser = defineTask(async (userId: string) => {
await effects.log('info', `Processing user ${userId}`);
const config = await effects.readConfig('processing_mode');
if (config === 'notify') {
await effects.sendNotification(userId, 'Processing started');
}
// ... processing logic
await effects.saveState(`user_${userId}`, { processed: true });
});Create handlers manually as plain objects:
import { withHandlers } from '@doeixd/effectively/handlers';
const productionHandlers = {
log: (level: string, message: string) => {
console.log(`[${level.toUpperCase()}] ${message}`);
},
readConfig: (key: string) => process.env[key.toUpperCase()],
saveState: (key: string, value: any) => {
// Save to Redis/database
redis.set(key, JSON.stringify(value));
},
sendNotification: (recipient: string, message: string) => {
// Send via email/SMS service
notificationService.send(recipient, message);
},
};
await run(processUser, 'user123', withHandlers(productionHandlers));Use createHandlers for better type safety:
import { createHandlers } from '@doeixd/effectively/handlers';
const handlers = createHandlers({
log: (level: 'info' | 'warn' | 'error', message: string) => {
console.log(`[${level.toUpperCase()}] ${message}`);
},
readConfig: (key: string): string | undefined => {
return process.env[key.toUpperCase()];
},
// TypeScript will ensure these match your effect signatures
});Effect handlers make testing incredibly straightforward:
import { describe, it, expect, jest } from '@jest/globals';
describe('User Processing', () => {
it('should log processing start and save state', async () => {
const mockLog = jest.fn();
const mockSaveState = jest.fn();
const testHandlers = createHandlers({
log: mockLog,
readConfig: () => 'silent', // No notifications in test
saveState: mockSaveState,
sendNotification: jest.fn(), // Mock but unused
});
await run(processUser, 'test-user', withHandlers(testHandlers));
expect(mockLog).toHaveBeenCalledWith('info', 'Processing user test-user');
expect(mockSaveState).toHaveBeenCalledWith('user_test-user', { processed: true });
});
it('should send notification when configured', async () => {
const mockSendNotification = jest.fn();
const testHandlers = createHandlers({
log: () => {},
readConfig: (key) => key === 'processing_mode' ? 'notify' : undefined,
saveState: () => {},
sendNotification: mockSendNotification,
});
await run(processUser, 'notify-user', withHandlers(testHandlers));
expect(mockSendNotification).toHaveBeenCalledWith('notify-user', 'Processing started');
});
});Create different handler sets for different environments:
// handlers/development.ts
export const developmentHandlers = createHandlers({
log: (level, message) => console.log(`[DEV:${level}] ${message}`),
readConfig: (key) => process.env[key] || 'default-dev-value',
saveState: (key, value) => {
// Use in-memory store for development
developmentStore.set(key, value);
},
sendNotification: (recipient, message) => {
console.log(`[MOCK EMAIL] To: ${recipient}, Message: ${message}`);
},
});
// handlers/production.ts
export const productionHandlers = createHandlers({
log: (level, message) => logger.log(level, message),
readConfig: (key) => configService.get(key),
saveState: (key, value) => database.save(key, value),
sendNotification: (recipient, message) => emailService.send(recipient, message),
});
// main.ts
const handlers = process.env.NODE_ENV === 'production'
? productionHandlers
: developmentHandlers;
await run(myApp, initialData, withHandlers(handlers));Handlers can contain conditional logic:
const smartHandlers = createHandlers({
log: (level, message) => {
if (process.env.NODE_ENV === 'test') return; // Silent in tests
if (level === 'error') {
// Send to error tracking service
errorTracker.captureException(new Error(message));
}
console.log(`[${level}] ${message}`);
},
saveState: (key, value) => {
if (process.env.CACHE_ENABLED === 'true') {
cache.set(key, value, { ttl: 3600 });
}
return database.save(key, value);
},
});Combine multiple handler objects:
const baseHandlers = createHandlers({
log: console.log,
readConfig: (key) => process.env[key],
});
const extendedHandlers = createHandlers({
...baseHandlers,
// Add new handlers
sendEmail: (to, subject, body) => emailService.send(to, subject, body),
// Override existing handlers
log: (level, message) => structuredLogger.log({ level, message, timestamp: Date.now() }),
});Effects can call other effects:
const effects = defineEffects({
log: (level: string, message: string) => void,
getCurrentTime: () => string,
logWithTimestamp: (level: string, message: string) => void,
});
// This effect uses other effects
const logWithTimestamp = defineEffect<(level: string, message: string) => void>('logWithTimestamp');
// Implementation in handlers
const handlers = createHandlers({
log: (level, message) => console.log(`[${level}] ${message}`),
getCurrentTime: () => new Date().toISOString(),
logWithTimestamp: async (level, message) => {
const timestamp = await effects.getCurrentTime();
await effects.log(level, `${timestamp} - ${message}`);
},
});Each effect should represent a single, well-defined operation:
// Good: Focused, single responsibility
const readFile = defineEffect<(path: string) => string>('readFile');
const parseJson = defineEffect<(json: string) => any>('parseJson');
// Avoid: Doing too much in one effect
const readAndParseFile = defineEffect<(path: string) => any>('readAndParseFile');Effect names should clearly describe what they do:
// Good: Clear and descriptive
const sendWelcomeEmail = defineEffect<(userId: string) => void>('sendWelcomeEmail');
const getUserPreferences = defineEffect<(userId: string) => UserPrefs>('getUserPreferences');
// Avoid: Vague or generic names
const doStuff = defineEffect<(data: any) => any>('doStuff');
const handler = defineEffect<(input: string) => string>('handler');Group related effects together:
// auth-effects.ts
export const authEffects = defineEffects({
hashPassword: (password: string) => string,
verifyPassword: (password: string, hash: string) => boolean,
generateToken: (userId: string) => string,
validateToken: (token: string) => { userId: string } | null,
});
// storage-effects.ts
export const storageEffects = defineEffects({
saveUser: (user: User) => void,
getUser: (id: string) => User | null,
deleteUser: (id: string) => void,
listUsers: (filters?: UserFilters) => User[],
});Use TypeScript generics and interfaces for better type safety:
interface LogLevel {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
meta?: Record<string, unknown>;
}
const log = defineEffect<(entry: LogLevel) => void>('log');
// Handler must match the interface
const handlers = createHandlers({
log: (entry: LogLevel) => {
console.log(`[${entry.level}] ${entry.message}`, entry.meta);
},
});Effects can throw errors, which should be handled appropriately:
const readFile = defineEffect<(path: string) => string>('readFile');
const handlers = createHandlers({
readFile: (path: string) => {
try {
return fs.readFileSync(path, 'utf8');
} catch (error) {
throw new Error(`Failed to read file ${path}: ${error.message}`);
}
},
});
// In your task, handle potential errors
const processFile = defineTask(async (filePath: string) => {
try {
const content = await readFile(filePath);
return processContent(content);
} catch (error) {
await log('error', `File processing failed: ${error.message}`);
throw error;
}
});If you call an effect without providing a handler, you'll get an EffectHandlerNotFoundError:
import { EffectHandlerNotFoundError } from '@doeixd/effectively/handlers';
try {
await run(taskThatUsesEffects, input); // No handlers provided
} catch (error) {
if (error instanceof EffectHandlerNotFoundError) {
console.error(`Missing handler for effect: ${error.effectName}`);
}
}You can check if handlers are available before calling effects:
import { getContext } from '@doeixd/effectively';
import { HANDLERS_KEY } from '@doeixd/effectively/handlers';
const conditionalLog = defineTask(async (message: string) => {
const context = getContext();
const handlers = context[HANDLERS_KEY];
if (handlers?.log) {
await log(message);
} else {
console.warn('No log handler available, skipping log');
}
});import { withErrorBoundary, createErrorHandler } from '@doeixd/effectively';
const resilientTask = withErrorBoundary(
taskThatUsesEffects,
createErrorHandler(
[EffectHandlerNotFoundError, async (error) => {
console.error(`Missing effect handler: ${error.effectName}`);
return null; // Fallback value
}]
)
);Effects work seamlessly in workflows:
const workflow = createWorkflow(
validateInput,
fetchUserData, // Uses effects for API calls
enrichUserData, // Uses effects for additional data
saveResult, // Uses effects for persistence
sendNotification // Uses effects for notifications
);import { doTask, pure } from '@doeixd/effectively';
const userWorkflow = doTask(function* (userId: string) {
yield effects.log('info', `Starting workflow for user ${userId}`);
const user = yield fetchUser(userId);
const profile = yield enrichProfile(user);
yield effects.saveState(`user_${userId}`, profile);
yield effects.log('info', 'Workflow completed successfully');
return yield pure(profile);
});Traditional DI injects dependencies at construction time:
// Traditional DI
class UserService {
constructor(
private logger: Logger,
private database: Database,
private emailService: EmailService
) {}
async createUser(name: string) {
this.logger.info(`Creating user ${name}`);
// ...
}
}Effect handlers inject behavior at runtime:
// Effect handlers
const createUser = defineTask(async (name: string) => {
await log('info', `Creating user ${name}`);
// ...
});
// Provide behavior when running
await run(createUser, 'Alice', withHandlers(handlers));Benefits of effect handlers:
- More flexible: can change behavior per execution
- Better for testing: easy to mock individual operations
- Composable: mix and match effects across tasks
You could use context directly for effects:
interface AppContext {
logger: Logger;
database: Database;
}
const createUser = defineTask(async (name: string) => {
const { logger } = getContext<AppContext>();
logger.info(`Creating user ${name}`);
});Effect handlers provide additional benefits:
- Type safety: Effects have explicit signatures
- Error handling: Clear errors when handlers are missing
- Organization: Effects are self-documenting and centralized
- Flexibility: Easy to provide different implementations
Effect handlers in Effectively provide a powerful pattern for building testable, modular applications. By separating the declaration of side effects from their implementation, you can:
- Write code that's easy to test with mocks
- Swap implementations for different environments
- Compose effects in complex workflows
- Maintain type safety throughout your application
The effect handlers system integrates seamlessly with all other Effectively features, making it a natural choice for building robust, maintainable applications.