Open/Closed Principle


Software entities should be open for extension, closed for modification.#

The Open/Closed Principle (OCP) states that classes, modules, and functions should be open for extension but closed for modification. In practice, this means you should be able to add new functionality without changing existing code.

This principle directly addresses a core tension in software development: how do you make systems flexible enough to accommodate new requirements while keeping existing code stable and thoroughly tested?

The Problem with Modification#

When every new requirement forces you to modify existing code, several problems emerge:

First, you risk breaking existing functionality. Every modification is a chance to introduce bugs in code that was working fine. Even with comprehensive tests, the psychological burden of modifying “stable” code creates hesitation and reduces development velocity.

Second, modification spreads changes across the codebase. A new requirement might force changes in multiple classes, making the codebase harder to reason about and review.

Third, if the existing code is in production and relied upon by many clients, modification becomes risky or impossible. You can’t safely change code that’s already deployed.

Extension avoids these problems by adding new behavior without touching existing code.

Extension vs. Modification#

Consider a payment processing system:

// Before: Modification violates Open/Closed Principle
class PaymentProcessor {
  processPayment(method: string, amount: number): void {
    if (method === "creditCard") {
      // credit card logic
    } else if (method === "paypal") {
      // PayPal logic
    } else if (method === "stripe") {
      // Stripe logic
    } else if (method === "bitcoin") {
      // Bitcoin logic - MODIFICATION
    }
  }
}

Every time you add a new payment method, you modify the PaymentProcessor class. This violates the Open/Closed Principle because the class is closed for modification but you keep modifying it.

The extension-based approach uses polymorphism:

interface PaymentMethod {
  process(amount: number): void;
}

class CreditCardPayment implements PaymentMethod {
  process(amount: number): void {
    // credit card logic
  }
}

class PayPalPayment implements PaymentMethod {
  process(amount: number): void {
    // PayPal logic
  }
}

class BitcoinPayment implements PaymentMethod {
  process(amount: number): void {
    // Bitcoin logic
  }
}

class PaymentProcessor {
  processPayment(method: PaymentMethod, amount: number): void {
    method.process(amount);
  }
}

Now adding a new payment method means creating a new class and registering it. The PaymentProcessor class doesn’t change.

Design Patterns That Enable OCP#

Strategy Pattern#

The strategy pattern encapsulates algorithms in separate classes, allowing you to select behavior at runtime without modifying the client code.

interface ReportStrategy {
  generate(data: any): string;
}

class PdfReportStrategy implements ReportStrategy {
  generate(data: any): string {
    // Generate PDF report
    return "PDF Report";
  }
}

class JsonReportStrategy implements ReportStrategy {
  generate(data: any): string {
    // Generate JSON report
    return "JSON Report";
  }
}

class ReportGenerator {
  constructor(private strategy: ReportStrategy) {}

  generateReport(data: any): string {
    return this.strategy.generate(data);
  }
}

// Usage: No modification needed to add new report types
const reportGenerator = new ReportGenerator(new PdfReportStrategy());

Template Method Pattern#

Template method defines a skeleton of an algorithm in the base class, allowing subclasses to override specific steps.

abstract class DatabaseConnection {
  connect(): void {
    this.openConnection();
    this.authenticate();
    this.configureSession();
  }

  protected abstract openConnection(): void;
  protected abstract authenticate(): void;
  
  private configureSession(): void {
    // Common configuration logic
  }
}

class MySQLConnection extends DatabaseConnection {
  protected openConnection(): void {
    // MySQL-specific connection
  }

  protected authenticate(): void {
    // MySQL-specific authentication
  }
}

Dependency Injection#

By injecting dependencies at construction time rather than creating them internally, classes become closed for modification but open for extension through different implementations.

class UserService {
  constructor(private repository: UserRepository) {}

  getUser(id: string): User {
    return this.repository.find(id);
  }
}

// Extension: Different repositories without modifying UserService
interface UserRepository {
  find(id: string): User;
}

class DatabaseUserRepository implements UserRepository {
  find(id: string): User { /* ... */ }
}

class CacheUserRepository implements UserRepository {
  find(id: string): User { /* ... */ }
}

Abstraction is the Key#

OCP works because abstraction enables extension without modification. When you design around interfaces or abstract classes rather than concrete implementations, new implementations can satisfy the contract without changing existing code.

However, don’t over-abstract. Abstraction creates complexity. You need to predict where change is likely and only abstract at those points.

Critical Balance: Over-applying OCP creates indirection that makes code harder to understand. Apply it strategically where you expect variation.

When to Apply OCP#

High-Risk Areas#

Areas where change is frequent or risky benefit most from OCP:

  • Payment processing: Frequently adds new providers
  • Reporting formats: Customers constantly request new formats
  • Authentication methods: Security requirements drive new methods
  • Data storage: Expansion often requires new storage backends

Low-Risk Areas#

Some areas don’t need OCP protection:

  • Business logic that’s unlikely to change
  • One-off features that aren’t extended
  • Internal implementation details that are stable

OCP in Practice#

Identify Extension Points#

Before designing for OCP, understand where systems are likely to change. Common extension points include:

  • External integrations (payment processors, authentication providers)
  • Data storage mechanisms (databases, caches)
  • Business logic variations (different calculation methods)
  • Output formats (reports, notifications)

Use Contracts (Interfaces)#

Define clear contracts that extensions must satisfy. This contract should capture what the extension point needs to do without exposing how it does it.

Provide Implementation Plugins#

Make it easy for new implementations to be registered. Avoid hardcoded lists or switch statements.

class PaymentProcessorRegistry {
  private processors = new Map<string, PaymentMethod>();

  register(method: string, processor: PaymentMethod): void {
    this.processors.set(method, processor);
  }

  get(method: string): PaymentMethod {
    return this.processors.get(method) ?? throw new Error(`Unknown method: ${method}`);
  }
}

Common Violations#

Switch Statement Anti-Pattern#

Switch statements checking type and acting differently are telltale signs of OCP violations:

// Violation: Modify to add new types
function calculateDiscount(customer: Customer): number {
  switch (customer.type) {
    case "premium":
      return 0.20;
    case "standard":
      return 0.10;
    case "vip":
      return 0.30; // Modification required
    default:
      return 0;
  }
}

// Better: OCP-compliant
interface DiscountCalculator {
  calculate(customer: Customer): number;
}

class PremiumDiscount implements DiscountCalculator {
  calculate(customer: Customer): number {
    return 0.20;
  }
}

Cascading Changes#

When adding a feature requires changes across multiple classes, you’re likely violating OCP. Good design should localize changes.

Relationship to Other Principles#

OCP depends on proper abstraction, which requires understanding the Single Responsibility Principle and Interface Segregation Principle. It enables the Dependency Inversion Principle by creating abstract contracts that high-level modules depend on.

Conclusion#

The Open/Closed Principle guides you toward systems that are resilient to change. By designing for extension rather than modification, you reduce the risk that new requirements break existing functionality.

The key is striking the right balance—apply OCP where change is likely, but avoid over-engineering every possible extension point. With experience, you’ll develop intuition about where to invest in abstraction.

When you find yourself modifying the same class repeatedly to add new functionality, that’s a signal to refactor around an abstraction. Your system will become more flexible, and your code will become easier to reason about.