SOLID Principle · OCP

Open/Closed Principle

Open for extension, closed for modification.

Open/Closed Principle (OCP): A Complete Guide with Examples in 11 Programming Languages

The Open/Closed Principle states that software entities should be open for extension but closed for modification. Once a class is written, tested, and deployed, you should be able to add new behavior to the system by adding new code — not by editing the existing, working code. OCP is the principle that keeps stable systems stable: new requirements arrive as new classes, not as surgical edits to battle-tested ones.

In this comprehensive guide, we'll explore OCP with real-world examples in Python, TypeScript, Java, JavaScript, C#, PHP, Go, Rust, Dart, Swift, and Kotlin — complete with language-specific best practices, common violations, and clear guidance on when and how to apply this essential principle.

Table of Contents

What is the Open/Closed Principle?

OCP is the second of the five SOLID principles, introduced by Bertrand Meyer and later refined by Robert C. Martin. Its definition is elegant: software entities (classes, modules, functions) should be open for extension but closed for modification.

Consider a payment processor that handles credit cards. A new requirement arrives: support PayPal. The naive approach opens the payment class and adds an if paymentType == "paypal" branch. Later, crypto arrives — another else if. With each new payment type, the same class is opened, edited, and re-tested. Every edit risks breaking credit card payments that were working perfectly. The class is never stable.

OCP solves this by defining an abstraction — a PaymentMethod interface — and making each payment type a separate implementation. When PayPal arrives, you add a PayPalPayment class that implements the interface. The credit card class is never touched. The processor that depends on the abstraction works automatically. The system extended without modification.

The key insight: open for extension means new behaviors can be added. Closed for modification means existing, tested code doesn't need to change to accommodate them. The mechanism that enables this is abstraction — interfaces, abstract classes, or function signatures that define a contract stable classes can depend on.

Why Follow OCP?

OCP provides several critical benefits:

  1. Stability: Working, tested code is never touched. Existing tests keep passing. Bug risk from new features is isolated to new code.
  2. Scalability: Adding the 10th payment type is as safe as adding the 2nd — no existing code changes, no regression risk on prior types.
  3. Parallel Development: Two developers can add two new payment types simultaneously without any file conflicts.
  4. Testability: Each new extension is independently testable without setting up the full system.
  5. Predictability: If a class is closed for modification, its behavior after testing is guaranteed — new requirements cannot silently alter it.
  6. Reduced Review Burden: Code reviews for new extensions only cover new code — not "did this edit to a central class break anything else?"

OCP Comparison: Violation vs. Compliance

DimensionOCP ViolationOCP Compliant
Adding new behaviorEdit existing class — add if/elseAdd new class implementing an abstraction
Risk of regressionHigh — every edit touches working codeMinimal — existing classes are untouched
Test impactMust re-run all tests for the modified classOnly new class needs testing
Parallel developmentConflicts — multiple devs editing the same classNo conflicts — each dev adds their own class
GrowthClass grows with every new variantClass stays the same size; new variants are new files
Code reviewReviews must check for regressionsReviews only inspect the new extension

OCP vs. Related Concepts:

  • OCP vs. Strategy Pattern: Strategy is the most direct implementation of OCP. The context is closed; strategies (extensions) are open. Every new algorithm is a new strategy class.
  • OCP vs. Template Method: Template Method defines the invariant skeleton (closed) and delegates variable steps to subclasses (open). Each subclass extends without modifying the template.
  • OCP vs. Plugin Architecture: Plugin systems are OCP at the system level. The core is closed; plugins extend functionality without core modification.

OCP Explained

The Abstraction Is the Key

OCP works by depending on abstractions, not concretions. A class that depends on a DiscountCalculator interface is closed — it works with any present or future discount calculator. A class that depends on ChristmasDiscountCalculator directly is open — every new type of discount forces a modification.

The abstraction creates a stable boundary. Everything above the boundary (the consumer) is closed for modification. Everything below (the implementations) can grow freely without affecting what's above.

When to Define the Abstraction

The classic mistake is creating the abstraction too early — building an elaborate interface hierarchy before you have two concrete implementations. OCP does not demand that every class be immediately surrounded by interfaces. The trigger is the second variant: when the second discount type arrives, that's when the abstraction earns its existence. "Fool me once" — the first variant taught you the shape of the extension point; the second variant confirms it.

Three OCP Implementation Strategies

  1. Interface/Abstract Class Inheritance: Define an interface or abstract class that captures the stable contract. New variants implement the interface. The consumer depends on the abstraction.
  2. Composition with Strategy: The context holds a reference to a strategy object. New strategies are new classes. The context is closed; strategies extend it without modifying it.
  3. Configuration/Data-Driven Extension: Store behavior in configuration, data, or a registry rather than in code. Adding a new variant means adding a new entry — no class modifications needed.

Class Diagram

Here's the UML class diagram showing a discount system refactored to comply with OCP:

Beginner-Friendly Example

Let's start with the most intuitive OCP example: a discount calculation system. We'll show the violation — a single class with growing if/else chains — then refactor to an abstraction-based design where new discount types arrive as new classes.

# ── ❌ OCP VIOLATION ───────────────────────────────────────────────
# Every new discount type requires opening and editing this class.
class DiscountCalculator:
    def calculate(self, price: float, discount_type: str) -> float:
        if discount_type == "none":
            return price
        elif discount_type == "percentage_10":
            return price * 0.90
        elif discount_type == "percentage_20":
            return price * 0.80
        elif discount_type == "flat_5":
            return max(0, price - 5)
        # Adding "buy_one_get_one" requires opening this class → violation
        else:
            raise ValueError(f"Unknown discount type: {discount_type}")


# ── ✅ OCP COMPLIANT ───────────────────────────────────────────────
from abc import ABC, abstractmethod


# The stable abstraction — never changes when new discounts are added
class DiscountCalculator(ABC):
    @abstractmethod
    def calculate(self, price: float) -> float:
        """Return the price after applying this discount."""

    @abstractmethod
    def label(self) -> str:
        """Human-readable name for this discount."""


# Each discount type is a new class — the existing classes are never touched
class NoDiscount(DiscountCalculator):
    def calculate(self, price: float) -> float:
        return price

    def label(self) -> str:
        return "No discount"


class PercentageDiscount(DiscountCalculator):
    def __init__(self, percent: float) -> None:
        self._percent = percent

    def calculate(self, price: float) -> float:
        return price * (1 - self._percent / 100)

    def label(self) -> str:
        return f"{self._percent}% off"


class FlatDiscount(DiscountCalculator):
    def __init__(self, amount: float) -> None:
        self._amount = amount

    def calculate(self, price: float) -> float:
        return max(0.0, price - self._amount)

    def label(self) -> str:
        return f"${self._amount:.2f} off"


# ✅ Adding a new discount: only ADD a new class, never edit existing ones
class BuyOneGetOneDiscount(DiscountCalculator):
    def calculate(self, price: float) -> float:
        return price / 2  # Effectively 50% when buying two

    def label(self) -> str:
        return "Buy One Get One (50% per item)"


# Consumer — closed for modification, works with any current or future discount
class PriceCalculator:
    def __init__(self, discount: DiscountCalculator) -> None:
        self._discount = discount

    def final_price(self, original_price: float) -> float:
        discounted = self._discount.calculate(original_price)
        print(f"  {self._discount.label()}: ${original_price:.2f} → ${discounted:.2f}")
        return discounted


# ── Client ─────────────────────────────────────────────────────────
if __name__ == "__main__":
    print("=== Discount Calculator ===\n")
    original = 100.0

    discounts = [
        NoDiscount(),
        PercentageDiscount(10),
        PercentageDiscount(20),
        FlatDiscount(5),
        BuyOneGetOneDiscount(),
    ]

    for discount in discounts:
        calc = PriceCalculator(discount)
        calc.final_price(original)

Production-Ready Example

Now let's build a notification system — a common real-world scenario where OCP delivers maximum value. A notification system must support multiple channels (email, SMS, push, Slack) and new channels are added regularly. Without OCP, each new channel opens the central dispatcher. With OCP, each channel is an independent implementation added without touching the dispatcher.

🐍 Python

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Sequence


# ── Domain Model ───────────────────────────────────────────────────
@dataclass
class Notification:
    recipient: str
    subject:   str
    body:      str
    priority:  str = "normal"  # "normal" | "urgent"


# ── Stable Abstraction ─────────────────────────────────────────────
class NotificationChannel(ABC):
    """Defines the contract every notification channel must fulfill.
    This interface is stable — it never changes as new channels are added."""

    @abstractmethod
    def can_handle(self, notification: Notification) -> bool:
        """Return True if this channel can handle the given notification."""

    @abstractmethod
    def send(self, notification: Notification) -> bool:
        """Send the notification. Return True on success."""

    @abstractmethod
    def channel_name(self) -> str:
        """Human-readable channel identifier."""


# ── Concrete Channels — each is an independent extension ──────────
class EmailChannel(NotificationChannel):
    """Email delivery via SMTP.
    Changes only when email provider or format changes."""

    def __init__(self, smtp_host: str, from_address: str) -> None:
        self._smtp_host   = smtp_host
        self._from_address = from_address

    def can_handle(self, notification: Notification) -> bool:
        return "@" in notification.recipient  # Valid email address

    def send(self, notification: Notification) -> bool:
        print(f"  [EMAIL via {self._smtp_host}]")
        print(f"    From:    {self._from_address}")
        print(f"    To:      {notification.recipient}")
        print(f"    Subject: {notification.subject}")
        print(f"    Body:    {notification.body[:50]}…")
        return True

    def channel_name(self) -> str:
        return "Email"


class SmsChannel(NotificationChannel):
    """SMS delivery via a carrier API.
    Changes only when the SMS provider or phone number format changes."""

    def __init__(self, api_key: str) -> None:
        self._api_key = api_key

    def can_handle(self, notification: Notification) -> bool:
        return notification.recipient.startswith("+")  # E.164 phone number

    def send(self, notification: Notification) -> bool:
        print(f"  [SMS via carrier API]")
        print(f"    To:   {notification.recipient}")
        print(f"    Body: {notification.body[:160]}")  # SMS length limit
        return True

    def channel_name(self) -> str:
        return "SMS"


class SlackChannel(NotificationChannel):
    """Slack webhook delivery.
    Changes only when the Slack API or message format changes."""

    def __init__(self, webhook_url: str) -> None:
        self._webhook_url = webhook_url

    def can_handle(self, notification: Notification) -> bool:
        return notification.recipient.startswith("#")  # Slack channel

    def send(self, notification: Notification) -> bool:
        print(f"  [SLACK → {notification.recipient}]")
        print(f"    *{notification.subject}*")
        print(f"    {notification.body}")
        return True

    def channel_name(self) -> str:
        return "Slack"


# ✅ New requirement: add push notifications — zero existing class changes
class PushNotificationChannel(NotificationChannel):
    def __init__(self, app_id: str) -> None:
        self._app_id = app_id

    def can_handle(self, notification: Notification) -> bool:
        return notification.recipient.startswith("device:")

    def send(self, notification: Notification) -> bool:
        device_id = notification.recipient.replace("device:", "")
        print(f"  [PUSH → device:{device_id}]")
        print(f"    Title: {notification.subject}")
        print(f"    Body:  {notification.body[:256]}")
        return True

    def channel_name(self) -> str:
        return "Push"


# ── Dispatcher — closed for modification ──────────────────────────
class NotificationDispatcher:
    """Routes notifications to the appropriate channel.
    This class is closed — it never changes as new channels are added."""

    def __init__(self, channels: Sequence[NotificationChannel]) -> None:
        self._channels = channels

    def dispatch(self, notification: Notification) -> bool:
        for channel in self._channels:
            if channel.can_handle(notification):
                print(f"\n  Dispatching via {channel.channel_name()}:")
                success = channel.send(notification)
                if success:
                    print(f"  ✅ Sent via {channel.channel_name()}")
                else:
                    print(f"  ❌ Failed via {channel.channel_name()}")
                return success
        print(f"  ❌ No channel found for recipient: {notification.recipient}")
        return False

    def dispatch_all(self, notification: Notification) -> dict[str, bool]:
        """Send through every capable channel (broadcast mode)."""
        results: dict[str, bool] = {}
        for channel in self._channels:
            if channel.can_handle(notification):
                print(f"\n  Broadcasting via {channel.channel_name()}:")
                results[channel.channel_name()] = channel.send(notification)
        return results


# ── Client ─────────────────────────────────────────────────────────
if __name__ == "__main__":
    dispatcher = NotificationDispatcher(channels=[
        EmailChannel(smtp_host="smtp.company.com", from_address="noreply@company.com"),
        SmsChannel(api_key="sms-api-key-123"),
        SlackChannel(webhook_url="https://hooks.slack.com/services/XXX"),
        PushNotificationChannel(app_id="com.company.app"),
    ])

    print("=== Notification Dispatch ===\n")

    notifications = [
        Notification("alice@company.com", "Q2 Report Ready", "Your Q2 report is now available."),
        Notification("+14155552671", "Login Alert", "New login from IP 1.2.3.4 at 14:32."),
        Notification("#engineering", "Deployment Complete", "v2.4.1 deployed to production ✅"),
        Notification("device:iPhone-ABC123", "New Message", "You have 3 unread messages."),
    ]

    for notif in notifications:
        dispatcher.dispatch(notif)

Real-World Use Cases

OCP is most valuable wherever new variants of a behavior arrive regularly:

Payment Gateways: A PaymentGateway interface with charge() and refund() methods. StripeGateway, PayPalGateway, CryptoGateway implement it. The checkout service depends on the interface — it's closed. Adding a new gateway means adding a new class, nothing more.

Export/Serialization: A Serializer interface with serialize(data) and contentType(). JsonSerializer, XmlSerializer, CsvSerializer, ProtobufSerializer implement it. The export endpoint depends on the interface. Adding PDF export is a new PdfSerializer class.

Authentication Strategies: A AuthStrategy interface with authenticate(credentials). PasswordAuth, OAuthAuth, SamlAuth, ApiKeyAuth implement it. The auth middleware depends on the interface. Adding biometric login is a new class.

Logging/Monitoring: A LogAppender interface. FileAppender, ConsoleAppender, ElasticsearchAppender, DatadogAppender implement it. The logger depends on the interface — it's closed. New monitoring tools are new appenders.

Data Validation Rules: A ValidationRule<T> interface with validate(value). RequiredRule, EmailFormatRule, MaxLengthRule, UniqueEmailRule implement it. The validator executes a list of rules — it's closed. New business validation rules are new rule classes.


Language-Specific Mistakes and Anti-Patterns

❌ Mistake #1: Using isinstance() Checks Instead of Polymorphism

# BAD: isinstance() is a code smell — it's a hidden OCP violation
class ReportExporter:
    def export(self, report, format_type):
        if isinstance(format_type, PdfFormat):
            return self._export_pdf(report)
        elif isinstance(format_type, CsvFormat):
            return self._export_csv(report)
        elif isinstance(format_type, HtmlFormat):
            return self._export_html(report)
        # Every new format = edit this method

# GOOD: Polymorphic dispatch — no isinstance needed
class ReportExporter:
    def export(self, report, formatter: ReportFormatter) -> str:
        return formatter.format(report)  # Each formatter knows how to format itself

❌ Mistake #2: Growing if/elif Chains in Processing Functions

# BAD: Adding a new image filter means opening this function
def apply_filter(image, filter_name: str):
    if filter_name == "grayscale":
        return image.convert("L")
    elif filter_name == "blur":
        return image.filter(ImageFilter.BLUR)
    elif filter_name == "sharpen":
        return image.filter(ImageFilter.SHARPEN)
    # New filter = new elif

# GOOD: Each filter is a callable; add new filters without touching existing code
class GrayscaleFilter:
    def apply(self, image): return image.convert("L")

class BlurFilter:
    def apply(self, image): return image.filter(ImageFilter.BLUR)

def apply_filter(image, image_filter):
    return image_filter.apply(image)  # Closed — no conditionals

Frequently Asked Questions

Q1: Does OCP mean I should create an interface for every class?

No. OCP does not demand interfaces everywhere — it demands that extension points be defined where variability exists or is expected. A UserRepository that saves to a database doesn't need an abstraction if you never intend to swap the database. The trigger for OCP is the second variant: when you have two implementations of the same concept, define an abstraction. Don't build the abstraction for one implementation hoping you'll need it someday.

Q2: Isn't OCP just the Strategy Pattern?

Strategy is the most direct implementation of OCP, but OCP is the broader principle. Strategy captures OCP in a specific structural form: the context is closed; strategies extend it. OCP also manifests through Template Method (the template is closed; subclasses extend steps), Decorator (the core is closed; decorators add behavior), and Plugin architecture (the system is closed; plugins extend it). OCP is the goal; Strategy (and others) is one way to achieve it.

Q3: What if the abstraction turns out to be wrong?

This is the real challenge of OCP: designing the right abstraction. A wrong abstraction — one that fits the first two cases but not the third — forces more modification than it prevents. The solution is not to abstract too early. Wait for the second variant to appear: it reveals the true shape of the extension point. Don't guess the abstraction; let real requirements teach it to you. When the abstraction proves wrong, refactor it — the cost of one refactor to fix a bad abstraction is lower than the cost of perpetual modifications to a closed system.

Q4: Is it ever OK to modify an existing class?

Yes. OCP says "closed for modification" in the context of adding new behavior variants. Fixing bugs in an existing class is not a violation — bugs are defects, not extensions. Improving performance of an existing implementation is not a violation. The "closed" part applies specifically to adding new variants of behavior: a new discount type, a new payment provider, a new export format. Those should arrive as new classes, not as edits to existing ones.

Q5: How do I apply OCP to free functions (not classes)?

In functional programming, OCP applies at the function level through higher-order functions and function composition. A function that accepts another function as a parameter is open for extension: you extend it by passing a new function. A function that checks if type == "A" is closed to extension — adding type "B" requires editing it. Higher-order functions are the functional equivalent of the Strategy pattern: the higher-order function is closed; the functions passed to it are extensions.

Q6: Can OCP lead to too many classes?

Yes — premature abstraction is a real risk. Creating an interface and ten implementations for something that only ever has one variant is overhead without value. The discipline is to apply OCP reactively: the first time you see a second variant, create the abstraction. Not before. "Speculative generality" — building extension points for variants that never arrive — creates complexity without benefit.

Q7: How does OCP relate to the "no switch statements" rule?

The rule isn't "no switch statements ever." It's "no switch statements that must grow as new variants are added." A switch on a fixed set of constants (days of the week, HTTP methods) that will never change is fine. A switch on a type that keeps growing (discount types, payment providers, notification channels) is an OCP violation waiting to happen. The test: does adding a new variant require opening this switch? If yes, the switch is violating OCP and should be replaced with polymorphism.

Q8: How do I refactor a class with a large if/else chain to comply with OCP?

  1. Identify the varying behavior — each branch of the if/else represents one variant.
  2. Define the abstraction — extract an interface with the common method signature shared by all branches.
  3. Extract each branch into a class — create one class per branch, each implementing the interface.
  4. Remove the conditional from the consumer — the consumer now holds a reference to the interface and calls it directly.
  5. Wire at the composition root — decide which implementation to inject at startup or based on configuration.

Key Takeaways

🎯 Core Concept: The Open/Closed Principle states that software entities should be open for extension but closed for modification. New behaviors arrive as new code (new classes, new implementations), not as edits to existing, working code. The mechanism is abstraction: a stable interface that existing code depends on, and new implementations that extend the behavior by implementing that interface.

🔑 Key Benefits:

  • Regression safety: Existing, tested classes are never opened — their behavior after testing is permanent
  • Parallel development: Multiple developers can add multiple extensions simultaneously without file conflicts
  • Predictable growth: The 10th extension is as safe as the 2nd — no existing code ever changes
  • Isolated testing: Each extension is independently testable without touching the integration

🌐 Language-Specific Highlights:

  • Python: Replace isinstance() checks and if/elif chains on type strings with ABC-based abstractions; use @abstractmethod to enforce the contract on all implementations
  • TypeScript: Replace discriminated union switch statements with interface-based dispatch; the compiler enforces the contract on every implementor
  • Java: Replace enum + switch tax/payment/export logic with interface-based strategies; use record for immutable value-carrying implementations
  • JavaScript: Use class-based implementation of a duck-typed protocol; register new handlers in a list rather than adding them to a switch
  • C#: Inject interface dependencies via the constructor; use IOptions<T> or factory patterns to select implementations at the composition root
  • PHP: Use constructor promotion with readonly for implementation-specific config; avoid static factory methods that switch on strings
  • Go: Define interfaces at the consumer side, not the provider side; embed interfaces in structs for zero-cost composition and extension
  • Rust: Use Box<dyn Trait> for runtime polymorphism; use generics with trait bounds for compile-time polymorphism — both are OCP-compliant
  • Dart: Use abstract interface class for the stable contract; use const constructors on stateless implementations for memory efficiency
  • Swift: Use protocol for the abstraction; store as any Protocol for heterogeneous collections; avoid enum with growing switch for behavior dispatch
  • Kotlin: Use interface for extensions; use object declarations for stateless singleton implementations; use sealed interface only when the set is intentionally closed and exhaustive

📋 When to Apply OCP:

  • A class has a growing if/else or switch statement where each branch represents a different variant of the same behavior
  • You are adding the same type of behavior for the second time (second payment provider, second export format, second notification channel)
  • Multiple team members are competing to edit the same class for different features
  • A change to one variant breaks the tests for another variant
  • You need to test a behavior variant in isolation without the overhead of the full class

⚠️ When NOT to Over-Apply OCP:

  • There is only one implementation and no realistic prospect of a second — abstraction adds indirection without value
  • The "variation" is actually a configuration difference, not a behavioral difference — use parameters instead of classes
  • The abstraction would be so specific that only one class could ever implement it
  • You are adding OCP to legacy stable code that never changes — the risk of refactoring outweighs the benefit

🛠️ Best Practices Across Languages:

  1. Wait for the second variant: Define the abstraction when the second implementation appears — the first teaches the shape; the second confirms it
  2. Name the abstraction after the concept, not the implementation: DiscountCalculator not PercentageDiscountBase; NotificationChannel not EmailBasedChannel
  3. Keep the abstraction minimal: The interface should declare only what consumers actually need — no methods that exist solely for one implementation
  4. Wire at the composition root: Let the main function, DI container, or application startup decide which implementation to inject — not the closed classes themselves
  5. Use the Registry Pattern for large extension points: Store a map of type → implementation and look up at dispatch time — adding a new type means adding one map entry

💡 Common Pitfalls:

  • The "closed too early" trap: Closing a class before the true extension point is clear leads to an abstraction that fights the third variant
  • Interface bloat: Adding methods to the abstraction that only one or two implementations use — this creates forced empty implementations and is an ISP violation
  • Leaking concrete types through the abstraction: Returning StripeCharge from a PaymentGateway.charge() method couples the consumer to Stripe — abstractions must return abstract types
  • Closing the wrong thing: Closing the implementations and keeping the dispatcher open — it should be the opposite
  • Premature abstraction: Building a six-interface hierarchy for a feature that only ever has one concrete implementation — speculation, not principle

The Open/Closed Principle is the principle that makes systems grow gracefully. In a codebase that follows OCP, adding new features is as safe as it is on day one — existing tests keep passing, existing classes are untouched, and the blast radius of every change is bounded by the walls of a single new class.


Want to dive deeper into SOLID principles? Check out our comprehensive guides on: