Factory method: Design Patterns best practices explained
When your application needs to create objects, the choice of which class to instantiate shouldn't live scattered across your codebase. Factory method: Design Patterns best practices explained shows you how to centralize that decision into a single, testable method. This pattern separates the *what* (the product interface) from the *how* (which concrete class to use), making your code more maintainable and flexible.
Callers ask for a product, not a class
The core principle of the factory method pattern is simple: callers should depend on an abstraction, not on concrete classes. When code spreads new S3Storage(), new LocalStorage(), and new AzureStorage() throughout your application, every module that needs storage becomes tightly coupled to specific implementations. This makes testing harder and refactoring riskier.
Instead, we ask the factory for a storage provider and work with the interface it returns. The caller doesn't care whether they're getting S3 or local disk storage—they just know they have an object that implements the Storage contract.
interface Storage {
put(key: string, value: string): Promise<void>;
get(key: string): Promise<string | null>;
delete(key: string): Promise<void>;
}
function createStorage(kind: 'local' | 's3'): Storage {
switch (kind) {
case 's3': return new S3Storage();
default: return new LocalStorage();
}
}
// Caller code
const storage = createStorage('s3');
await storage.put('user:123', JSON.stringify(userData));
Now when you need to switch storage backends for a customer or test environment, you change the factory call once, not dozens of scattered instantiations. Your business logic remains unchanged.
Centralizes creation logic
As applications grow, object creation often involves more than just calling a constructor. You might need to initialize dependencies, validate configuration, apply feature flags, or handle fallback behavior. A centralized factory method becomes the single source of truth for all that logic.
Consider a real-world scenario: your payment processor factory needs to handle timeouts, retry policies, and different credential stores depending on the environment.
interface PaymentProcessor {
charge(amount: number, currency: string): Promise<{
id: string;
status: 'success' | 'failed';
}>;
}
class StripeProcessor implements PaymentProcessor {
private apiKey: string;
constructor(apiKey: string, private retries: number = 3) {
this.apiKey = apiKey;
}
async charge(amount: number, currency: string) {
// Implementation with retry logic
}
}
class MockProcessor implements PaymentProcessor {
async charge(amount: number, currency: string) {
return { id: 'mock-' + Date.now(), status: 'success' };
}
}
function createPaymentProcessor(
env: 'production' | 'staging' | 'test'
): PaymentProcessor {
switch (env) {
case 'production':
const prodKey = process.env.STRIPE_KEY;
if (!prodKey) throw new Error('Missing STRIPE_KEY');
return new StripeProcessor(prodKey, 5);
case 'staging':
return new StripeProcessor(process.env.STRIPE_TEST_KEY || '', 3);
case 'test':
return new MockProcessor();
}
}
By centralizing this logic, you avoid scattered conditional checks in tests and avoid accidental use of production credentials in staging. When requirements change—say, adding a new payment provider or adjusting retry counts—you update one place.
Great for choosing implementations by config or input
The factory method pattern shines when your choice of implementation depends on external factors: configuration files, environment variables, user preferences, or request data. This is common in multi-tenant platforms, feature flags, and infrastructure tooling.
We built a secure tunnel service where customers can choose between different encryption algorithms based on their compliance requirements. The factory makes this choice transparent:
interface Encryptor {
encrypt(data: Buffer): Promise<Buffer>;
decrypt(data: Buffer): Promise<Buffer>;
}
class AES256GCMEncryptor implements Encryptor {
// FIPS 140-2 compliant
}
class ChaCha20Encryptor implements Encryptor {
// Lightweight, hardware-efficient
}
interface TenantConfig {
id: string;
encryptionMethod: 'aes256-gcm' | 'chacha20';
}
function createEncryptor(config: TenantConfig): Encryptor {
switch (config.encryptionMethod) {
case 'aes256-gcm':
return new AES256GCMEncryptor();
case 'chacha20':
return new ChaCha20Encryptor();
}
}
// Request handler
app.post('/tunnel/encrypt', (req: Request) => {
const tenant = getTenantConfig(req.headers['x-tenant-id']);
const encryptor = createEncryptor(tenant);
const encrypted = await encryptor.encrypt(req.body);
return encrypted;
});
Each tenant gets the encryption suited to their needs without knowing about the others' choices. Adding a new encryption method requires only a new class and a case in the factory.
Often a simple function is enough
Not every factory needs complex inheritance hierarchies or abstract classes. Many teams over-engineer this pattern. If your factory is small and straightforward, a plain function that returns an interface-typed object is often the best choice.
A function-based factory is easier to reason about, requires less boilerplate, and integrates naturally into dependency injection frameworks. Here's a practical example from a SaaS application:
interface Logger {
debug(msg: string, meta?: object): void;
info(msg: string, meta?: object): void;
error(msg: string, meta?: object): void;
}
// Simple factory function
function createLogger(service: string): Logger {
if (process.env.NODE_ENV === 'production') {
return {
debug: () => {}, // Suppress in production
info: (msg, meta) => console.log(`[${service}]`, msg, meta),
error: (msg, meta) => console.error(`[${service}]`, msg, meta),
};
}
return {
debug: (msg, meta) => console.debug(`[${service}]`, msg, meta),
info: (msg, meta) => console.info(`[${service}]`, msg, meta),
error: (msg, meta) => console.error(`[${service}]`, msg, meta),
};
}
// Usage
const log = createLogger('PaymentService');
log.info('Processing order', { orderId: '12345' });
This achieves all the benefits of the pattern without ceremony. You control which implementation gets used, you keep creation logic centralized, and the caller depends only on the Logger interface.
Common mistakes
- Leaking creation logic into callers: If you create the factory inside every module that uses it, you haven't centralized anything. Inject the factory function or result so there's a single point of control.
- Overusing switch statements: When you have many implementations, a switch with dozens of cases becomes hard to maintain. Consider a registry pattern:
const registry = new Map<string, () => Processor>(). Register implementations, then look them up by key. - Forgetting to test both paths: In tests, verify that different configurations actually return different implementations. Don't just test the happy path.
- Tying the factory to a specific framework: If your factory imports a database ORM or a web framework, it becomes harder to reuse. Keep factories focused on the abstraction they're creating.
- Creating a "god factory": One factory that creates everything in your app is a red flag. Each logical domain should have its own factory for its key abstractions.
Key takeaways
- The factory method pattern lets callers depend on abstractions, not concrete classes, making code more flexible and testable.
- Centralizing creation logic in one place means configuration changes, new implementations, and feature flags affect fewer files.
- Use the factory pattern when the choice of implementation depends on config, environment, or runtime data.
- A simple function returning an interface-typed object is often all you need—don't over-engineer with heavy class hierarchies.
- Watch for scattered instantiations in your codebase as a sign that a factory would help.






Comments
Loading comments…