Understanding the Open/Closed Principle: A Ruby Perspective

Feb 12, 2017

Introduction

After exploring the Single Responsibility Principle, let's dive into the "O" in SOLID - the Open/Closed Principle (OCP). This principle states:

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification

At first glance, this might seem contradictory. How can something be open to extension yet closed for modification? Let's explore this concept through a practical example.

The Initial Problem

Consider a payment processing system for an e-commerce platform:

class PaymentProcessor
  def process_payment(order)
    # Hard-coded to only handle credit card payments
    if order.total > 0
      CreditCardPayment.charge(
        amount: order.total,
        card_number: order.credit_card_number,
        expiry: order.card_expiry,
        cvv: order.cvv
      )
    end
  end
end

While this code works for credit card payments, it's not open for extension. If we want to add PayPal, crypto, or bank transfer payments, we'd need to modify the existing code. This violates the Open/Closed Principle.

Applying the Open/Closed Principle

Step 1: Dependency Injection

First, let's improve the design using dependency injection:

class PaymentProcessor
  def process_payment(order, payment_method = CreditCardPayment.new)
    payment_method.process(order) if order.total > 0
  end
end

This simple change brings significant flexibility:

  • Existing code continues to work (backwards compatibility)
  • Credit card payment remains the default
  • We can now inject different payment methods

Step 2: Creating a Common Interface

Let's create a payment interface that all payment methods must implement:

class PaymentMethod
  def process(order)
    raise NotImplementedError
  end
end

class CreditCardPayment < PaymentMethod
  def process(order)
    # Process credit card payment
    charge(
      amount: order.total,
      card_number: order.credit_card_number,
      expiry: order.card_expiry,
      cvv: order.cvv
    )
  end

  private

  def charge(amount:, card_number:, expiry:, cvv:)
    # Implementation for credit card processing
  end
end

class PayPalPayment < PaymentMethod
  def process(order)
    # Process PayPal payment
    initiate_paypal_transaction(
      amount: order.total,
      email: order.paypal_email
    )
  end

  private

  def initiate_paypal_transaction(amount:, email:)
    # Implementation for PayPal processing
  end
end

class CryptoPayment < PaymentMethod
  def process(order)
    # Process cryptocurrency payment
    create_crypto_transaction(
      amount: order.total,
      wallet_address: order.crypto_wallet
    )
  end

  private

  def create_crypto_transaction(amount:, wallet_address:)
    # Implementation for crypto processing
  end
end

Using the Improved Design

Now we can easily add new payment methods:

# Process with different payment methods
processor = PaymentProcessor.new

# Credit Card Payment (default)
processor.process_payment(order)

# PayPal Payment
processor.process_payment(order, PayPalPayment.new)

# Crypto Payment
processor.process_payment(order, CryptoPayment.new)

Real-World Extension Example

Let's say we need to add support for Apple Pay. With our new design, we simply create a new class:

class ApplePayPayment < PaymentMethod
  def process(order)
    verify_device_token(order.device_token)
    process_apple_pay_transaction(
      amount: order.total,
      token: order.payment_token
    )
  end

  private

  def verify_device_token(token)
    # Verify Apple Pay device token
  end

  def process_apple_pay_transaction(amount:, token:)
    # Process Apple Pay transaction
  end
end

# Use the new payment method
processor.process_payment(order, ApplePayPayment.new)

Benefits

  1. Flexibility: Easy to add new payment methods without changing existing code
  2. Reduced Risk: No need to modify working payment processing code
  3. Better Testing: Each payment method can be tested independently
  4. Maintainability: Clear separation between payment methods
  5. Code Reuse: Common interface ensures consistent implementation

Key Principle

As stated in the literature:

Design our modules, classes and functions in a way that when a new functionality is needed, we should not modify our existing code but rather write new code that will be used by existing code.

Our refactored payment system achieves this by:

  • Defining a common interface for all payment methods
  • Allowing new payment methods to be added without modifying the processor
  • Maintaining backward compatibility with existing implementations

Conclusion

The Open/Closed Principle helps us create more flexible and maintainable code by:

  • Using dependency injection to remove hard-coded dependencies
  • Creating common interfaces through base classes
  • Allowing for extension through inheritance or composition

In our payment processing example, we can now add support for any new payment method without touching the core payment processing code. This makes our system more robust and easier to maintain as requirements evolve.

Mirzalazuardi Hermawan