Factory vs Singleton Decision Guide
Language: JavaScript
The default is factory pattern for testability, with singleton pattern reserved for rare cases.
Quick Decision Matrix
| Module Characteristic | Use Factory Pattern | Use Singleton Pattern |
|---|---|---|
| Has external dependencies (libs, config, adapters) | ✅ Always | ❌ Never |
| Takes configuration parameters | ✅ Always | ❌ Never |
| Needs different behavior per caller | ✅ Always | ❌ Never |
Exposes a React hook (use* calling useState, useRef, or useEffect) | ✅ Always | ❌ Never |
| Pure functions with zero dependencies | ✅ Preferred | ⚠️ Only if no test isolation needed |
| Foundation utility library | ✅ Preferred | ⚠️ Only if truly dependency-free |
Rule of thumb: If a module takes any external dependencies (libs, config, adapters), use factory pattern. Only consider singleton for pure utility modules with zero dependencies.
Factory Pattern (Default Choice)
When to Use
Use factory pattern when any of these apply:
- Module takes
shared_libsparameter - Module takes
configparameter - Module wraps external dependencies (DB, SDK, filesystem)
- Different callers need different configurations
- Module holds per-instance state (connections, pools, clients)
- Module's public surface includes a React hook; the per-consumer state a hook binds into rendering cannot be shared across callers. See React Hook Modules Are Factories
- Test isolation is required
Benefits
Testability
// Each test gets isolated instance
const testInstance1 = createModule({ log_level: 'debug' });
const testInstance2 = createModule({ log_level: 'error' });
// Tests can run in parallel without conflicts
// Mock dependencies can be injected per testConfiguration Flexibility
// Different behavior for different contexts
const devLogger = createDebugModule({ output_format: 'text' });
const prodLogger = createDebugModule({ output_format: 'json' });
// Multi-tenant setups
const tenantAAuth = createAuthModule({ store: tenantAStore });
const tenantBAuth = createAuthModule({ store: tenantBStore });Future-Proofing
// Easy to add state later without breaking changes
// Easy to add new dependencies without breaking changes
// Easy to evolve API without breaking existing callersImplementation Pattern
module.exports = function loader (shared_libs, config) {
const Lib = { Utils: shared_libs.Utils };
const CONFIG = Object.assign({}, require('./[module].config'), config || {});
const ERRORS = require('./[module].errors');
const Validators = require('./[module].validators')(Lib, ERRORS);
Validators.validateConfig(CONFIG);
return createInterface(Lib, CONFIG, ERRORS, Validators);
};
const createInterface = function (Lib, CONFIG, ERRORS, Validators) {
return {
methodName: function (params) {
// Functions close over Lib, CONFIG, ERRORS, and Validators
}
};
};See Universal Companion Files - the four fixed slots (Lib, CONFIG, ERRORS, Validators) are always wired, even when a companion file is empty today.
Singleton Pattern (Rare Exception)
When to Use
Use singleton pattern only when all criteria are met:
- Zero external dependencies (no
shared_libs, noconfig) - Pure functions only (no I/O, no side effects)
- Identical behavior for all callers
- No configuration needed
- No test isolation concerns
Valid Use Cases
Foundation Utilities
// js-helper-utils - pure utility functions, loader initializes Validators
let Validators;
module.exports = function loader (shared_libs, config) {
Validators = require('./utils.validators')(shared_libs);
return Utils;
};
const Utils = {
isNull: function (arg) { return arg === null; },
isString: function (arg) { return typeof arg === 'string'; },
// ... pure utility functions
};Data-Only Modules
// error catalogs, config defaults
module.exports = Object.freeze({
INVALID_INPUT: 'Invalid input provided',
MISSING_REQUIRED: 'Required field is missing'
});Implementation Pattern
// No loader needed for pure singletons
module.exports = {
methodName: function (params) {
// Pure function, no external deps
}
};Testability Implications
Factory Pattern Testing
describe('MyModule', () => {
it('handles different configurations', () => {
const instance1 = createModule({ timeout: 1000 });
const instance2 = createModule({ timeout: 5000 });
// Each instance has different behavior
expect(instance1.getTimeout()).toBe(1000);
expect(instance2.getTimeout()).toBe(5000);
});
it('accepts mock dependencies', () => {
const mockLib = { Utils: { isNull: () => false } };
const instance = createModule(mockLib, {});
// Test with mocked dependencies
});
});Singleton Pattern Testing
describe('UtilsModule', () => {
it('works consistently across tests', () => {
// No configuration needed
expect(Utils.isNull(null)).toBe(true);
expect(Utils.isNull('')).toBe(false);
});
// Note: Cannot test with different configurations
// Note: Cannot inject mock dependencies
});Migration Guidance
From Singleton to Factory
When to migrate:
- Adding external dependencies
- Adding configuration parameters
- Needing test isolation
- Multiple instance requirements
Migration steps:
- Add loader function with
shared_libsandconfigparameters - Move module logic into
createInterfacefunction - Update all callers to use factory pattern
- Update tests to create instances per test
- Version bump (breaking change)
From Factory to Singleton
When to consider:
- Module has zero dependencies
- Module is pure utility functions
- No configuration needed
- Test isolation not required
Migration steps:
- Remove loader function
- Export functions directly
- Update all callers to use direct require
- Update tests accordingly
- Version bump (breaking change)
Performance Considerations
Factory Pattern Overhead
- Memory: Each instance holds its own closure (minimal overhead)
- CPU: Instance creation is fast (microseconds)
- Benefits: Far outweighs minimal overhead in most cases
Singleton Pattern Benefits
- Memory: Single instance shared across process
- CPU: No instance creation overhead
- Trade-offs: Lost testability and flexibility
Recommendation
Performance differences are negligible compared to testability benefits. Use factory pattern unless module is truly dependency-free.
Real-World Examples
Correct Factory Usage
// js-helper-debug - takes config, needs test isolation
const Debug = require('helper-debug')(Lib, {
log_level: 'info',
output_format: 'json'
});
// js-helper-time - takes Utils dependency and config
const Time = require('helper-time')(Lib, {
default_timezone: 'UTC'
});
// js-server-helper-auth - takes a ready-to-use store object
const Auth = require('helper-auth')(Lib, {
Store: postgresStore,
ACTOR_TYPE: 'user'
});Correct Singleton Usage
// js-helper-utils - singleton loader, returns same Utils object
const Utils = require('helper-utils')({}, {});
// error catalog - data only
const Errors = require('./module.errors');Decision Checklist
Before choosing singleton pattern, verify:
- [ ] Module has zero external dependencies
- [ ] Module takes no configuration parameters
- [ ] All functions are pure (no I/O, no side effects)
- [ ] Module exposes no React hook
- [ ] No caller needs different behavior
- [ ] Test isolation is not required
- [ ] Module is truly dependency-free
If any answer is "no", use factory pattern.
Common Pitfalls
Incorrect Singleton Usage
// WRONG: Takes config but uses singleton pattern
let CONFIG;
module.exports = function loader (config) {
CONFIG = config; // Last caller wins!
return singletonInstance;
};
// PROBLEM: Tests interfere with each other
// PROBLEM: Cannot have different configurations
// SOLUTION: Use factory patternMissing Test Isolation
// WRONG: Global state in singleton
let globalCache = {};
module.exports = {
get: function(key) { return globalCache[key]; },
set: function(key, value) { globalCache[key] = value; }
};
// PROBLEM: Tests share cache state
// PROBLEM: Cannot reset state between tests
// SOLUTION: Use factory pattern with per-instance stateFurther Reading
- Module Structure (JavaScript) - Implementation patterns
- Core Helper Modules - Factory pattern guidance
- Module Testing - Testing strategies and isolation