Loading learning content...
Among all the design patterns in the Gang of Four catalog, the Strategy Pattern holds a special place in the practice of software design. It isn't merely a clever technique—it's the primary mechanism through which experienced engineers achieve the Open/Closed Principle in real-world systems.
When we say a system should be "open for extension, closed for modification," we're articulating an ideal. The Strategy Pattern is how that ideal becomes reality. It transforms abstract principle into concrete implementation, providing a repeatable, proven approach to building systems that can grow without destabilization.
This isn't coincidental. The Strategy Pattern was designed precisely to address the problem OCP solves: how do we add new behavior without touching existing, working code? Understanding this deep connection transforms both concepts from academic curiosities into practical engineering tools.
By the end of this page, you will understand how the Strategy Pattern serves as an OCP enabler, why this relationship is fundamental rather than incidental, and how to recognize opportunities to apply Strategy for OCP compliance in your own designs.
Before we explore Strategy's relationship with OCP, we need a rigorous understanding of the pattern itself. The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
Let's dissect this definition:
"Defines a family of algorithms" — Strategy groups related behaviors that serve the same purpose but differ in implementation. Sorting, for example, can be accomplished through QuickSort, MergeSort, or HeapSort. These form a "family" because they all achieve the same goal (sorted output) through different means.
"Encapsulates each one" — Each algorithm is isolated in its own class, hidden behind a common interface. The implementation details of each approach are invisible to calling code. This isn't just information hiding—it's behavioral packaging.
"Makes them interchangeable" — Any member of the strategy family can be substituted for any other at runtime. The calling code doesn't know (and doesn't care) which specific strategy is executing. This is the key that unlocks OCP.
The Strategy Pattern works because it separates WHAT you want done (the strategy interface) from HOW it's done (the concrete strategy implementations). This separation is precisely what OCP requires: a stable 'what' that can accommodate new 'hows' without modification.
The Three Participants:
Every Strategy Pattern implementation involves three key participants:
Strategy Interface — The abstraction that defines what operation needs to be performed. This is the stable contract that never changes once established.
Concrete Strategies — Implementations of the strategy interface, each providing a different algorithm. These are the extension points where new behavior is added.
Context — The class that uses strategies. It maintains a reference to a strategy object and delegates work to it. The context is written against the strategy interface, not concrete implementations.
This triangular relationship creates a structure where the context can accept any strategy, including strategies that don't exist yet. The context is closed to modification but open to extension through new strategy implementations.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
// The Strategy Interface - defines WHAT can be done// This is the stable abstraction - it should rarely changeinterface PaymentStrategy { processPayment(amount: number): PaymentResult; validatePayment(details: PaymentDetails): ValidationResult; getPaymentMethodName(): string;} // A Concrete Strategy - defines HOW it's done for credit cardsclass CreditCardStrategy implements PaymentStrategy { private cardProcessor: CardProcessorService; constructor(cardProcessor: CardProcessorService) { this.cardProcessor = cardProcessor; } processPayment(amount: number): PaymentResult { // Credit card-specific processing logic const authorization = this.cardProcessor.authorize(amount); if (authorization.approved) { return this.cardProcessor.capture(authorization.id, amount); } return PaymentResult.declined(authorization.reason); } validatePayment(details: PaymentDetails): ValidationResult { // Credit card-specific validation return this.cardProcessor.validateCard(details); } getPaymentMethodName(): string { return "Credit Card"; }} // Another Concrete Strategy - defines HOW for PayPalclass PayPalStrategy implements PaymentStrategy { private paypalClient: PayPalClient; constructor(paypalClient: PayPalClient) { this.paypalClient = paypalClient; } processPayment(amount: number): PaymentResult { // PayPal-specific processing logic return this.paypalClient.processPayment({ amount, currency: "USD", intent: "sale" }); } validatePayment(details: PaymentDetails): ValidationResult { return this.paypalClient.validateAccount(details); } getPaymentMethodName(): string { return "PayPal"; }} // The Context - uses strategies without knowing concrete typesclass CheckoutService { private paymentStrategy: PaymentStrategy; // Strategy is injected - the context doesn't create it constructor(paymentStrategy: PaymentStrategy) { this.paymentStrategy = paymentStrategy; } // Strategy can be changed at runtime setPaymentStrategy(strategy: PaymentStrategy): void { this.paymentStrategy = strategy; } processOrder(order: Order): OrderResult { // Validate using current strategy const validation = this.paymentStrategy.validatePayment( order.paymentDetails ); if (!validation.isValid) { return OrderResult.failed(validation.errors); } // Process payment using current strategy const result = this.paymentStrategy.processPayment(order.total); if (result.succeeded) { return OrderResult.completed( order, this.paymentStrategy.getPaymentMethodName() ); } return OrderResult.failed(result.errorMessage); }}Now we can examine the precise mechanism by which Strategy enables OCP. The connection isn't superficial—it's structural. The very architecture of the Strategy Pattern is designed to create OCP-compliant systems.
The Closed Part: The Context Class
The context class (CheckoutService in our example) is the "closed" component. Once written and tested, it doesn't need to change when new payment methods are added. Consider what must remain stable:
This stability is precious. The CheckoutService has been tested, debugged, deployed, and is handling real transactions. OCP says we shouldn't touch it when requirements change. Strategy makes that possible.
The Open Part: Concrete Strategies
The "open" component is the set of concrete strategies. When the business decides to support Apple Pay, we don't modify CheckoutService. We create a new class:
1234567891011121314151617181920212223242526272829303132
// NEW REQUIREMENT: Support Apple Pay// Notice: We're ADDING code, not CHANGING existing code class ApplePayStrategy implements PaymentStrategy { private applePayService: ApplePayService; constructor(applePayService: ApplePayService) { this.applePayService = applePayService; } processPayment(amount: number): PaymentResult { // Apple Pay-specific processing const token = this.applePayService.getPaymentToken(); return this.applePayService.processPayment(token, amount); } validatePayment(details: PaymentDetails): ValidationResult { return this.applePayService.validateToken(details.applePayToken); } getPaymentMethodName(): string { return "Apple Pay"; }} // Usage - existing code unchanged, new capability addedconst applePayStrategy = new ApplePayStrategy(applePayService);const checkoutService = new CheckoutService(applePayStrategy); // The CheckoutService code is UNCHANGED// It works with ApplePayStrategy exactly as it does with any other strategyconst result = checkoutService.processOrder(order);The Mathematical Certainty
This isn't just a convention or best practice—it's a structural guarantee. The CheckoutService cannot be affected by new strategies because:
This is OCP at its purest. The system is architecturally incapable of requiring context modification for new strategies. The "closed" property isn't maintained by discipline—it's enforced by structure.
In production systems, 'not modifying existing code' isn't just aesthetic preference—it's risk management. Every modification to CheckoutService requires retesting, revalidation, and carries the risk of regression. With Strategy + OCP, new payment methods carry zero risk to existing payment processing.
| Component | Changes When Adding Feature? | Risk Level | OCP Role |
|---|---|---|---|
| CheckoutService (Context) | No — unchanged | Zero regression risk | Closed for modification |
| PaymentStrategy (Interface) | No — stable contract | Zero risk | Stable abstraction |
| New strategy class | N/A — new code | Isolated new code | Open for extension |
| Strategy instantiation site | Yes — creates new strategy | Localized, minimal | Configuration change |
Let's examine the architectural anatomy of how Strategy achieves OCP. Understanding this structure helps you apply the pattern correctly and avoid common pitfalls that break OCP compliance.
Layer 1: The Stable Abstraction Layer
At the core is the strategy interface. This abstraction layer is the foundation of the entire OCP structure. Its stability is paramount:
interface NotificationStrategy {
send(recipient: string, message: Message): SendResult;
validateRecipient(recipient: string): ValidationResult;
supportsRichContent(): boolean;
}
This interface represents a commitment. Every current and future notification strategy must conform to this contract. The interface should be:
Layer 2: The Context Layer
The context layer contains classes that USE strategies. These classes are written once, tested thoroughly, and then left untouched:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
class NotificationService { // Context holds a reference to the abstraction, NOT a concrete type private strategy: NotificationStrategy; private logger: Logger; private metrics: MetricsCollector; constructor( strategy: NotificationStrategy, logger: Logger, metrics: MetricsCollector ) { this.strategy = strategy; this.logger = logger; this.metrics = metrics; } // All context methods work through the strategy interface // They have no knowledge of concrete strategy implementations async notifyUser(userId: string, message: Message): Promise<boolean> { const startTime = Date.now(); // Resolve recipient from user ID const recipient = await this.resolveRecipient(userId); // Validate through strategy - might be email, SMS, push, etc. const validation = this.strategy.validateRecipient(recipient); if (!validation.isValid) { this.logger.warn(`Invalid recipient: ${validation.reason}`); return false; } // Handle rich content based on strategy capability const finalMessage = this.strategy.supportsRichContent() ? message : message.toPlainText(); // Send through strategy const result = this.strategy.send(recipient, finalMessage); // Record metrics this.metrics.recordNotification({ success: result.succeeded, duration: Date.now() - startTime, type: this.strategy.constructor.name }); return result.succeeded; } // The context can be sophisticated without knowing concrete strategies async notifyUserWithFallback( userId: string, message: Message, fallbackStrategy: NotificationStrategy ): Promise<boolean> { const success = await this.notifyUser(userId, message); if (!success) { this.logger.info("Primary notification failed, trying fallback"); // Temporarily swap strategy for fallback const originalStrategy = this.strategy; this.strategy = fallbackStrategy; const fallbackSuccess = await this.notifyUser(userId, message); this.strategy = originalStrategy; return fallbackSuccess; } return true; }}Layer 3: The Extension Layer
The extension layer contains concrete strategy implementations. This is where all new code lives when requirements change. Each strategy encapsulates one complete approach:
Layer 4: The Composition Layer
The composition layer (often in application startup or dependency injection configuration) is where strategies are instantiated and wired to contexts. This is the only place that "knows" about concrete types.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
// ========== EXTENSION LAYER ==========// Each strategy fully encapsulates one approach class EmailNotificationStrategy implements NotificationStrategy { private emailClient: EmailClient; constructor(emailClient: EmailClient) { this.emailClient = emailClient; } send(recipient: string, message: Message): SendResult { return this.emailClient.sendEmail({ to: recipient, subject: message.subject, body: message.toHtml(), headers: { "X-Priority": message.priority } }); } validateRecipient(recipient: string): ValidationResult { const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/; return emailRegex.test(recipient) ? ValidationResult.valid() : ValidationResult.invalid("Invalid email format"); } supportsRichContent(): boolean { return true; // Email supports HTML }} class SMSNotificationStrategy implements NotificationStrategy { private smsGateway: SMSGateway; constructor(smsGateway: SMSGateway) { this.smsGateway = smsGateway; } send(recipient: string, message: Message): SendResult { // SMS has length limits - truncate if necessary const smsBody = message.toPlainText().substring(0, 160); return this.smsGateway.sendSMS(recipient, smsBody); } validateRecipient(recipient: string): ValidationResult { const phoneRegex = /^+?[1-9]d{9,14}$/; return phoneRegex.test(recipient) ? ValidationResult.valid() : ValidationResult.invalid("Invalid phone number"); } supportsRichContent(): boolean { return false; // SMS is plain text only }} // ADDING NEW CAPABILITY: Push notifications// Notice: No changes to NotificationService, EmailStrategy, or SMSStrategyclass PushNotificationStrategy implements NotificationStrategy { private pushService: PushNotificationService; constructor(pushService: PushNotificationService) { this.pushService = pushService; } send(recipient: string, message: Message): SendResult { return this.pushService.sendPush({ deviceToken: recipient, title: message.subject, body: message.toPlainText(), data: message.metadata, badge: 1 }); } validateRecipient(recipient: string): ValidationResult { // Device tokens have specific format return recipient.length >= 32 ? ValidationResult.valid() : ValidationResult.invalid("Invalid device token"); } supportsRichContent(): boolean { return true; // Push supports rich content }} // ========== COMPOSITION LAYER ==========// This is where concrete types are known and wired together class ApplicationBootstrap { configureNotifications(container: DependencyContainer): void { // Register all strategies container.register(EmailNotificationStrategy, { useFactory: (c) => new EmailNotificationStrategy( c.resolve(EmailClient) ) }); container.register(SMSNotificationStrategy, { useFactory: (c) => new SMSNotificationStrategy( c.resolve(SMSGateway) ) }); container.register(PushNotificationStrategy, { useFactory: (c) => new PushNotificationStrategy( c.resolve(PushNotificationService) ) }); // Configure which strategy each service uses container.register(NotificationService, { useFactory: (c) => new NotificationService( c.resolve(EmailNotificationStrategy), // Primary c.resolve(Logger), c.resolve(MetricsCollector) ) }); }}While multiple patterns enable OCP (Template Method, Decorator, Factory), Strategy holds a privileged position as the default choice. Understanding why reveals deep principles about software design.
Reason 1: Direct Expression of Behavioral Variation
OCP's core need is handling behavioral variation without modification. Strategy addresses this directly:
This directness makes Strategy easy to understand, explain, and maintain. When someone asks "how do I add a new payment method?", the answer is clear: implement PaymentStrategy.
Reason 2: Composition-Based Architecture
Strategy uses composition (HAS-A) rather than inheritance (IS-A). The context has a strategy rather than being a specialized type. This composition-based approach offers significant advantages:
Reason 3: Perfect Alignment with Dependency Injection
Strategy and dependency injection are natural partners. Strategies are injected into contexts, making the pattern work seamlessly with modern DI frameworks:
// Constructor injection - DI container resolves strategy
constructor(@Inject(PAYMENT_STRATEGY) private strategy: PaymentStrategy)
This integration means Strategy-based OCP fits naturally into enterprise application architecture without requiring specialized wiring or lifecycle management.
In practice, Strategy Pattern handles about 80% of OCP needs. Other patterns (Template Method, Decorator, Abstract Factory) are valuable for specific situations, but Strategy remains the default choice. When you see behavioral variation, think Strategy first.
Mastering Strategy for OCP requires recognizing situations where the pattern applies. These recognition skills separate experienced designers from those who apply patterns mechanically.
Signal 1: Conditional Behavior Based on Type
When you see if-else chains or switch statements selecting behavior based on some type or category, you're looking at a Strategy opportunity:
// OCP VIOLATION — Adding behavior requires modification
function calculateDiscount(customerType: string, amount: number): number {
if (customerType === 'regular') {
return amount * 0.05;
} else if (customerType === 'premium') {
return amount * 0.10;
} else if (customerType === 'vip') {
return amount * 0.20;
}
// Adding 'platinum' customer requires changing this function
return 0;
}
The presence of if-else chains based on type is almost always a signal for strategies.
Signal 2: Comments Indicating Future Variation
Comments like "add new algorithms here" or "extend for additional providers" are explicit signals:
// TODO: Support additional payment gateways as needed
function processPayment(gateway: string, amount: number) { ... }
Signal 3: Algorithms That Differ by Context
When the same operation needs different implementations based on deployment context, environment, or configuration:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// BEFORE: OCP Violation - Environment-based algorithm selectionclass RateLimiter { checkLimit(userId: string): boolean { if (process.env.NODE_ENV === "development") { return true; // No limiting in dev } else if (process.env.RATE_LIMIT_TYPE === "redis") { return this.redisCheck(userId); } else if (process.env.RATE_LIMIT_TYPE === "memory") { return this.memoryCheck(userId); } else if (process.env.RATE_LIMIT_TYPE === "distributed") { return this.distributedCheck(userId); } return true; }} // AFTER: OCP Compliant with Strategyinterface RateLimitStrategy { checkLimit(userId: string): boolean; recordRequest(userId: string): void;} class NoLimitStrategy implements RateLimitStrategy { checkLimit(userId: string): boolean { return true; } recordRequest(userId: string): void { /* no-op */ }} class RedisRateLimitStrategy implements RateLimitStrategy { constructor(private redis: RedisClient, private windowMs: number) {} checkLimit(userId: string): boolean { const count = this.redis.get(`ratelimit:${userId}`); return count < 100; } recordRequest(userId: string): void { this.redis.incr(`ratelimit:${userId}`); this.redis.expire(`ratelimit:${userId}`, this.windowMs / 1000); }} class DistributedRateLimitStrategy implements RateLimitStrategy { constructor(private coordinator: DistributedCoordinator) {} checkLimit(userId: string): boolean { return this.coordinator.acquirePermit(userId, "api-request"); } recordRequest(userId: string): void { this.coordinator.recordUsage(userId, "api-request"); }} // Context is now closed to modificationclass RateLimiter { constructor(private strategy: RateLimitStrategy) {} checkLimit(userId: string): boolean { return this.strategy.checkLimit(userId); } recordRequest(userId: string): void { this.strategy.recordRequest(userId); }} // Adding new rate limiting approaches requires only new strategiesclass TokenBucketStrategy implements RateLimitStrategy { // New implementation, existing code untouched}Strategy/OCP adds complexity through additional classes and interfaces. If there's only one algorithm and no reasonable expectation of variation, Strategy is premature. Apply OCP to likely variation points, not everywhere. YAGNI (You Ain't Gonna Need It) tempers OCP enthusiasm.
OCP can also be achieved through inheritance (Template Method pattern or simple subclassing). However, Strategy's composition-based approach is usually superior. Understanding why reinforces both patterns.
Inheritance-Based OCP:
// OCP through inheritance
abstract class OrderProcessor {
processOrder(order: Order): OrderResult {
this.validate(order);
const total = this.calculateTotal(order); // Hook for variation
const discount = this.applyDiscount(order); // Hook for variation
return this.finalize(order, total - discount);
}
protected abstract calculateTotal(order: Order): number;
protected abstract applyDiscount(order: Order): number;
}
// Extension through subclassing
class RetailOrderProcessor extends OrderProcessor {
protected calculateTotal(order: Order): number { /* retail rules */ }
protected applyDiscount(order: Order): number { /* retail discounts */ }
}
class WholesaleOrderProcessor extends OrderProcessor {
protected calculateTotal(order: Order): number { /* wholesale rules */ }
protected applyDiscount(order: Order): number { /* wholesale discounts */ }
}
This achieves OCP—new order processors extend rather than modify. But it has problems:
| Concern | Inheritance Approach | Strategy Approach |
|---|---|---|
| Combining variations | Cannot combine—stuck with one subclass | Can compose multiple strategies freely |
| Runtime flexibility | Class chosen at instantiation time | Strategy swappable at any time |
| Hierarchy growth | Deep hierarchies with multiple dimensions | Flat, independent strategy sets |
| Testing | Must test through base class | Strategies testable in isolation |
| Independence | All variations coupled to base class | Strategies independent of each other |
| Multiple aspects | Leads to explosion (retail+rush, wholesale+standard) | Each aspect has separate strategies |
The Combinatorial Explosion Problem:
Consider an order processor with two dimensions of variation:
With inheritance, you need 3 × 3 = 9 subclasses:
Add a third dimension (domestic/international shipping), and you have 18 classes.
With Strategy, you have:
Total: 8 classes, composable in 18 combinations. More maintainable, more flexible, truly OCP-compliant across all dimensions.
Inheritance-based OCP (Template Method) is appropriate when: (1) The algorithm structure is truly fixed and only steps vary, (2) There's only one dimension of variation, (3) Runtime swapping isn't needed, and (4) You're building a framework where subclassing is expected. For application code, prefer Strategy.
We've explored the deep connection between the Strategy Pattern and the Open/Closed Principle. This relationship isn't coincidental—Strategy was designed to solve the very problem OCP describes.
Let's consolidate the key insights:
What's Next:
Now that we understand Strategy as an OCP enabler, we'll explore the mechanics of injecting behavior variations—how strategies are created, selected, and swapped at runtime to provide flexible, extensible systems.
You now understand why the Strategy Pattern is the primary mechanism for achieving OCP in practice. This knowledge transforms OCP from an abstract principle into a concrete implementation approach. Next, we'll dive into the mechanics of injecting behavior variations.