Open/Closed Principle
is the second principle in SOLID
principles, it states that, a software entity should be open for extension, but closed for modification.
A software entity is a class, module, function, etc. Basically, it is a piece of code that does something.
Basically, you should be able to extend the behavior of a software entity without modifying its source code.
Actually, for many reasons, let's list them:
Maintainable
,Your code will be more maintainable, because you will not need to modify the source code of the software entity.Testable
, as it does not require modification, you will not need to modify the tests.Reusable
, you can use the software entity in other places which can be easily extended.Scalable
, add more features or functions without the need to modify the original source code.Let's say we have a User
class, and we want to add a new feature to it, which is sendEmail
function, which sends an email to the user.
class User { constructor(public name: string, public email: string) {} sendEmail() { console.log(`Sending email to ${this.name} at ${this.email}`); } }
Now, we want to add a new feature to the User
class, which is sendSMS
function, which sends an SMS to the user.
class User { constructor(public name: string, public email: string) {} sendEmail() { console.log(`Sending email to ${this.name} at ${this.email}`); } sendSMS() { console.log(`Sending SMS to ${this.name} at ${this.email}`); } }
Now, we want to add a new feature to the User
class, which is sendPushNotification
function, which sends a push notification to the user.
class User { constructor(public name: string, public email: string) {} sendEmail() { console.log(`Sending email to ${this.name} at ${this.email}`); } sendSMS() { console.log(`Sending SMS to ${this.name} at ${this.email}`); } sendPushNotification() { console.log(`Sending Push Notification to ${this.name} at ${this.email}`); } }
Now, we want to add a new feature to the User
class, which is sendWhatsAppMessage
function, which sends a WhatsApp message to the user.
class User { constructor(public name: string, public email: string) {} sendEmail() { console.log(`Sending email to ${this.name} at ${this.email}`); } sendSMS() { console.log(`Sending SMS to ${this.name} at ${this.email}`); } sendPushNotification() { console.log(`Sending Push Notification to ${this.name} at ${this.email}`); } sendWhatsAppMessage() { console.log(`Sending WhatsApp Message to ${this.name} at ${this.email}`); } }
This is good, but we still have to modify the User
class every time we want to add a new feature, which is not totally good, because we are modifying the User
class and make it over complicated.
Now let's head to the Open/Closed Principle
solution.
The idea is simple, we will make a contract A.K.A interface, which will be used among variant classes, and each class will implement the contract, and add its own implementation.
The point here is to make the contract as generic as possible, to allow multiple communication channels without touching the original User class
.
interface CommunicationChannel { send(user: User): void; }
class EmailChannel implements CommunicationChannel { send(user: User) { console.log(`Sending email to ${user.name} at ${user.email}`); } }
Now we can add as many communication channels as we want, without touching the User
class.
class SMSChannel implements CommunicationChannel { send(user: User) { console.log(`Sending SMS to ${user.name} at ${user.email}`); } }
class PushNotificationChannel implements CommunicationChannel { send(user: User) { console.log(`Sending Push Notification to ${user.name} at ${user.email}`); } }
class WhatsAppChannel implements CommunicationChannel { send(user: User) { console.log(`Sending WhatsApp Message to ${user.name} at ${user.email}`); } }
Let's take another example, we have a Shape
and an AreaCalculator
classes, and we want to add a new shape to the AreaCalculator
class.
class Shape { constructor(public type: string) {} } class AreaCalculator { constructor(public shapes: Shape[]) {} area() { return this.shapes.reduce((sum, shape) => { if (shape.type === "circle") { sum += Math.PI * Math.pow(shape.radius, 2); } else if (shape.type === "square") { sum += Math.pow(shape.side, 2); } else if (shape.type === "rectangle") { sum += shape.width * shape.height; } return sum; }, 0); } }
What if we want to add a new shape, let's say triangle
, we will have to modify the AreaCalculator
class, which is not good.
class Shape { constructor(public type: string) {} } class AreaCalculator { constructor(public shapes: Shape[]) {} area() { return this.shapes.reduce((sum, shape) => { if (shape.type === "circle") { sum += Math.PI * Math.pow(shape.radius, 2); } else if (shape.type === "square") { sum += Math.pow(shape.side, 2); } else if (shape.type === "rectangle") { sum += shape.width * shape.height; } else if (shape.type === "triangle") { sum += (shape.base * shape.height) / 2; } return sum; }, 0); } }
TOO BIG, is'nt it? let's break it down.
interface Shape { area(): number; }
class SquareShape implements Shape { public constructor(public side: number) {} public area() { return this.side * this.side; } }
class CircleShape implements Shape { public constructor(public radius: number) {} public area() { return Math.PI * Math.pow(this.radius, 2); } }
class RectangleShape implements Shape { public constructor(public width: number, public height: number) {} public area() { return this.width * this.height; } }
class TriangleShape implements Shape { public constructor(public base: number, public height: number) {} public area() { return (this.base * this.height) / 2; } }
class AreaCalculator { public constructor(public shapes: Shape[]) {} public sum() { return this.shapes.reduce((total, shape) => { return total + shape.area(); }, 0); } }
Clean, clear, easy to read and maintainable!
Open/Closed principle is very essential and crucial when you build your software architecture, it will make your code more maintainable, testable, reusable and scalable.