Loading learning content...
The 23 patterns in the Gang of Four book are not the complete universe of software design solutions. They're not even the complete universe of patterns. Treating the GoF catalog as a menu from which you must order limits your design options unnecessarily.
The Real Design Space
For any given problem, your actual solution options include:
Experienced engineers move fluidly across this spectrum. They don't force problems into pattern-shaped boxes; they select from the full range of design tools.
By the end of this page, you will understand when and how to apply pattern variants, combine patterns effectively, leverage non-GoF patterns, and recognize when simpler solutions outperform pattern-based solutions. You'll develop judgment for selecting the right level of design sophistication.
Patterns are templates, not blueprints. The canonical form documented in books rarely matches real-world implementations exactly. Skilled engineers adapt patterns to fit context while preserving the pattern's essential structure and intent.
What Can Be Modified
Pattern modifications fall into categories:
Structural Simplifications
Implementation Variations
Scope Adjustments
Example: Simplified Strategy
Canonical Strategy has:
Simplification for Modern Languages:
// Classic Strategy
interface DiscountStrategy {
calculate(price: number): number;
}
class Order {
constructor(
private discountStrategy: DiscountStrategy
) {}
getTotal(price: number): number {
return this.discountStrategy
.calculate(price);
}
}
Simplified Version:
// Functional-style Strategy
type DiscountFn =
(price: number) => number;
function getTotal(
price: number,
discount: DiscountFn
): number {
return discount(price);
}
// Usage
const noDiscount: DiscountFn =
(p) => p;
const tenPercent: DiscountFn =
(p) => p * 0.9;
getTotal(100, tenPercent); // 90
Same pattern essence:
Fewer moving parts:
Modifications should preserve what makes the pattern work. Strategy's essence is algorithm encapsulation and interchangeability. The simplified version preserves this. If your modification removes the essence, you no longer have the pattern—you have something different that should be evaluated independently.
Complex problems often require multiple patterns working together. Pattern combination is an advanced skill that requires understanding how patterns interact.
Common Pattern Combinations
| Combination | How They Interact | Use When |
|---|---|---|
| Strategy + Factory | Factory creates appropriate strategy based on context/configuration | Strategy selection logic is complex, isolated from client |
| Decorator + Composite | Decorators can wrap composites; composites can contain decorated objects | Need both tree structure and dynamic behavior modification |
| Observer + Mediator | Mediator uses observer internally to decouple colleague notifications | Complex multi-object coordination with event-driven triggers |
| Command + Memento | Command saves memento before executing for undo support | Need command pattern benefits plus state restoration |
| State + Singleton | State objects are stateless singletons; context switches between shared instances | Many context objects, limited state variety, memory optimization |
| Abstract Factory + Builder | Factory returns builder; client uses builder to construct product | Complex product families with stepwise construction |
Example: Strategy + Factory Combination
Problem: Payment processing with many providers. Strategy fits, but selecting the right strategy requires complex logic (user preferences, regional availability, transaction type, fallback rules).
Strategy alone pushes selection complexity to clients.
Strategy + Factory encapsulates selection:
// Strategy (payment method)
interface PaymentProcessor {
process(amount: number, details: PaymentDetails): Promise<Result>;
}
// ConcreteStrategies
class StripeProcessor implements PaymentProcessor { /* ... */ }
class PayPalProcessor implements PaymentProcessor { /* ... */ }
class ApplePayProcessor implements PaymentProcessor { /* ... */ }
// Factory (encapsulates selection logic)
class PaymentProcessorFactory {
constructor(
private featureFlags: FeatureFlags,
private geoService: GeoService,
private userPreferences: UserPreferencesService
) {}
async createProcessor(
user: User,
order: Order
): Promise<PaymentProcessor> {
// Complex selection logic hidden here
const region = await this.geoService.getRegion(user);
const preferred = await this.userPreferences.getPaymentPreference(user);
if (this.featureFlags.isEnabled('apple-pay', region) && preferred === 'apple') {
return new ApplePayProcessor(this.getApplePayConfig(region));
}
if (order.amount > 10000) {
return new StripeProcessor(this.getHighValueConfig());
}
// ... more rules
return new PayPalProcessor(this.getDefaultConfig());
}
}
// Client is simple
class CheckoutService {
constructor(private factory: PaymentProcessorFactory) {}
async checkout(user: User, order: Order) {
const processor = await this.factory.createProcessor(user, order);
return processor.process(order.total, order.paymentDetails);
}
}
Each pattern does its job:
Don't hunt for pattern combinations. Let the problem drive you. If you implement Strategy and find selection logic becoming complex, that's when Factory becomes relevant. Appropriate combinations emerge from actual needs.
The Gang of Four book published 23 patterns in 1994. The pattern community has documented hundreds more covering domains the GoF book didn't address.
Important Pattern Catalogs
Selected Non-GoF Patterns You Should Know
Null Object Pattern
Problem: Code littered with null checks. Special case handling scattered everywhere.
Solution: Provide a do-nothing object that satisfies the interface. Replace null with this object.
// Instead of null checks everywhere
interface Logger {
log(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string) { console.log(message); }
}
class NullLogger implements Logger {
log(message: string) { /* do nothing */ }
}
// Usage: no null checks needed
class Service {
constructor(private logger: Logger = new NullLogger()) {}
doWork() {
this.logger.log('Starting'); // works with or without real logger
// ...
}
}
Repository Pattern
Problem: Domain logic coupled to data access details (SQL queries, ORM specifics).
Solution: Abstract data access behind a collection-like interface.
Circuit Breaker Pattern
Problem: Failing external dependency brings down entire system through cascading timeouts.
Solution: Wrap external calls; track failures; "open" circuit after threshold; fail fast while open; periodically probe to recover.
Pattern knowledge accumulates over your career. Start with GoF for foundational object patterns. Add POSA and Fowler for enterprise/architectural patterns. Add EIP and microservices patterns as you work on distributed systems. Each catalog is a thinking toolkit for its domain.
Each programming language and framework has idioms—conventional ways of solving problems that may not rise to the level of formal patterns but represent community wisdom.
When Idioms Replace Patterns
Modern languages often have features that make certain patterns unnecessary or transform them into simpler idioms:
Singleton → Module/Object Constant
JavaScript ES6+ modules are singletons by default:
// config.ts
export const config = {
apiUrl: process.env.API_URL,
timeout: 5000,
};
// Every import gets the same object instance
import { config } from './config';
No private constructor, no static getInstance. The module system provides singleton semantics.
Strategy → Higher-Order Functions
Functional languages make Strategy nearly invisible:
const sort = (arr: number[], compare: (a: number, b: number) => number) =>
[...arr].sort(compare);
const ascending = (a: number, b: number) => a - b;
const descending = (a: number, b: number) => b - a;
sort([3, 1, 2], ascending); // [1, 2, 3]
sort([3, 1, 2], descending); // [3, 2, 1]
Observer → Native Event Systems
Many frameworks provide observable/event primitives:
// Angular/RxJS
@Component({ /* ... */ })
class StockComponent {
constructor(private stockService: StockService) {}
ngOnInit() {
this.stockService.priceChanges$.subscribe(
price => this.updateDisplay(price)
);
}
}
Decorator → Language Decorators/Annotations
TypeScript/Python decorators provide pattern-like behavior:
// TypeScript class decorators
@Injectable()
@LogMethodCalls()
class UserService {
@Memoize({ ttl: 60 })
getUser(id: string): User {
// expensive operation
}
}
Before implementing a textbook pattern, check if your language/framework has an idiomatic solution. Using language features is typically cleaner than implementing classic patterns literally. But understand the pattern so you recognize the idiom.
The most overlooked alternative to patterns is the humble simple solution. Patterns add abstraction and indirection. Sometimes the problem doesn't warrant that cost.
Signs That Simple Is Better
Over-Engineered Pattern
// 5 files for formatting dates
// DateFormatStrategy.ts
interface DateFormatStrategy {
format(date: Date): string;
}
// ISOFormatStrategy.ts
class ISOFormatStrategy
implements DateFormatStrategy {
format(date: Date): string {
return date.toISOString();
}
}
// PrettyFormatStrategy.ts
class PrettyFormatStrategy
implements DateFormatStrategy {
format(date: Date): string {
return date.toLocaleDateString();
}
}
// DateFormatter.ts
class DateFormatter {
constructor(
private strategy: DateFormatStrategy
) {}
format(d: Date) {
return this.strategy.format(d);
}
}
// DateFormatterFactory.ts
// ... more complexity
Simple Solution
// 1 file, clear and done
type DateFormat = 'iso' | 'pretty';
function formatDate(
date: Date,
format: DateFormat
): string {
switch (format) {
case 'iso':
return date.toISOString();
case 'pretty':
return date.toLocaleDateString();
}
}
// Usage
formatDate(new Date(), 'iso');
formatDate(new Date(), 'pretty');
Why simple wins here:
The Pattern Tax
Every pattern incurs costs:
Patterns pay for these costs through benefits like extensibility, testability, and maintainability. When benefits don't exceed costs, the pattern is net negative.
The YAGNI Consideration
You Aren't Gonna Need It applies to patterns. If you're adding Strategy because you might need more strategies someday, you're speculating. Start simple; refactor to patterns when actual needs emerge. It's easier to extract a pattern from working code than to remove an unnecessary pattern.
Using patterns to demonstrate knowledge rather than solve problems is a form of technical debt. The goal is working, maintainable software—not an impressive-looking class diagram.
Sometimes your problem genuinely doesn't fit any catalog pattern. This is especially true for domain-specific problems where business rules don't map to general object interaction patterns.
When Custom Design Is Appropriate
Designing Custom Solutions
Use pattern principles even when not applying specific patterns:
1. Single Responsibility Each component does one thing well
2. Separation of Concerns Distinct concerns go in distinct components
3. Dependency Inversion Depend on abstractions, not concretions
4. Interface Segregation Clients shouldn't depend on methods they don't use
5. Composition Over Inheritance Prefer composing objects to inheriting behavior
Example: Custom Event Processing Design
Problem: Events arrive from multiple sources. Each event type needs different validation, enrichment, and routing. Events can fail at any stage and need different retry strategies. Volume is high; latency matters.
Why Patterns Don't Fit Perfectly:
Custom Design:
// Custom pipeline design borrowing from multiple patterns
interface EventHandler<T, R> {
canHandle(event: T): boolean;
handle(event: T): Promise<R>;
}
interface PipelineStage<T, R> {
process(event: T): Promise<StageResult<R>>;
}
type StageResult<R> =
| { status: 'continue'; result: R }
| { status: 'skip'; reason: string }
| { status: 'retry'; delay: number }
| { status: 'fail'; error: Error };
class EventPipeline<T> {
private stages: PipelineStage<T, T>[] = [];
addStage(stage: PipelineStage<T, T>): this {
this.stages.push(stage);
return this;
}
async process(event: T): Promise<PipelineResult<T>> {
let current = event;
for (const stage of this.stages) {
const result = await stage.process(current);
switch (result.status) {
case 'continue':
current = result.result;
break;
case 'skip':
return { status: 'skipped', reason: result.reason };
case 'retry':
// Queue for retry with delay
return { status: 'deferred', delay: result.delay };
case 'fail':
return { status: 'failed', error: result.error };
}
}
return { status: 'completed', result: current };
}
}
This borrows ideas from:
But it's not any single pattern. It's a custom design that fits the specific problem.
When you create custom designs, document them as if they were patterns. State the problem, forces, solution, and consequences. This helps future maintainers understand your thinking and enables reuse if the pattern proves valuable.
Choosing between catalog patterns, variants, combinations, and custom solutions requires systematic thinking. Here's a decision framework:
Evaluation Criteria for Each Alternative
| Alternative | Best When | Caution When |
|---|---|---|
| Simple solution | 2-3 variants, local scope, stable requirements | Problem will grow, cross boundaries, or needs testing isolation |
| Catalog pattern as-is | Classic problem, all fit criteria pass, team knows pattern | Your context has unusual constraints that classic form doesn't address |
| Pattern variant | Pattern essence applies but canonical form is heavy for context | Modifications remove key pattern benefits or confuse readers |
| Pattern combination | Problem decomposes into sub-problems matching different patterns | Combined complexity exceeds individual pattern complexity |
| Domain pattern | Your domain has established pattern literature | You're forcing a pattern from wrong domain |
| Language idiom | Language/framework provides elegant built-in solution | Idiom hides important behavior that needs visibility |
| Custom design | Unique constraints, no catalog pattern fits, team has design skill | You're reinventing a wheel that exists in catalogs you don't know |
When alternatives are close in fit, choose the simpler one. Simplicity reduces cognitive load, maintenance burden, and future change cost. You can always add complexity later; removing it is harder.
Skilled engineers draw from a full palette of design options, not just the 23 GoF patterns. Knowing when and how to use alternatives makes you a more effective designer.
What's Next:
The final page in this module provides the Decision Checklist—a comprehensive set of questions to work through before finalizing any pattern selection. It consolidates everything you've learned into a practical evaluation tool.
You now understand the full range of design alternatives available to you. From simple solutions to pattern combinations to custom designs, you can select the right tool for each problem rather than forcing problems into pattern shapes. Next, we'll synthesize everything into a practical decision checklist.