Loading learning content...
You've learned to recognize OCP violations—if-else chains, instanceof checks, and switch statements on type. You understand why they're problematic and the architectural costs they impose. Now comes the crucial skill: systematically refactoring these violations into OCP-compliant, extensible designs.
This isn't about applying fixes randomly until the code looks different. It's about understanding why specific refactoring patterns work, when to apply each technique, and how to execute transformations safely in production codebases. A principal engineer doesn't just make code 'better'—they can articulate the design principles guiding each decision and foresee the implications of each change.
The Refactoring Mindset:
Effective refactoring requires shifting from 'fixing violations' to 'designing for extension.' Rather than asking 'How do I eliminate this switch?', ask 'What extension points should this design provide?' The destination matters as much as the departure point.
By the end of this page, you will master systematic refactoring techniques for eliminating OCP violations, understand which design patterns apply to which violation types, execute step-by-step transformations with safety guarantees, handle complex refactoring scenarios including incremental migration, and develop intuition for designing OCP-compliant systems from the start.
Before diving into specific techniques, let's establish a systematic framework for approaching OCP refactorings. This framework applies regardless of the specific violation type.
The Five-Phase Approach:
Critical Safety Practices:
Refactoring OCP violations in production code requires discipline:
| Practice | Why It Matters |
|---|---|
| Comprehensive test coverage before refactoring | Refactoring changes structure, not behavior. Tests verify behavior preservation. |
| One transformation step at a time | Large changes obscure bugs. Small steps allow bisection if issues arise. |
| Maintain parallel execution temporarily | Run new and old code paths simultaneously; compare results before removing old code. |
| Feature flags for gradual rollout | Enable new implementation for subset of users; monitor for issues before full rollout. |
| Incremental migration over big bang | Transform one switch at a time; don't try to fix everything simultaneously. |
A common mistake is to refactor only the most visible switch statement while leaving parallel switches scattered elsewhere. This creates inconsistency—some code uses polymorphism while other code still checks types. Complete the migration before considering the refactoring 'done.'
This fundamental refactoring pattern directly addresses if-else chains and switch statements by moving variant behavior into separate classes. It's the most common and widely applicable OCP refactoring technique.
Complete Before/After Example:
Let's walk through a complete transformation for a notification system:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// BEFORE: OCP-violating notification systeminterface NotificationData { type: 'email' | 'sms' | 'push' | 'slack'; recipient: string; message: string; subject?: string; // email only phoneNumber?: string; // sms only deviceToken?: string; // push only channel?: string; // slack only} class NotificationService { async send(notification: NotificationData): Promise<void> { if (notification.type === 'email') { await this.smtpClient.send({ to: notification.recipient, subject: notification.subject, body: notification.message, }); } else if (notification.type === 'sms') { await this.twilioClient.sendSMS({ to: notification.phoneNumber, body: notification.message, }); } else if (notification.type === 'push') { await this.firebaseClient.send({ token: notification.deviceToken, notification: { title: notification.subject, body: notification.message, }, }); } else if (notification.type === 'slack') { await this.slackClient.postMessage({ channel: notification.channel, text: notification.message, }); } else { throw new Error(`Unknown notification type: ${notification.type}`); } } async validate(notification: NotificationData): Promise<ValidationResult> { // Another if-else chain for validation if (notification.type === 'email') { return this.validateEmail(notification); } else if (notification.type === 'sms') { return this.validateSMS(notification); } // ... continues for each type } getDeliveryEstimate(notification: NotificationData): Duration { // Yet another if-else chain if (notification.type === 'email') { return Duration.seconds(30); } else if (notification.type === 'sms') { return Duration.seconds(5); } // ... continues for each type }}Step 1: Define the Abstraction
Identify all behaviors that vary by notification type and define an interface:
1234567891011121314151617181920212223242526
// STEP 1: Define the abstractioninterface NotificationChannel { // Core operation send(recipient: string, message: string, options?: ChannelOptions): Promise<void>; // Validation validate(recipient: string, options?: ChannelOptions): ValidationResult; // Metadata getDeliveryEstimate(): Duration; // For logging/monitoring getChannelType(): string;} // Related typesinterface ChannelOptions { subject?: string; priority?: 'low' | 'normal' | 'high'; metadata?: Record<string, unknown>;} interface ValidationResult { valid: boolean; errors: string[];}Step 2: Create Implementations
Each case from the original switch becomes its own class:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
// STEP 2: Create implementations class EmailChannel implements NotificationChannel { constructor(private smtpClient: SMTPClient) {} async send(recipient: string, message: string, options?: ChannelOptions): Promise<void> { await this.smtpClient.send({ to: recipient, subject: options?.subject ?? 'Notification', body: message, priority: this.mapPriority(options?.priority), }); } validate(recipient: string): ValidationResult { const errors: string[] = []; if (!this.isValidEmail(recipient)) { errors.push('Invalid email address format'); } if (!recipient.includes('@')) { errors.push('Email must contain @'); } return { valid: errors.length === 0, errors }; } getDeliveryEstimate(): Duration { return Duration.seconds(30); // Email typically slower } getChannelType(): string { return 'email'; } private isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } private mapPriority(priority?: 'low' | 'normal' | 'high'): number { switch (priority) { case 'high': return 1; case 'low': return 5; default: return 3; } }} class SMSChannel implements NotificationChannel { constructor(private twilioClient: TwilioClient) {} async send(recipient: string, message: string): Promise<void> { await this.twilioClient.sendSMS({ to: recipient, body: message.substring(0, 160), // SMS character limit }); } validate(recipient: string): ValidationResult { const errors: string[] = []; if (!this.isValidPhoneNumber(recipient)) { errors.push('Invalid phone number format'); } return { valid: errors.length === 0, errors }; } getDeliveryEstimate(): Duration { return Duration.seconds(5); // SMS typically fast } getChannelType(): string { return 'sms'; } private isValidPhoneNumber(phone: string): boolean { return /^\+?[1-9]\d{1,14}$/.test(phone); }} class PushChannel implements NotificationChannel { constructor(private firebaseClient: FirebaseClient) {} async send(recipient: string, message: string, options?: ChannelOptions): Promise<void> { await this.firebaseClient.send({ token: recipient, // Device token is the "recipient" notification: { title: options?.subject ?? 'Notification', body: message, }, }); } validate(recipient: string): ValidationResult { const errors: string[] = []; if (!recipient || recipient.length < 20) { errors.push('Invalid device token'); } return { valid: errors.length === 0, errors }; } getDeliveryEstimate(): Duration { return Duration.seconds(2); // Push notifications are fast } getChannelType(): string { return 'push'; }} class SlackChannel implements NotificationChannel { constructor(private slackClient: SlackClient) {} async send(recipient: string, message: string): Promise<void> { await this.slackClient.postMessage({ channel: recipient, text: message, }); } validate(recipient: string): ValidationResult { const errors: string[] = []; if (!recipient.startsWith('#') && !recipient.startsWith('@')) { errors.push('Slack recipient must be a channel (#) or user (@)'); } return { valid: errors.length === 0, errors }; } getDeliveryEstimate(): Duration { return Duration.seconds(1); // Slack is very fast } getChannelType(): string { return 'slack'; }}Step 3: Create the Factory
The factory localizes the remaining type-selection logic:
12345678910111213141516171819202122232425262728293031323334353637383940414243
// STEP 3: Create the factory class NotificationChannelFactory { constructor( private smtpClient: SMTPClient, private twilioClient: TwilioClient, private firebaseClient: FirebaseClient, private slackClient: SlackClient, ) {} create(channelType: string): NotificationChannel { switch (channelType) { case 'email': return new EmailChannel(this.smtpClient); case 'sms': return new SMSChannel(this.twilioClient); case 'push': return new PushChannel(this.firebaseClient); case 'slack': return new SlackChannel(this.slackClient); default: throw new Error(`Unknown notification channel: ${channelType}`); } } // For registry-based approach (even more extensible) private channels: Map<string, () => NotificationChannel> = new Map(); register(channelType: string, factory: () => NotificationChannel): void { this.channels.set(channelType, factory); } createFromRegistry(channelType: string): NotificationChannel { const factory = this.channels.get(channelType); if (!factory) { throw new Error(`Unknown notification channel: ${channelType}`); } return factory(); }} // With registry, third parties can add channels without modifying factory:// channelFactory.register('discord', () => new DiscordChannel(discordClient));Step 4: Refactor the Service
The service becomes beautifully simple:
12345678910111213141516171819202122232425262728293031323334353637383940414243
// STEP 4: Refactored notification service interface NotificationRequest { channelType: string; recipient: string; message: string; options?: ChannelOptions;} class NotificationService { constructor(private channelFactory: NotificationChannelFactory) {} async send(request: NotificationRequest): Promise<void> { const channel = this.channelFactory.create(request.channelType); // Validate before sending const validation = channel.validate(request.recipient, request.options); if (!validation.valid) { throw new ValidationError(validation.errors); } // Send via polymorphic method — NO CONDITIONALS await channel.send(request.recipient, request.message, request.options); // Logging works polymorphically too this.logger.info(`Sent ${channel.getChannelType()} notification to ${request.recipient}`); } async validate(request: NotificationRequest): Promise<ValidationResult> { const channel = this.channelFactory.create(request.channelType); return channel.validate(request.recipient, request.options); } getDeliveryEstimate(channelType: string): Duration { const channel = this.channelFactory.create(channelType); return channel.getDeliveryEstimate(); }} // Adding a new channel (e.g., WhatsApp):// 1. Create WhatsAppChannel implements NotificationChannel// 2. Add case to factory (or register with registry)// 3. NotificationService remains UNCHANGEDNotice how the refactored NotificationService contains zero conditionals on channel type. Adding WhatsApp, Discord, Teams, or any other channel requires only creating a new class implementing NotificationChannel. The service is truly closed for modification and open for extension.
The Strategy pattern is the formalized design pattern that captures the 'Replace Conditional with Polymorphism' refactoring. Understanding it at the pattern level helps apply it consistently.
Strategy Pattern Structure:
The Strategy pattern consists of three participants:
When to Apply Strategy:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// Strategy Pattern for Pricing Calculations // BEFORE: Switch-based pricingclass PricingService { calculatePrice(product: Product, customer: Customer): Money { switch (customer.pricingTier) { case 'retail': return product.basePrice; case 'wholesale': return product.basePrice.multiply(0.8); // 20% discount case 'partner': return product.basePrice.multiply(0.7); // 30% discount case 'enterprise': return this.negotiatedPrice(product, customer); case 'promotional': return this.promotionalPrice(product, customer); } }} // AFTER: Strategy pattern // Strategy interfaceinterface PricingStrategy { calculatePrice(product: Product, customer: Customer): Money; getStrategyName(): string;} // Concrete strategiesclass RetailPricing implements PricingStrategy { calculatePrice(product: Product): Money { return product.basePrice; } getStrategyName(): string { return 'retail'; }} class WholesalePricing implements PricingStrategy { private readonly discountRate = 0.20; calculatePrice(product: Product): Money { return product.basePrice.multiply(1 - this.discountRate); } getStrategyName(): string { return 'wholesale'; }} class PartnerPricing implements PricingStrategy { private readonly discountRate = 0.30; calculatePrice(product: Product): Money { return product.basePrice.multiply(1 - this.discountRate); } getStrategyName(): string { return 'partner'; }} class EnterprisePricing implements PricingStrategy { constructor(private contractRepository: ContractRepository) {} calculatePrice(product: Product, customer: Customer): Money { const contract = this.contractRepository.findForCustomer(customer); return contract.getNegotiatedPrice(product); } getStrategyName(): string { return 'enterprise'; }} class PromotionalPricing implements PricingStrategy { constructor(private promotionEngine: PromotionEngine) {} calculatePrice(product: Product, customer: Customer): Money { const promotion = this.promotionEngine.findBestPromotion(product, customer); return promotion?.apply(product.basePrice) ?? product.basePrice; } getStrategyName(): string { return 'promotional'; }} // Context uses strategy without knowing concrete typeclass PricingService { constructor(private strategyFactory: PricingStrategyFactory) {} calculatePrice(product: Product, customer: Customer): Money { const strategy = this.strategyFactory.getStrategy(customer); return strategy.calculatePrice(product, customer); }} // Factory handles strategy selectionclass PricingStrategyFactory { private strategies: Map<string, PricingStrategy> = new Map(); constructor( retailPricing: RetailPricing, wholesalePricing: WholesalePricing, partnerPricing: PartnerPricing, enterprisePricing: EnterprisePricing, promotionalPricing: PromotionalPricing, ) { this.strategies.set('retail', retailPricing); this.strategies.set('wholesale', wholesalePricing); this.strategies.set('partner', partnerPricing); this.strategies.set('enterprise', enterprisePricing); this.strategies.set('promotional', promotionalPricing); } getStrategy(customer: Customer): PricingStrategy { const strategy = this.strategies.get(customer.pricingTier); return strategy ?? this.strategies.get('retail')!; }}With the Strategy pattern, strategy selection becomes completely decoupled from strategy execution. You could select based on customer tier (as shown), based on A/B tests, based on time of day, or any other criteria—without modifying the PricingService.
When variations share significant common logic with only certain steps differing, the Template Method pattern is more appropriate than pure Strategy. It allows you to define an algorithm skeleton while letting subclasses override specific steps.
Template Method vs Strategy:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
// Template Method for Document Processing // BEFORE: All variation logic in conditionalsclass DocumentProcessor { process(document: Document): ProcessedDocument { // Common: Load document const content = this.loadContent(document); // Type-specific: Parse let parsed: ParsedContent; if (document.type === 'pdf') { parsed = this.parsePDF(content); } else if (document.type === 'word') { parsed = this.parseWord(content); } else if (document.type === 'html') { parsed = this.parseHTML(content); } // Common: Extract metadata const metadata = this.extractMetadata(parsed); // Type-specific: Transform let transformed: TransformedContent; if (document.type === 'pdf') { transformed = this.transformPDF(parsed); } else if (document.type === 'word') { transformed = this.transformWord(parsed); } else if (document.type === 'html') { transformed = this.transformHTML(parsed); } // Common: Save return this.save(transformed, metadata); }} // AFTER: Template Method pattern abstract class DocumentProcessor { // Template method - defines the algorithm skeleton process(document: Document): ProcessedDocument { // Common step const content = this.loadContent(document); // Template step - subclasses implement const parsed = this.parse(content); // Common step const metadata = this.extractMetadata(parsed); // Template step - subclasses implement const transformed = this.transform(parsed); // Common step return this.save(transformed, metadata); } // Common implementations in base class protected loadContent(document: Document): Buffer { return this.fileSystem.read(document.path); } protected extractMetadata(parsed: ParsedContent): Metadata { return { title: parsed.title, author: parsed.author, createdAt: parsed.createdAt, }; } protected save(content: TransformedContent, metadata: Metadata): ProcessedDocument { return { content, metadata, processedAt: new Date() }; } // Abstract template steps - subclasses must implement protected abstract parse(content: Buffer): ParsedContent; protected abstract transform(parsed: ParsedContent): TransformedContent;} class PDFDocumentProcessor extends DocumentProcessor { constructor(private pdfParser: PDFParser) { super(); } protected parse(content: Buffer): ParsedContent { return this.pdfParser.parse(content); } protected transform(parsed: ParsedContent): TransformedContent { // PDF-specific transformation return this.pdfParser.toStandardFormat(parsed); }} class WordDocumentProcessor extends DocumentProcessor { constructor(private wordParser: WordParser) { super(); } protected parse(content: Buffer): ParsedContent { return this.wordParser.parse(content); } protected transform(parsed: ParsedContent): TransformedContent { // Word-specific transformation return this.wordParser.toStandardFormat(parsed); }} class HTMLDocumentProcessor extends DocumentProcessor { constructor(private htmlParser: HTMLParser) { super(); } protected parse(content: Buffer): ParsedContent { return this.htmlParser.parse(content); } protected transform(parsed: ParsedContent): TransformedContent { // HTML-specific transformation return this.htmlParser.toStandardFormat(parsed); }} // Factory creates appropriate processorclass DocumentProcessorFactory { create(documentType: string): DocumentProcessor { switch (documentType) { case 'pdf': return new PDFDocumentProcessor(this.pdfParser); case 'word': return new WordDocumentProcessor(this.wordParser); case 'html': return new HTMLDocumentProcessor(this.htmlParser); default: throw new Error(`Unknown document type: ${documentType}`); } }}Template Method ensures that all document types follow the same processing sequence: load → parse → extract metadata → transform → save. Subclasses cannot skip or reorder steps. This is valuable when procedural consistency is required.
Sometimes you need to add operations to a class hierarchy without modifying the classes themselves. The Visitor pattern enables this by separating operations from the objects they operate on.
When Visitor Is Appropriate:
When to Avoid Visitor:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
// Visitor Pattern for AST Processing // BEFORE: instanceof checks for different node typesclass ASTInterpreter { evaluate(node: ASTNode): number { if (node instanceof NumberNode) { return node.value; } else if (node instanceof AddNode) { return this.evaluate(node.left) + this.evaluate(node.right); } else if (node instanceof SubtractNode) { return this.evaluate(node.left) - this.evaluate(node.right); } else if (node instanceof MultiplyNode) { return this.evaluate(node.left) * this.evaluate(node.right); } else if (node instanceof DivideNode) { return this.evaluate(node.left) / this.evaluate(node.right); } throw new Error('Unknown node type'); }} // Also need pretty-printer, optimizer, type-checker...// Each would have the same instanceof chain // AFTER: Visitor pattern // Visitor interface - one method per node typeinterface ASTVisitor<T> { visitNumber(node: NumberNode): T; visitAdd(node: AddNode): T; visitSubtract(node: SubtractNode): T; visitMultiply(node: MultiplyNode): T; visitDivide(node: DivideNode): T;} // Each node type accepts a visitorabstract class ASTNode { abstract accept<T>(visitor: ASTVisitor<T>): T;} class NumberNode extends ASTNode { constructor(public value: number) { super(); } accept<T>(visitor: ASTVisitor<T>): T { return visitor.visitNumber(this); }} class AddNode extends ASTNode { constructor(public left: ASTNode, public right: ASTNode) { super(); } accept<T>(visitor: ASTVisitor<T>): T { return visitor.visitAdd(this); }} class SubtractNode extends ASTNode { constructor(public left: ASTNode, public right: ASTNode) { super(); } accept<T>(visitor: ASTVisitor<T>): T { return visitor.visitSubtract(this); }} class MultiplyNode extends ASTNode { constructor(public left: ASTNode, public right: ASTNode) { super(); } accept<T>(visitor: ASTVisitor<T>): T { return visitor.visitMultiply(this); }} class DivideNode extends ASTNode { constructor(public left: ASTNode, public right: ASTNode) { super(); } accept<T>(visitor: ASTVisitor<T>): T { return visitor.visitDivide(this); }} // Concrete visitors - each operation is a separate class class EvaluatorVisitor implements ASTVisitor<number> { visitNumber(node: NumberNode): number { return node.value; } visitAdd(node: AddNode): number { return node.left.accept(this) + node.right.accept(this); } visitSubtract(node: SubtractNode): number { return node.left.accept(this) - node.right.accept(this); } visitMultiply(node: MultiplyNode): number { return node.left.accept(this) * node.right.accept(this); } visitDivide(node: DivideNode): number { return node.left.accept(this) / node.right.accept(this); }} class PrettyPrinterVisitor implements ASTVisitor<string> { visitNumber(node: NumberNode): string { return node.value.toString(); } visitAdd(node: AddNode): string { return `(${node.left.accept(this)} + ${node.right.accept(this)})`; } visitSubtract(node: SubtractNode): string { return `(${node.left.accept(this)} - ${node.right.accept(this)})`; } visitMultiply(node: MultiplyNode): string { return `(${node.left.accept(this)} × ${node.right.accept(this)})`; } visitDivide(node: DivideNode): string { return `(${node.left.accept(this)} ÷ ${node.right.accept(this)})`; }} // Usageconst ast = new AddNode( new NumberNode(5), new MultiplyNode(new NumberNode(3), new NumberNode(4))); const evaluator = new EvaluatorVisitor();const result = ast.accept(evaluator); // 17 const printer = new PrettyPrinterVisitor();const formatted = ast.accept(printer); // "(5 + (3 × 4))" // Adding new operations (e.g., type-checker, optimizer):// Create new visitor class - NO changes to AST node classesThe Visitor pattern uses double dispatch: first dispatch on the node type (via accept), then dispatch on the visitor type (via visitXxx). This resolves both dimensions of variation without instanceof checks.
In production systems, 'big bang' refactorings are risky. Incremental migration strategies allow you to transform OCP-violating code gradually while maintaining system stability.
Strategy 1: Strangler Fig Pattern
Gradually replace old conditional code with new polymorphic code, one branch at a time:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Strangler Fig: Gradual migration from switch to polymorphism // PHASE 1: Introduce interface and first implementationinterface PaymentGateway { charge(amount: Money): Promise<ChargeResult>;} class StripeGateway implements PaymentGateway { async charge(amount: Money): Promise<ChargeResult> { // Migrated from switch case return this.stripeClient.charge(amount); }} // PHASE 2: Hybrid service - delegates to new code OR falls back to oldclass PaymentService { private gateways: Map<string, PaymentGateway> = new Map(); constructor() { // Only Stripe migrated so far this.gateways.set('stripe', new StripeGateway(this.stripeClient)); } async processPayment(type: string, amount: Money): Promise<ChargeResult> { // Check if migrated to new system if (this.gateways.has(type)) { return this.gateways.get(type)!.charge(amount); } // Fall back to old switch for unmigrated types return this.legacyProcess(type, amount); } private async legacyProcess(type: string, amount: Money): Promise<ChargeResult> { // Old switch statement - shrinks as we migrate switch (type) { case 'paypal': return this.paypalClient.charge(amount); case 'square': return this.squareClient.charge(amount); // ... remaining types } }} // PHASE 3: Continue migrating - add PayPalGateway, SquareGateway// PHASE 4: Once all migrated, delete legacyProcess methodStrategy 2: Feature Flag Control
Use feature flags to gradually shift traffic to the new implementation:
123456789101112131415161718192021222324252627
// Feature Flag: Control migration via configuration class PaymentService { constructor( private featureFlags: FeatureFlagService, private newPaymentProcessor: PaymentProcessor, // OCP-compliant private legacyPaymentProcessor: LegacyPaymentProcessor, // Old switch-based ) {} async processPayment(request: PaymentRequest): Promise<PaymentResult> { // Feature flag controls which implementation runs if (this.featureFlags.isEnabled('use-polymorphic-payments', { userId: request.userId, paymentType: request.type, })) { return this.newPaymentProcessor.process(request); } return this.legacyPaymentProcessor.process(request); }} // Rollout phases:// Phase 1: 1% of traffic to new implementation// Phase 2: 10% of traffic, monitor metrics// Phase 3: 50% of traffic, verify at scale// Phase 4: 100%, remove legacy code and feature flagStrategy 3: Parallel Run and Compare
Run both implementations and compare results to verify correctness:
123456789101112131415161718192021222324252627282930313233343536373839
// Parallel Run: Execute both, compare results class PaymentService { async processPayment(request: PaymentRequest): Promise<PaymentResult> { // Run legacy (source of truth) const legacyResult = await this.legacyProcessor.process(request); // Run new implementation in parallel (but don't use its result yet) try { const newResult = await this.newProcessor.process(request); // Compare results if (!this.resultsMatch(legacyResult, newResult)) { this.metrics.increment('payment.mismatch'); this.logger.warn('Payment result mismatch', { request, legacy: legacyResult, new: newResult, }); } else { this.metrics.increment('payment.match'); } } catch (error) { this.metrics.increment('payment.new_error'); this.logger.error('New implementation error', { request, error }); } // Still return legacy result return legacyResult; } private resultsMatch(a: PaymentResult, b: PaymentResult): boolean { return a.status === b.status && a.amount.equals(b.amount) && a.transactionId === b.transactionId; }} // Monitor until mismatch rate is zero, then switch to new implementationThe most dangerous phase of migration is when both old and new code exist. Maintain discipline: complete the migration within a defined timeframe, track progress on a dashboard, and don't let 'temporary' parallel systems become permanent. Technical debt compounds.
While refactoring is essential, the best approach is designing for OCP compliance from the beginning. Here are principles and practices that naturally lead to extensible designs.
Think in Extension Points:
When designing any system, explicitly identify where variation is expected. These become your extension points—interfaces that hide the variation behind stable abstractions.
Apply the Two-Occurrence Rule:
Don't create abstractions for hypothetical variations. Wait until you have two concrete cases before introducing polymorphism. This prevents over-engineering while ensuring you don't miss genuine extension points.
Design Interfaces Client-First:
Design interfaces based on what clients need, not what implementations provide. This ensures the abstraction serves its purpose and doesn't leak implementation concerns.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// CLIENT-FIRST DESIGN: What does the consumer need? // Bad: Interface designed around Stripe's APIinterface PaymentGateway { createPaymentIntent(stripeParams: StripeIntentParams): Promise<StripeIntent>; confirmPaymentIntent(intentId: string): Promise<StripeConfirmation>;} // Problem: PayPal, Square, etc. don't have "intents"// The abstraction is coupled to Stripe's implementation // Better: Interface designed around client needsinterface PaymentGateway { // What does the client CARE about? authorize(amount: Money, paymentMethod: string): Promise<Authorization>; capture(authorization: Authorization): Promise<CapturedPayment>; refund(payment: CapturedPayment, amount?: Money): Promise<Refund>;} // Now any gateway can implement these operations// The abstraction reflects business concepts, not vendor APIs class StripeGateway implements PaymentGateway { async authorize(amount: Money, paymentMethod: string): Promise<Authorization> { // Translate to Stripe API (intents, etc.) const intent = await this.stripe.paymentIntents.create({ amount: amount.cents, payment_method: paymentMethod, capture_method: 'manual', }); return { id: intent.id, amount, status: 'authorized' }; } // ...} class PayPalGateway implements PaymentGateway { async authorize(amount: Money, paymentMethod: string): Promise<Authorization> { // Translate to PayPal API (orders, etc.) const order = await this.paypal.orders.create({ intent: 'AUTHORIZE', purchase_units: [{ amount: { value: amount.toString() } }], }); return { id: order.id, amount, status: 'authorized' }; } // ...}Client-first interface design naturally aligns with the Interface Segregation Principle (ISP). When you ask 'What does this client need?', you naturally create focused interfaces rather than bloated ones. OCP and ISP reinforce each other.
This page has provided comprehensive techniques for transforming OCP-violating code into extensible, maintainable designs. Let's consolidate the key learnings from this entire module:
| Violation Type | Recommended Pattern(s) | Key Transformation |
|---|---|---|
| If-else chain on type | Strategy, Factory | Each branch → class implementing interface |
| instanceof checks | Polymorphism, Visitor | Type check → polymorphic method call |
| Switch on enum/string type | Strategy, Factory | Switch → factory + polymorphism |
| Parallel conditionals in multiple methods | Template Method | Common structure → base class, variations → subclasses |
| Operations on type hierarchy | Visitor | External operation → visitor class |
The Bigger Picture:
Mastering OCP violations and fixes is more than learning to refactor individual code patterns. It's about developing an architectural instinct that naturally leads to extensible designs. When you see a requirement like 'support multiple payment processors', your mind should immediately think 'PaymentProcessor interface with implementations', not 'switch statement with cases.'
This instinct compounds over time. Systems designed with OCP in mind evolve gracefully—adding new payment processors, notification channels, or export formats becomes a matter of creating new classes, not touching existing code. Teams can work in parallel without merge conflicts. Third-party integrations become plug-ins, not forks.
The effort invested in understanding and applying OCP pays dividends throughout your career and across every system you build.
You have completed the OCP Violations and Fixes module. You can now identify all major forms of OCP violations, understand when conditionals are appropriate versus problematic, apply the Strategy, Template Method, and Visitor patterns to eliminate violations, execute safe incremental migrations in production code, and design new systems with OCP compliance from the start. These skills will serve you throughout your engineering career.