Open/Closed Principle

↝ It is said that our code should be open for extension but closed for modification. What does this mean? Well, at some point, you may want to extend the functionality of a class, but modification is restricted to ensure bug-free code—thus, it must remain closed for modification.

In Open/Closed principle, new features should be placed in new classes, and existing code should only be intended for bug fixing. This approach allows you to add new functionality to a class without altering its existing code.

Let's create a scenario: you've been assigned to develop a payment system for an application. This means the application should accept various of payment methods like credit card, PayPal or wire transfers. Your code might be something like this:

type SupportedPayments = "credit" | "paypal" | "wire";

class Payment {
  payWithCreditCard() {
    // Logic for paying with credit card
  }

  payWithPaypal() {
    // Logic for paying with PayPal
  }

  payWithWireTransfer() {
    // Logic for paying with wire transfer
  }
}

class PaymentController {
  pay(mode: SupportedPayments) {
    // Logic before handling payments

    const payment = new Payment();
    if (mode === "credit") {
      payment.payWithCreditCard();
    } else if (mode === "paypal") {
      payment.payWithPaypal();
    } else if (mode === "wire") {
      payment.payWithWireTransfer();
    }

    throw new Error("Unsupported payment mode" + mode);
  }
}

So what's the problem here? You may have noticed that whenever we add a new payment method, like paypal or wire transfer, the PaymentController needs to be modified. Modification in this context implies that the PaymentController needs to know all possible payments. For instance, Bitcoin might not have been initially considered as a valid payment method, but today, some applications accept it. This means that this class violates the open/closed principle by being open for modification.

What can be improved?

  • Since methods have their own way of implementing their own payment mode. Why not we utilize abstraction, one of the pillars of OOP? This means we are taking away the implementation details from Payment class and create separate classes for each methods.

  • We can also employ inheritance by defining an interface as a blueprint. This interface would specify all the required properties and methods that need to be implemented for a payment method to function. For example, if want to incorporate bitcoin as one of payment methods, we can implement all necessary details for it to work.

type SupportedPayments = "credit" | "paypal" | "wire" | "cod";

interface IPayable {
  pay(): void;
}

class CreditCardPayment implements IPayable {
  pay() {
    // Logic for paying with credit card
  }
}

class PaypalPayment implements IPayable {
  pay() {
    // Logic for paying with PayPal
  }
}

class WireTransferPayment implements IPayable {
  pay() {
    // Logic for paying with wire transfer
  }
}

class BitcoinPayment implements IPayable {
  pay() {
    // Logic for paying with bitcoin
  }
}

class PaymentController {
  pay(payment: IPayable) {
   // Logic before handling payments
    payment.pay();
  }
}

So what we did here?

  • The pay method in the PaymentController class now accepts any instance of payment as a parameter. This means that when calling the pay method, you're specifying the desired payment mode for that particular instance, and it's expected to function accordingly because it knows how to handle it.

  • By abstracting away the implementation details, we've effectively closed the PaymentController class for modification. It's no longer tightly coupled to a specific implementation, allowing subclasses to implement their own payment logic. This is achieved through abstraction.

  • The PaymentController class is now open for extension. We've accomplished this by defining an interface called IPayable, which allows for the extension of payment methods through implementation of the interface. The pay method in PaymentController accepts any object that adheres to the IPayable interface.

To sum up, there are times that you need to add more functionalities to your code. Again, change creates ripple effect, meaning, adding parts to your code could break something else. How can you ensure that your class doesn't violate open/closed principle? Ask your self this question: if a new feature needs to be implemented, how can I do it without altering the existing code? From there, you might consider OOP pillars like abstraction and inheritance and begin by creating an interface. Thank you and happy coding!