Imagine that you are designing a house with modular architecture. You want to create a design that allows the owners to add new rooms or sections to the house, without having to demolish any existing walls or structures.
To achieve this, you would need to design the house in such a way that it can easily accommodate new additions. You would need to plan and ensure that the existing structure is flexible enough to accommodate changes, without requiring any major modifications.
Sounds too good to be true? well, it’s true. Welcome to the Open-Closed Principle.
The open-closed principle (OCP) of object-oriented programming states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
In simpler terms, it means that you should be able to add new features to your software without having to change the existing code that’s already working. That code will be left untouched.
A good question to ask ourselves when implementing this principle will be: Will I need to modify the existing code when I will add a new functionality?
Why is it important that we will build our project using this principle? Well, there are several motives to use this principle:
Maintainability — As a project progresses, the codebase can become increasingly complex, making it challenging to maintain and add new functionality. If you’ve ever struggled to add new features to a project that has grown in complexity, you know firsthand how frustrating this can be. The open-close principle can keep your codebase stable and maintainable, even as your project evolves.
Reusability — By designing software components that are modular and easy to extend, we can reuse existing code to implement new features or functionality. This can save us time and effort, as we don’t need to start from scratch whenever we want to add something new to our software.
Ok, let’s look at an example that violates the open-closed principle and analyze it:
class PaymentService {
constructor(amount, paymentProvider) {
this.amount = amount
this.paymentProvider = paymentProvider
}
makePayment() {
switch (this.paymentProvider) {
case 'credit':
this.creditCardPayment()
break
case 'paypal':
this.paypalPayment()
break
}
}
creditCardPayment() {
console.log(`Performing credit payment of ${this.amount} dollars`)
}
paypalPayment() {
console.log(`Performing paypal payment of ${this.amount} dollars`)
}
}
The problem with this code is that we have to make changes to the switch statement if we want to use another payment provider, like Stripe. Let’s see:
class PaymentService {
constructor(amount, paymentProvider) {
this.amount = amount
this.paymentProvider = paymentProvider
}
makePayment() {
switch (this.paymentProvider) {
case 'credit':
this.creditCardPayment()
break
case 'paypal':
this.paypalPayment()
break
// THIS PART IS NEW IN THE SWITCH STATEMENT
case 'stripe':
this.stripePayment()
break
}
}
creditCardPayment() {
console.log(`Performing credit payment of ${this.amount} dollars`)
}
paypalPayment() {
console.log(`Performing paypal payment of ${this.amount} dollars`)
}
stripePayment(){
console.log(`Performing stripe payment of ${this.amount} dollars`)
}
}
So each time we add a new payment provider the switch statement will have to change, and we will violate the open-closed principle.
A good approach for implementing the open-closed principle is to use the Strategy design pattern. the Strategy pattern is a design pattern that lets you define a family of algorithms, encapsulate each one of them, and make them interchangeable. This way, you can switch the algorithm that you’re using at runtime, without having to change the context that uses it. This leads to more flexible and maintainable code.
Let’s see it in action:
First, we will create an interface or base class called PaymentProvider that declares a method for making payments. This will serve as the strategy interface:
class PaymentProvider {
makePayment(amount) {
throw new Error('makePayment method must be implemented') // We throw an error so that we know we are not supposed to use this function, but override it instead
}
}
Next, we will separate classes for each payment provider that implements the PaymentProvider interface and provide their implementation of the makePayment method:
class CreditCardPaymentProvider extends PaymentProvider {
makePayment(amount) {
console.log(`Performing credit payment of ${amount} dollars`)
}
}
class PaypalPaymentProvider extends PaymentProvider {
makePayment(amount) {
console.log(`Performing PayPal payment of ${amount} dollars`)
}
}
class StripePaymentProvider extends PaymentProvider {
makePayment(amount) {
console.log(`Performing Stripe payment of ${amount} dollars`)
}
}
Now, we will modify the PaymentService class to accept an instance of PaymentProvider during construction instead of a payment provider string:
class PaymentService {
constructor(amount, paymentProvider) {
this.amount = amount
this.paymentProvider = paymentProvider
}
makePayment() {
this.paymentProvider.makePayment(this.amount)
}
}
Now we can add as many payment providers as we want, and we won’t need to change the existing code.
The Open-Closed Principle is a fundamental concept in SOLID programming that helps us write more robust and maintainable code. By understanding and applying this principle, we can build software that is more resilient to change and easier to manage.
Happy coding :)
Also published here.