The Open/Closed Principle (OCP) says: software entities should be open for extension but closed for modification. It’s one of those principles that sounds great in a textbook and confusing in practice.

What it really means: when you need to add new behavior, you shouldn’t have to crack open existing, working code and rewrite it. You should be able to extend the system by adding new code.

Let’s look at why this matters and how to apply it without over-engineering.

The Problem: The Never-Ending If-Else

Here’s a payment processor that violates OCP. Every time we add a payment method, we modify the same function:

function processPayment(method: string, amount: number): PaymentResult {
  if (method === "credit_card") {
    // 20 lines of credit card logic
    return chargeCreditCard(amount);
  } else if (method === "paypal") {
    // 15 lines of PayPal logic
    return chargePayPal(amount);
  } else if (method === "stripe") {
    // Added last month — touched the same function
    return chargeStripe(amount);
  } else if (method === "crypto") {
    // Added this week — touched it again
    return chargeCrypto(amount);
  }
  throw new Error(`Unknown payment method: ${method}`);
}

Every new payment method means:

  1. Opening a function that already works
  2. Adding another branch
  3. Risking a bug in existing branches (a misplaced brace, a forgotten else)
  4. Making the function longer and harder to test

This function is closed for extension (you can’t add behavior without editing it) and open for modification (you have to change it constantly). Exactly backwards.

The Fix: Strategy via Interface

Define a contract, then let each payment method implement it independently:

interface PaymentProcessor {
  readonly methodName: string;
  charge(amount: number): Promise<PaymentResult>;
  refund(transactionId: string, amount: number): Promise<RefundResult>;
}

class CreditCardProcessor implements PaymentProcessor {
  readonly methodName = "credit_card";

  async charge(amount: number): Promise<PaymentResult> {
    // Credit card-specific logic
    const token = await this.tokenizeCard();
    return await gateway.charge(token, amount);
  }

  async refund(transactionId: string, amount: number): Promise<RefundResult> {
    return await gateway.refund(transactionId, amount);
  }

  private async tokenizeCard(): Promise<string> { /* ... */ }
}

class PayPalProcessor implements PaymentProcessor {
  readonly methodName = "paypal";

  async charge(amount: number): Promise<PaymentResult> {
    // PayPal-specific logic — totally independent
    return await paypalApi.createCharge(amount);
  }

  async refund(transactionId: string, amount: number): Promise<RefundResult> {
    return await paypalApi.issueRefund(transactionId, amount);
  }
}

Now the payment service is closed for modification:

class PaymentService {
  private processors: Map<string, PaymentProcessor>;

  constructor(processors: PaymentProcessor[]) {
    this.processors = new Map(processors.map(p => [p.methodName, p]));
  }

  async charge(method: string, amount: number): Promise<PaymentResult> {
    const processor = this.processors.get(method);
    if (!processor) {
      throw new Error(`Unsupported payment method: ${method}`);
    }
    return processor.charge(amount);
  }
}

// Adding crypto? Just add a new class. PaymentService doesn't change.
class CryptoProcessor implements PaymentProcessor {
  readonly methodName = "crypto";
  async charge(amount: number): Promise<PaymentResult> { /* ... */ }
  async refund(transactionId: string, amount: number): Promise<RefundResult> { /* ... */ }
}

// Registration:
const service = new PaymentService([
  new CreditCardProcessor(),
  new PayPalProcessor(),
  new CryptoProcessor(), // New! No existing code was modified.
]);

OCP Through Composition in Python

Python’s duck typing makes OCP natural. You don’t even need explicit interfaces — though protocols help:

from typing import Protocol
from abc import abstractmethod

class NotificationSender(Protocol):
    def send(self, recipient: str, message: str) -> bool: ...

class EmailSender:
    def send(self, recipient: str, message: str) -> bool:
        # SMTP logic
        print(f"Email to {recipient}: {message}")
        return True

class SmsSender:
    def send(self, recipient: str, message: str) -> bool:
        # Twilio/SMS logic
        print(f"SMS to {recipient}: {message}")
        return True

class SlackSender:
    def send(self, recipient: str, message: str) -> bool:
        # Slack webhook logic
        print(f"Slack to #{recipient}: {message}")
        return True

class NotificationService:
    def __init__(self, senders: list[NotificationSender]):
        self._senders = senders

    def notify_all(self, recipient: str, message: str) -> None:
        for sender in self._senders:
            sender.send(recipient, message)

# Extending? Add a new sender class. NotificationService never changes.
service = NotificationService([EmailSender(), SmsSender(), SlackSender()])

OCP with Decorators

Sometimes you need to augment behavior without modifying existing classes. Decorators are perfect for this:

interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(message);
  }
}

// Extend logging with timestamps — without modifying ConsoleLogger
class TimestampedLogger implements Logger {
  constructor(private inner: Logger) {}

  log(message: string): void {
    const timestamp = new Date().toISOString();
    this.inner.log(`[${timestamp}] ${message}`);
  }
}

// Extend with filtering — without modifying anything
class FilteredLogger implements Logger {
  constructor(
    private inner: Logger,
    private minLevel: string,
  ) {}

  log(message: string): void {
    if (this.shouldLog(message)) {
      this.inner.log(message);
    }
  }

  private shouldLog(message: string): boolean { /* ... */ }
}

// Stack them:
const logger = new FilteredLogger(
  new TimestampedLogger(new ConsoleLogger()),
  "warn",
);

Each layer adds behavior. None modifies existing code. That’s OCP through composition.

OCP with the Template Method

When subclasses need to customize steps within an algorithm, the template method pattern keeps the algorithm itself closed:

from abc import ABC, abstractmethod

class DataExporter(ABC):
    def export(self, data: list[dict]) -> str:
        """Template method — this algorithm doesn't change."""
        validated = self._validate(data)
        transformed = self._transform(validated)
        output = self._format(transformed)
        return output

    def _validate(self, data: list[dict]) -> list[dict]:
        """Default validation — can be overridden."""
        return [row for row in data if row]

    @abstractmethod
    def _transform(self, data: list[dict]) -> list[dict]:
        """Subclasses define their own transformation."""
        ...

    @abstractmethod
    def _format(self, data: list[dict]) -> str:
        """Subclasses define their own output format."""
        ...

class CsvExporter(DataExporter):
    def _transform(self, data: list[dict]) -> list[dict]:
        # Flatten nested structures for CSV
        return [self._flatten(row) for row in data]

    def _format(self, data: list[dict]) -> str:
        headers = ",".join(data[0].keys())
        rows = "\n".join(",".join(str(v) for v in row.values()) for row in data)
        return f"{headers}\n{rows}"

    def _flatten(self, row: dict) -> dict: ...

class JsonExporter(DataExporter):
    def _transform(self, data: list[dict]) -> list[dict]:
        return data  # JSON handles nesting fine

    def _format(self, data: list[dict]) -> str:
        import json
        return json.dumps(data, indent=2)

Adding an XML exporter? Create XmlExporter. The export() template method stays untouched.

When OCP Goes Wrong

Like all principles, OCP can be taken too far. Watch out for:

Premature Abstraction

// YAGNI! Don't create an interface for one implementation.
interface IUserRepository { /* ... */ }
class UserRepository implements IUserRepository { /* ... */ }

// If there's only ever one implementation, this is just noise.
// Wait until you actually need a second one.

Abstraction Astronautics

// Too many layers of indirection make code unreadable
const result = processorFactory
  .createProcessor(strategySelector.select(configProvider.getConfig()))
  .process(
    dataTransformerFactory.createTransformer(mappingProvider.getMapping())
      .transform(input)
  );

// Sometimes a well-placed if-else is more readable than three interfaces

The Rule of Three

Don’t extract an abstraction the first time you see duplication. Don’t do it the second time either. The third time, you have enough examples to see the real pattern:

// First payment method? Just write the code.
// Second payment method? Maybe copy-paste is fine for now.
// Third payment method? NOW extract the PaymentProcessor interface.

When to Apply OCP

Apply it when:

  • You’re adding the third variant of something (rule of three)
  • The area changes frequently (payment methods, notification channels, export formats)
  • Multiple teams work on the same codebase and need independent extension points
  • You’re building a plugin or middleware system

Skip it when:

  • There’s only one implementation and no foreseeable second
  • The branching logic is simple and stable (2-3 cases that rarely change)
  • Adding an abstraction makes the code harder to follow
  • You’re prototyping and the design is still in flux

The Real Test

Here’s how to know if your code follows OCP: imagine your product manager asks for a new feature variant (a new payment method, a new export format, a new notification channel). Do you:

A) Open an existing file, find the right if-else branch, and carefully insert new code between existing branches?

B) Create a new file with a new class, implement the interface, and register it?

If the answer is B, your code is open for extension and closed for modification. If it’s A, consider refactoring — but only if you’re actually feeling the pain. OCP is a response to real change, not imaginary future change.