Loading learning content...
The Gang of Four catalog presents patterns as discrete, independent solutions. Yet in production systems, patterns rarely operate in isolation. Real architectures orchestrate multiple patterns, each addressing a specific facet of the design challenge.
Consider a sophisticated order processing system:
These patterns don't compete—they collaborate. Understanding how patterns combine is what separates textbook knowledge from architectural mastery.
By the end of this page, you will understand how to identify synergistic pattern combinations, avoid conflicts between patterns, and design cohesive multi-pattern architectures. You'll see how patterns reinforce each other's strengths and compensate for each other's weaknesses.
Before exploring specific combinations, let's establish principles that guide effective pattern integration.
The Complementarity Principle:
Patterns combine well when they address orthogonal concerns—different aspects of the same problem that don't overlap. For example:
These concerns are orthogonal: solving one doesn't solve the others, yet they naturally compose.
Just because patterns can combine doesn't mean they should. Each pattern adds conceptual overhead. Before adding another pattern, ask: 'Is this complexity justified by a real requirement, or am I pattern-matching for its own sake?' The simplest design that meets requirements is the best design.
Certain behavioral patterns have natural affinities—they complement each other so well that they're frequently found together in mature architectures. Understanding these affinities accelerates design decisions.
| Combination | Synergy | Combined Benefit |
|---|---|---|
| Command + Memento | Command operations can use Memento for undo state | Full undo/redo with encapsulation preserved |
| Command + Chain of Responsibility | Chain routes commands to appropriate handlers | Decoupled command dispatch with flexible routing |
| Observer + Mediator | Mediator coordinates; Observer notifies specific events | Controlled communication with targeted notifications |
| State + Strategy | State uses strategies for state-specific algorithms | Clean state management with algorithm flexibility |
| Iterator + Visitor | Iterator handles traversal; Visitor handles operations | Separation of traversal and processing logic |
| Command + Observer | Commands execute; Observers react to command completion | Action execution with reactive notification |
| State + Memento | Memento captures state object snapshots for rollback | State machine with checkpoint capability |
| Chain of Responsibility + Command | Chain handlers wrap command execution with preprocessing | Pipeline processing with command semantics |
| Template Method + Strategy | Template defines structure; Strategy varies key steps | Fixed algorithm skeleton with pluggable components |
Recognizing Affinity Signals:
When analyzing a design problem, look for signals that multiple patterns are needed:
| Signal | Likely Combination |
|---|---|
| "We need to undo this" | Command + Memento |
| "Multiple systems need to know" | Command/State + Observer |
| "Route request dynamically, then execute" | Chain of Responsibility + Command |
| "Traverse and transform" | Iterator + Visitor |
| "State affects algorithm choice" | State + Strategy |
| "Centralize coordination, but notify specifically" | Mediator + Observer |
| "Fixed workflow, variable implementations" | Template Method + Strategy |
The Command + Memento combination is perhaps the most iconic pattern pairing for implementing undo/redo functionality. Let's explore how these patterns synergize.
The Individual Roles:
The Synergy:
Command alone can support undo if each command implements an unexecute() method. However, this approach has limitations:
Memento complements Command by capturing state snapshots before command execution. To undo, simply restore the memento rather than computing the inverse operation.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
// Memento captures editor stateinterface EditorMemento { content: string; cursorPosition: number; selectionRange: [number, number] | null;} class Editor { private content: string = ''; private cursorPosition: number = 0; private selectionRange: [number, number] | null = null; // Create memento (save state) createMemento(): EditorMemento { return { content: this.content, cursorPosition: this.cursorPosition, selectionRange: this.selectionRange ? [...this.selectionRange] : null, }; } // Restore from memento restoreMemento(memento: EditorMemento): void { this.content = memento.content; this.cursorPosition = memento.cursorPosition; this.selectionRange = memento.selectionRange; } // Normal editing operations insertText(text: string): void { /* ... */ } deleteSelection(): void { /* ... */ }} // Command with memento-based undointerface Command { execute(): void; undo(): void;} class InsertTextCommand implements Command { private memento: EditorMemento | null = null; constructor( private editor: Editor, private text: string ) {} execute(): void { // Capture state BEFORE execution this.memento = this.editor.createMemento(); this.editor.insertText(this.text); } undo(): void { if (this.memento) { // Restore state instead of computing inverse this.editor.restoreMemento(this.memento); } }} // Command history manages undo/redo stackclass CommandHistory { private undoStack: Command[] = []; private redoStack: Command[] = []; executeCommand(command: Command): void { command.execute(); this.undoStack.push(command); this.redoStack = []; // Clear redo on new command } undo(): void { const command = this.undoStack.pop(); if (command) { command.undo(); this.redoStack.push(command); } } redo(): void { const command = this.redoStack.pop(); if (command) { command.execute(); this.undoStack.push(command); } }}Observer + Mediator work together in systems with complex inter-component coordination that also requires targeted event notification. This combination is common in UI frameworks and event-driven architectures.
The Individual Roles:
The Synergy:
Mediator handles coordination logic—when component A changes, what should components B, C, and D do? Observer handles notification mechanics—how do interested parties learn of events?
Without Observer, Mediator must hardcode all notification targets. Without Mediator, Observer handling complex dependencies leads to spaghetti subscription logic. Together, they provide flexible coordination with extensible notification.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// Event types for type-safe notificationsinterface DialogEvent { type: 'input-changed' | 'button-clicked' | 'validation-complete'; source: string; data?: unknown;} // Observer interface for dialog eventsinterface DialogObserver { onDialogEvent(event: DialogEvent): void;} // Mediator coordinates dialog componentsclass DialogMediator { private components: Map<string, DialogComponent> = new Map(); private observers: Set<DialogObserver> = new Set(); // Observer pattern: subscription management subscribe(observer: DialogObserver): void { this.observers.add(observer); } unsubscribe(observer: DialogObserver): void { this.observers.delete(observer); } private notify(event: DialogEvent): void { this.observers.forEach(observer => observer.onDialogEvent(event)); } // Mediator pattern: component registration registerComponent(id: string, component: DialogComponent): void { this.components.set(id, component); component.setMediator(this); } // Mediator pattern: coordination logic handleComponentChange(source: string, event: string, data?: unknown): void { // Centralized coordination logic if (source === 'username' && event === 'input-changed') { const password = this.components.get('password'); const submit = this.components.get('submit'); // Enable password if username is valid if (data && (data as string).length >= 3) { password?.enable(); } else { password?.disable(); submit?.disable(); } } if (source === 'password' && event === 'input-changed') { const submit = this.components.get('submit'); const username = this.components.get('username'); // Enable submit if both fields are valid if (data && (data as string).length >= 8 && username?.getValue()?.length >= 3) { submit?.enable(); } else { submit?.disable(); } } // Observer pattern: notify external observers this.notify({ type: event as DialogEvent['type'], source, data }); }} // Dialog components communicate via mediatorabstract class DialogComponent { protected mediator: DialogMediator | null = null; setMediator(mediator: DialogMediator): void { this.mediator = mediator; } protected notifyMediator(event: string, data?: unknown): void { this.mediator?.handleComponentChange(this.getId(), event, data); } abstract getId(): string; abstract enable(): void; abstract disable(): void; abstract getValue(): string;} // External system observes dialog eventsclass AuditLogger implements DialogObserver { onDialogEvent(event: DialogEvent): void { console.log(`[Audit] ${event.source}: ${event.type}`); }} class AnalyticsTracker implements DialogObserver { onDialogEvent(event: DialogEvent): void { if (event.type === 'button-clicked') { // Track button click in analytics analytics.track('dialog_interaction', event); } }}Use Observer + Mediator when:
State + Strategy combine when an object's state determines which algorithm to use. This powerful combination separates state management from algorithm selection.
The Individual Roles:
The Synergy:
In State pattern, each state class typically contains the behavior for that state. But what if the behavior itself has variations? State + Strategy allows each state to delegate behavioral variations to strategies, keeping state classes focused on state-specific logic while strategies handle algorithm variations.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
// Strategy for pricing calculationinterface PricingStrategy { calculatePrice(basePrice: number, quantity: number): number;} class RegularPricing implements PricingStrategy { calculatePrice(basePrice: number, quantity: number): number { return basePrice * quantity; }} class BulkDiscountPricing implements PricingStrategy { calculatePrice(basePrice: number, quantity: number): number { const discount = quantity >= 100 ? 0.2 : quantity >= 50 ? 0.1 : 0; return basePrice * quantity * (1 - discount); }} class LoyaltyPricing implements PricingStrategy { constructor(private loyaltyDiscount: number) {} calculatePrice(basePrice: number, quantity: number): number { return basePrice * quantity * (1 - this.loyaltyDiscount); }} // State for customer accountinterface CustomerState { getName(): string; getPricingStrategy(): PricingStrategy; getShippingRate(): number; canAccessPremiumProducts(): boolean; handlePurchase(amount: number, context: CustomerAccount): void;} class GuestState implements CustomerState { getName() { return 'Guest'; } getPricingStrategy() { return new RegularPricing(); } getShippingRate() { return 9.99; } canAccessPremiumProducts() { return false; } handlePurchase(amount: number, context: CustomerAccount): void { // Guests get prompted to register after purchase console.log('Consider registering for benefits!'); }} class StandardState implements CustomerState { getName() { return 'Standard'; } getPricingStrategy() { return new RegularPricing(); } getShippingRate() { return 5.99; } canAccessPremiumProducts() { return true; } handlePurchase(amount: number, context: CustomerAccount): void { context.addLoyaltyPoints(Math.floor(amount)); if (context.getTotalSpent() >= 1000) { context.setState(new GoldState()); } }} class GoldState implements CustomerState { getName() { return 'Gold'; } getPricingStrategy() { return new LoyaltyPricing(0.05); } getShippingRate() { return 0; } // Free shipping! canAccessPremiumProducts() { return true; } handlePurchase(amount: number, context: CustomerAccount): void { context.addLoyaltyPoints(Math.floor(amount * 1.5)); // Bonus points if (context.getTotalSpent() >= 5000) { context.setState(new PlatinumState()); } }} class PlatinumState implements CustomerState { getName() { return 'Platinum'; } getPricingStrategy() { return new LoyaltyPricing(0.15); } getShippingRate() { return 0; } canAccessPremiumProducts() { return true; } handlePurchase(amount: number, context: CustomerAccount): void { context.addLoyaltyPoints(Math.floor(amount * 2)); // Double points // Platinum is top tier }} // Context uses State, which uses Strategyclass CustomerAccount { private state: CustomerState = new GuestState(); private totalSpent: number = 0; private loyaltyPoints: number = 0; setState(state: CustomerState): void { console.log(`State changed from ${this.state.getName()} to ${state.getName()}`); this.state = state; } getTotalSpent(): number { return this.totalSpent; } addLoyaltyPoints(points: number): void { this.loyaltyPoints += points; } // Pricing uses strategy from current state calculatePrice(basePrice: number, quantity: number): number { const strategy = this.state.getPricingStrategy(); return strategy.calculatePrice(basePrice, quantity); } getShippingRate(): number { return this.state.getShippingRate(); } makePurchase(basePrice: number, quantity: number): void { const price = this.calculatePrice(basePrice, quantity); const shipping = this.state.getShippingRate(); const total = price + shipping; this.totalSpent += total; this.state.handlePurchase(total, this); console.log(`Order: $${total} (Member: ${this.state.getName()})`); }}The Layered Benefit:
This combination creates a layered architecture:
Each layer has a single responsibility:
This separation makes each layer independently testable and modifiable.
Iterator + Visitor form a powerful combination for processing object structures. Iterator handles how to traverse; Visitor handles what to do at each element.
The Individual Roles:
The Synergy:
Without Iterator, Visitor must embed traversal logic or receive elements one-by-one from elsewhere. Without Visitor, Iterator clients must use type checking to handle heterogeneous elements. Together, they provide clean separation of traversal and operation dispatch.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
// Element hierarchy for file systeminterface FileSystemElement { accept(visitor: FileSystemVisitor): void; getName(): string;} class File implements FileSystemElement { constructor( private name: string, private size: number, private extension: string ) {} getName() { return this.name; } getSize() { return this.size; } getExtension() { return this.extension; } accept(visitor: FileSystemVisitor): void { visitor.visitFile(this); }} class Directory implements FileSystemElement { private children: FileSystemElement[] = []; constructor(private name: string) {} getName() { return this.name; } getChildren() { return this.children; } add(element: FileSystemElement): void { this.children.push(element); } accept(visitor: FileSystemVisitor): void { visitor.visitDirectory(this); }} // Visitor defines operationsinterface FileSystemVisitor { visitFile(file: File): void; visitDirectory(directory: Directory): void;} // Concrete visitors implement specific operationsclass SizeCalculator implements FileSystemVisitor { private totalSize = 0; visitFile(file: File): void { this.totalSize += file.getSize(); } visitDirectory(directory: Directory): void { // Directory itself has no size; children contribute } getTotal() { return this.totalSize; }} class FileCounter implements FileSystemVisitor { private counts = new Map<string, number>(); visitFile(file: File): void { const ext = file.getExtension(); this.counts.set(ext, (this.counts.get(ext) || 0) + 1); } visitDirectory(directory: Directory): void { // Directories not counted } getCounts() { return this.counts; }} class SearchVisitor implements FileSystemVisitor { private results: File[] = []; constructor(private pattern: RegExp) {} visitFile(file: File): void { if (this.pattern.test(file.getName())) { this.results.push(file); } } visitDirectory(directory: Directory): void { // Optionally match directories too } getResults() { return this.results; }} // Iterator handles traversalinterface FileSystemIterator { hasNext(): boolean; next(): FileSystemElement;} class DepthFirstIterator implements FileSystemIterator { private stack: FileSystemElement[] = []; constructor(root: FileSystemElement) { this.stack.push(root); } hasNext(): boolean { return this.stack.length > 0; } next(): FileSystemElement { const element = this.stack.pop()!; if (element instanceof Directory) { // Push children in reverse for correct order const children = element.getChildren(); for (let i = children.length - 1; i >= 0; i--) { this.stack.push(children[i]); } } return element; }} class BreadthFirstIterator implements FileSystemIterator { private queue: FileSystemElement[] = []; constructor(root: FileSystemElement) { this.queue.push(root); } hasNext(): boolean { return this.queue.length > 0; } next(): FileSystemElement { const element = this.queue.shift()!; if (element instanceof Directory) { element.getChildren().forEach(child => this.queue.push(child)); } return element; }} // Traversal + operation combined cleanlyfunction processFileSystem( iterator: FileSystemIterator, visitor: FileSystemVisitor): void { while (iterator.hasNext()) { const element = iterator.next(); element.accept(visitor); }} // Usage: Any iterator + any visitorconst root = new Directory('root');// ... populate file system ... const sizeCalc = new SizeCalculator();processFileSystem(new DepthFirstIterator(root), sizeCalc);console.log('Total size:', sizeCalc.getTotal()); const counter = new FileCounter();processFileSystem(new BreadthFirstIterator(root), counter);console.log('File counts:', counter.getCounts());Notice how any Iterator can combine with any Visitor. DepthFirstIterator + SizeCalculator, BreadthFirstIterator + SearchVisitor—all combinations work. This is the power of orthogonal pattern combination: M iterators × N visitors = M×N behaviors with only M+N implementations.
Chain of Responsibility + Command create sophisticated request processing pipelines. Chain routes requests; Command encapsulates them for flexible handling.
The Individual Roles:
The Synergy:
Commands become the what that flows through the chain. Handlers become the processors that can examine, modify, execute, or pass commands. This enables:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
// Command represents the requestinterface Command { readonly type: string; execute(): Promise<CommandResult>; getMetadata(): CommandMetadata;} interface CommandMetadata { userId: string; timestamp: Date; priority: 'low' | 'normal' | 'high';} interface CommandResult { success: boolean; data?: unknown; error?: Error;} // Handler in the chaininterface CommandHandler { setNext(handler: CommandHandler): CommandHandler; handle(command: Command): Promise<CommandResult>;} abstract class BaseHandler implements CommandHandler { private nextHandler: CommandHandler | null = null; setNext(handler: CommandHandler): CommandHandler { this.nextHandler = handler; return handler; } async handle(command: Command): Promise<CommandResult> { const result = await this.process(command); // If we handled it or no next handler, return result if (result !== null || !this.nextHandler) { return result ?? { success: false, error: new Error('Unhandled command') }; } // Pass to next handler return this.nextHandler.handle(command); } protected abstract process(command: Command): Promise<CommandResult | null>;} // Authentication handler - validates user permissionsclass AuthenticationHandler extends BaseHandler { private authorizedUsers = new Set(['admin', 'user1', 'user2']); protected async process(command: Command): Promise<CommandResult | null> { const { userId } = command.getMetadata(); if (!this.authorizedUsers.has(userId)) { return { success: false, error: new Error(`Unauthorized: ${userId}`) }; } console.log(`[Auth] User ${userId} authorized`); return null; // Pass to next handler }} // Validation handler - validates command structureclass ValidationHandler extends BaseHandler { protected async process(command: Command): Promise<CommandResult | null> { // Validate command-specific requirements if (!command.type) { return { success: false, error: new Error('Invalid command: missing type') }; } console.log(`[Validation] Command ${command.type} validated`); return null; // Pass to next handler }} // Logging handler - logs all commandsclass LoggingHandler extends BaseHandler { protected async process(command: Command): Promise<CommandResult | null> { const meta = command.getMetadata(); console.log(`[Log] ${meta.timestamp.toISOString()} - User: ${meta.userId} - Command: ${command.type}`); return null; // Always pass to next handler }} // Rate limiting handler - prevents abuseclass RateLimitHandler extends BaseHandler { private requestCounts = new Map<string, number>(); private readonly limit = 100; protected async process(command: Command): Promise<CommandResult | null> { const { userId } = command.getMetadata(); const count = (this.requestCounts.get(userId) || 0) + 1; if (count > this.limit) { return { success: false, error: new Error('Rate limit exceeded') }; } this.requestCounts.set(userId, count); return null; // Pass to next handler }} // Executor handler - actually runs the commandclass ExecutorHandler extends BaseHandler { protected async process(command: Command): Promise<CommandResult | null> { try { console.log(`[Executor] Executing ${command.type}...`); const result = await command.execute(); console.log(`[Executor] ${command.type} completed`); return result; } catch (error) { return { success: false, error: error as Error }; } }} // Build the pipelinefunction createCommandPipeline(): CommandHandler { const auth = new AuthenticationHandler(); const validation = new ValidationHandler(); const logging = new LoggingHandler(); const rateLimit = new RateLimitHandler(); const executor = new ExecutorHandler(); // Chain: Auth → Validation → Logging → RateLimit → Execute auth .setNext(validation) .setNext(logging) .setNext(rateLimit) .setNext(executor); return auth;} // Usageconst pipeline = createCommandPipeline();const result = await pipeline.handle(someCommand);Pipeline Benefits:
| Concern | Handler | Responsibility |
|---|---|---|
| Security | AuthenticationHandler | Verify user permissions |
| Correctness | ValidationHandler | Validate command structure |
| Observability | LoggingHandler | Audit trail |
| Protection | RateLimitHandler | Prevent abuse |
| Execution | ExecutorHandler | Run the command |
Each cross-cutting concern has a dedicated handler. Adding a new concern (caching, metrics, transformation) requires only adding a new handler to the chain.
Not all pattern combinations are beneficial. Some combinations create conflicts, redundancy, or unnecessary complexity. Recognizing anti-patterns in combination is as important as knowing good combinations.
Before combining patterns, ask: 'What distinct requirement does each pattern address?' If you can't articulate a unique need for each pattern, the combination is likely over-engineering. Patterns should solve problems, not demonstrate knowledge.
We've explored how behavioral patterns work together in real architectures. Pattern combination is where design pattern knowledge transforms into architectural skill. Here are the essential insights:
What's Next:
With a solid understanding of pattern selection and combination, the final page applies these skills to real-world examples. You'll see how production systems use behavioral patterns to solve complex design challenges.
You now understand how behavioral patterns synergize in multi-pattern architectures. This knowledge enables you to design cohesive systems where patterns reinforce each other's strengths. The next page brings these concepts to life with real-world examples.