16 KiB
16 KiB
| title | description | tags | |||
|---|---|---|---|---|---|
| Creational Design Patterns | Reference for Singleton, Factory, Builder, Prototype and other object creation patterns |
|
Creational Design Patterns
Patterns that deal with object creation mechanisms, trying to create objects in a manner suitable to the situation.
Singleton
Definition
Ensures a class has only one instance and provides a global point of access to it.
When to Use
- Exactly one instance of a class is needed (configuration, logging, database connection)
- Controlled access to a single object is required
- The instance should be extensible by subclassing
Warning: Often overused. Consider dependency injection or context-based alternatives first.
TypeScript Signature
class Singleton {
private static instance: Singleton;
private constructor() {
// Private constructor prevents instantiation
}
public static getInstance(): Singleton {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
public someMethod(): void {
// Business logic
}
}
// Usage
const instance = Singleton.getInstance();
Stack-Native Alternatives
React:
// Instead of Singleton, use Context
const ConfigContext = createContext<Config>(defaultConfig);
export const ConfigProvider = ({ children }: Props) => {
const [config] = useState(() => loadConfig());
return <ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>;
};
Angular:
// Injectable service (singleton by default)
@Injectable({ providedIn: 'root' })
export class ConfigService {
// Automatically singleton via DI
}
NestJS:
@Injectable() // Default scope is SINGLETON
export class AppService {}
Detection Markers
private constructorstatic getInstance()methodprivate static instancefield- Lazy initialization check:
if (!instance)
Code Smells It Fixes
- Global state access: Provides controlled access instead of scattered global variables
- Multiple instances of shared resource: Ensures single database connection, config object, etc.
Common Mistakes
- Hard to test: Static methods and global state make unit testing difficult
- Solution: Use dependency injection instead, or provide
resetInstance()for tests
- Solution: Use dependency injection instead, or provide
- Thread-safety issues: (Less relevant in JavaScript's single-threaded model, but important for Node.js workers)
- Hidden dependencies: Classes using
getInstance()have hidden coupling - Violates Single Responsibility: Often manages both instance creation and business logic
Evaluation Criteria
- Testability: 3/10 (hard to mock, global state)
- Thread-safety: 7/10 (less critical in JS)
- Extensibility: 5/10 (subclassing is complex)
Factory Method
Definition
Defines an interface for creating an object, but lets subclasses decide which class to instantiate.
When to Use
- Class cannot anticipate the type of objects it needs to create
- Class wants its subclasses to specify the objects it creates
- Centralize object creation logic to avoid duplication
- Need to decouple object creation from usage
TypeScript Signature
// Product interface
interface Product {
operation(): string;
}
// Concrete products
class ConcreteProductA implements Product {
operation(): string {
return 'Product A';
}
}
class ConcreteProductB implements Product {
operation(): string {
return 'Product B';
}
}
// Creator (Factory)
abstract class Creator {
// Factory method
abstract createProduct(): Product;
// Business logic using the product
someOperation(): string {
const product = this.createProduct();
return `Creator: ${product.operation()}`;
}
}
// Concrete creators
class CreatorA extends Creator {
createProduct(): Product {
return new ConcreteProductA();
}
}
class CreatorB extends Creator {
createProduct(): Product {
return new ConcreteProductB();
}
}
// Usage
const creator: Creator = new CreatorA();
console.log(creator.someOperation());
Modern TypeScript Alternative
// Simpler approach without inheritance
type ProductType = 'A' | 'B';
function createProduct(type: ProductType): Product {
switch (type) {
case 'A': return new ConcreteProductA();
case 'B': return new ConcreteProductB();
}
}
Detection Markers
- Method named
create*()returning interface/abstract class abstract createProduct()in base class- Subclasses override factory method
switchorif-elseon type/kind parameter
Code Smells It Fixes
- Tight coupling to concrete classes: Client code depends on interface, not implementation
- Duplication of instantiation logic: Centralized in factory method
- Switch statements scattered: Consolidated in one place
Common Mistakes
- Simple Factory confusion: Factory Method uses inheritance; Simple Factory uses composition
- Too many parameters: Should create objects with default configuration
- Forgetting to make factory method abstract: Defeats the purpose of subclass specialization
Evaluation Criteria
- Testability: 8/10 (easy to mock products)
- Flexibility: 9/10 (new products don't modify existing code)
- Complexity: 6/10 (adds inheritance hierarchy)
Abstract Factory
Definition
Provides an interface for creating families of related or dependent objects without specifying their concrete classes.
When to Use
- System should be independent of how its products are created
- System should be configured with one of multiple families of products
- Family of related product objects must be used together
- You want to provide a library of products and reveal only interfaces
TypeScript Signature
// Abstract products
interface AbstractProductA {
usefulFunctionA(): string;
}
interface AbstractProductB {
usefulFunctionB(): string;
anotherFunctionB(collaborator: AbstractProductA): string;
}
// Concrete products - Family 1
class ConcreteProductA1 implements AbstractProductA {
usefulFunctionA(): string {
return 'Product A1';
}
}
class ConcreteProductB1 implements AbstractProductB {
usefulFunctionB(): string {
return 'Product B1';
}
anotherFunctionB(collaborator: AbstractProductA): string {
return `B1 collaborating with ${collaborator.usefulFunctionA()}`;
}
}
// Concrete products - Family 2
class ConcreteProductA2 implements AbstractProductA {
usefulFunctionA(): string {
return 'Product A2';
}
}
class ConcreteProductB2 implements AbstractProductB {
usefulFunctionB(): string {
return 'Product B2';
}
anotherFunctionB(collaborator: AbstractProductA): string {
return `B2 collaborating with ${collaborator.usefulFunctionA()}`;
}
}
// Abstract factory
interface AbstractFactory {
createProductA(): AbstractProductA;
createProductB(): AbstractProductB;
}
// Concrete factories
class ConcreteFactory1 implements AbstractFactory {
createProductA(): AbstractProductA {
return new ConcreteProductA1();
}
createProductB(): AbstractProductB {
return new ConcreteProductB1();
}
}
class ConcreteFactory2 implements AbstractFactory {
createProductA(): AbstractProductA {
return new ConcreteProductA2();
}
createProductB(): AbstractProductB {
return new ConcreteProductB2();
}
}
// Client code
function clientCode(factory: AbstractFactory) {
const productA = factory.createProductA();
const productB = factory.createProductB();
console.log(productB.anotherFunctionB(productA));
}
// Usage
clientCode(new ConcreteFactory1());
clientCode(new ConcreteFactory2());
Detection Markers
- Multiple
create*()methods in factory interface - Families of related products (e.g., Button + Checkbox for Windows/Mac)
- Factory implementations return different product families
- Interface with 2+ factory methods
Code Smells It Fixes
- Inconsistent product families: Ensures compatible products are created together (Windows Button + Windows Checkbox, not mixed)
- Scattered creation logic: Centralizes creation of related objects
Common Mistakes
- Over-engineering: Often too complex for simple scenarios; Factory Method may suffice
- Rigid product families: Adding new product types requires changing all factories
- Confusion with Factory Method: Abstract Factory creates families; Factory Method creates one product type
Evaluation Criteria
- Testability: 8/10 (factories are easily mocked)
- Consistency: 10/10 (guarantees compatible products)
- Complexity: 4/10 (high complexity, many classes)
Builder
Definition
Separates the construction of a complex object from its representation, allowing step-by-step construction.
When to Use
- Object has many optional parameters (>4)
- Construction process should allow different representations
- Need to construct complex objects step-by-step
- Want to avoid "telescoping constructor" anti-pattern
TypeScript Signature
// Product
class House {
public walls: string = '';
public doors: number = 0;
public windows: number = 0;
public roof: string = '';
public garage: boolean = false;
public pool: boolean = false;
public describe(): string {
return `House with ${this.walls} walls, ${this.doors} doors, ${this.windows} windows, ${this.roof} roof, garage: ${this.garage}, pool: ${this.pool}`;
}
}
// Builder
class HouseBuilder {
private house: House;
constructor() {
this.house = new House();
}
public setWalls(walls: string): this {
this.house.walls = walls;
return this;
}
public setDoors(doors: number): this {
this.house.doors = doors;
return this;
}
public setWindows(windows: number): this {
this.house.windows = windows;
return this;
}
public setRoof(roof: string): this {
this.house.roof = roof;
return this;
}
public addGarage(): this {
this.house.garage = true;
return this;
}
public addPool(): this {
this.house.pool = true;
return this;
}
public build(): House {
const result = this.house;
this.house = new House(); // Reset for next build
return result;
}
}
// Usage
const house = new HouseBuilder()
.setWalls('brick')
.setDoors(2)
.setWindows(6)
.setRoof('tile')
.addGarage()
.build();
Modern TypeScript Alternative (Type-Safe Builder)
// Progressive type safety: each step unlocks the next
type HouseBuilderState<
TWalls extends boolean = false,
TRoof extends boolean = false
> = {
setWalls: TWalls extends true ? never : (walls: string) => HouseBuilderState<true, TRoof>;
setRoof: TRoof extends true ? never : (roof: string) => HouseBuilderState<TWalls, true>;
build: TWalls extends true ? (TRoof extends true ? () => House : never) : never;
};
Detection Markers
- Method chaining (returns
thisor builder type) build()method returning final productwith*()orset*()methods- Optional fields being set incrementally
Code Smells It Fixes
- Telescoping constructor: Constructor with many parameters
// Bad new House(walls, doors, windows, roof, garage, pool, garden, basement, ...); // Good with Builder new HouseBuilder().setWalls('brick').setRoof('tile').build(); - Unclear parameter order: Named methods make intent clear
- Optional parameters complexity: Builder handles optional features elegantly
Common Mistakes
- Mutable builder: Reusing builder can lead to unexpected state
- Solution: Reset internal state after
build()
- Solution: Reset internal state after
- Incomplete builder: Not validating required fields in
build()- Solution: Use TypeScript types to enforce required steps
- Too simple for the pattern: If <4 parameters, constructor or object literal may be simpler
Evaluation Criteria
- Testability: 9/10 (easy to create test fixtures)
- Readability: 10/10 (fluent interface is self-documenting)
- Complexity: 7/10 (adds builder class)
Prototype
Definition
Creates new objects by copying an existing object (prototype) rather than creating from scratch.
When to Use
- Object creation is expensive (complex initialization, database queries)
- Need to avoid subclassing just to change initialization
- System should be independent of how products are created
- Classes to instantiate are specified at runtime
TypeScript Signature
// Prototype interface
interface Prototype {
clone(): Prototype;
}
// Concrete prototype
class ConcretePrototype implements Prototype {
public field: number;
public complexObject: { data: string };
constructor(field: number, complexObject: { data: string }) {
this.field = field;
this.complexObject = complexObject;
}
// Shallow clone
public clone(): ConcretePrototype {
return Object.create(this);
}
// Deep clone
public deepClone(): ConcretePrototype {
return new ConcretePrototype(
this.field,
{ data: this.complexObject.data } // Clone nested objects
);
}
}
// Usage
const original = new ConcretePrototype(42, { data: 'important' });
const shallowCopy = original.clone();
const deepCopy = original.deepClone();
// Shallow copy shares nested objects
shallowCopy.complexObject.data = 'modified';
console.log(original.complexObject.data); // 'modified' (!)
// Deep copy is independent
deepCopy.field = 99;
console.log(original.field); // 42 (unchanged)
Modern JavaScript Alternatives
// Spread operator (shallow)
const copy1 = { ...original };
// Object.assign (shallow)
const copy2 = Object.assign({}, original);
// structuredClone (deep, modern browsers/Node 17+)
const copy3 = structuredClone(original);
// JSON (deep, but limited: no functions, undefined, etc.)
const copy4 = JSON.parse(JSON.stringify(original));
Detection Markers
clone()methodObject.create()structuredClone()JSON.parse(JSON.stringify())pattern- Spread operator
{ ...obj }
Code Smells It Fixes
- Expensive initialization: Clone instead of re-initializing
- Complex object graphs: Cloning preserves relationships
- Runtime type specification: Clone prototype instead of hardcoding types
Common Mistakes
- Shallow vs Deep clone confusion: Shallow clone shares nested objects
// Dangerous if nested objects are modified const shallow = { ...original }; - Circular references:
JSON.stringifyfails on circular references- Solution: Use
structuredClone()or custom clone logic
- Solution: Use
- Cloning methods/functions: Some approaches lose methods
JSON.parse(JSON.stringify(obj)); // Loses all methods! - Not cloning private state: Ensure all necessary state is copied
Evaluation Criteria
- Performance: 9/10 (faster than re-initialization)
- Simplicity: 7/10 (shallow vs deep cloning is tricky)
- Reliability: 6/10 (easy to get wrong with nested objects)
Summary Table
| Pattern | Complexity | Use Frequency | Main Benefit |
|---|---|---|---|
| Singleton | Low | High | Global access control |
| Factory Method | Medium | High | Decouples creation from usage |
| Abstract Factory | High | Medium | Consistent product families |
| Builder | Medium | High | Fluent construction of complex objects |
| Prototype | Low | Low | Efficient cloning |
Best Practices
- Prefer composition over inheritance: Factory and Builder often better than Singleton
- Use stack-native alternatives: React Context > Singleton, DI > getInstance()
- TypeScript leverage: Use generics and type constraints for type-safe builders
- Test-friendly design: Avoid Singleton; use dependency injection
- Simplicity first: Don't use Abstract Factory when Factory Method suffices
References
- Design Patterns: Elements of Reusable Object-Oriented Software (Gang of Four)
- Effective TypeScript by Dan Vanderkam
- Refactoring Guru: Creational Patterns