SOLID Principles: Open Closed Principle

SOLID Principles: Open Closed Principle

Introduction

Imagine software that embraces change effortlessly, evolving gracefully without breaking under the weight of modifications. That's the essence of the Open-Closed Principle (OCP) in SOLID design.

Here's the key idea:

  • Open for extension: Your code should welcome new features and adaptations without requiring modifications to its existing structure.

  • Closed for modification: Once a class or module is thoroughly tested and working flawlessly, it should remain sealed off from direct changes.

How does OCP achieve this magic?

  • Abstractions to the Rescue: Rely on interfaces and abstract classes to define contracts for behavior, allowing concrete implementations to vary without disrupting the core structure.

  • Delegation for Flexibility: Employ techniques like factories and dependency injection to select and manage concrete implementations at runtime, fostering adaptability.

Benefits of Embracing OCP:

  • Reduced Risk of Bugs: By minimizing code changes, you lower the chances of introducing new errors and maintain stability.

  • Improved Maintainability: Code that adheres to OCP becomes easier to understand, navigate, and modify over time.

  • Enhanced Flexibility: Your software can elegantly accommodate new features and requirements without disrupting its core foundation.

  • Promotes Testability: Dependency injection and loose coupling make code more testable, ensuring quality and reliability.

Ready to unlock the secrets of OCP? In the next article, we'll dive into practical examples and explore how to apply this principle effectively in your projects. Stay tuned!

Without OCP

  • You create a Notification class that directly sends emails, text messages, and push notifications.

  • When a new notification channel like WhatsApp is needed, you must modify the Notification class, potentially introducing bugs and making it harder to maintain.

public class Notification {

    public void sendEmail(String message, String emailAddress) {
        // Code to send email
    }

    public void sendTextMessage(String message, String phoneNumber) {
        // Code to send text message
    }

    public void sendPushNotification(String message, String deviceId) {
        // Code to send push notification
    }

    // New method added for WhatsApp notification
    public void sendWhatsAppNotification(String message, String whatsappId) {
        // Code to send WhatsApp notification
    }
}

Problems with this approach:

  • Tightly coupled: The Notification class is directly responsible for handling multiple notification channels, making it rigid and less adaptable.

  • Hard to maintain: Adding a new channel (like WhatsApp) requires modifying the Notification class, potentially introducing bugs and increasing complexity. What about a third channel? a fourth on? etc.

  • Testing challenges: Testing each notification channel independently becomes difficult as they're all intertwined within the same class.

  • Risk of errors: Changes to one channel's logic could inadvertently affect others, leading to unexpected issues.

  • Limited extensibility: Adding further channels in the future would require continuous modifications to this core class, hindering its maintainability over time.

Abstractions to the Rescue

This example highlights the importance of adhering to the Open-Closed Principle to create flexible, maintainable, and testable code that can gracefully accommodate change.

Rely on interfaces and abstract classes to define contracts for behavior, allowing concrete implementations to vary without disrupting the core structure.

public interface NotificationChannel {
    void sendNotification(String message);
}

public class EmailNotification implements NotificationChannel {
    @Override
    public void sendNotification(String message) {
        // Send email logic
    }
}

public class TextNotification implements NotificationChannel {
    @Override
    public void sendNotification(String message) {
        // Send text message logic
    }
}

// Later, add WhatsAppNotification as needed

public class WhatsappNotification implements NotificationChannel {
    @Override
    public void sendNotification(String message) {
        // Send whatsapp message logic
    }
}


public class Notification {
    private NotificationChannel channel;

    public Notification(NotificationChannel channel) {
        this.channel = channel;
    }

    public void send(String message) {
        channel.sendNotification(message);
    }
}

Benefits:

  • Flexibility: Add new notification channels without touching the Notification class.

  • Testability: Easily test different notification channels independently.

  • Maintainability: Changes to one channel don't affect others.

  • Decoupling: The core Notification class is no longer tied to specific implementations.

Delegation for Flexibility

Employ techniques like factories and dependency injection to select and manage concrete implementations at runtime, fostering adaptability.

1. Introduce a factory:

We'll introduce later what is factory design pattern, and how we can employ it.

public class NotificationFactory {
    public static Notification createNotification(String type) {
        if (type.equals("email")) {
            return new Notification(new EmailNotification());
        } else if (type.equals("text")) {
            return new Notification(new TextNotification());
        } else if (type.equals("whatsapp")) {
            return new Notification(new WhatsAppNotification()); // Easily add new channels
        } else {
            throw new IllegalArgumentException("Invalid notification type");
        }
    }
}

2. Use dependency injection:

public class UserService{
private final NotificationFactory notifcationFactory;
public UserService(NotificationFactory notifcationFactory)
{
        this.notifcationFactory = notifcationFactory;
}
public static void sendWelcomeEmail(User newUser) {
    Notification notification = NotificationFactory.createNotification("email");
    notification.send("Hello from email!: " + newUser.getUserName());
}
}

Key takeaways:

  • Delegation shifts responsibility to other classes, making code more adaptable.

    • A class acting as a factory.

    • Separate classes for each channel

    • A notification class injected in it the notification channel interface.

  • Factories encapsulate object creation logic, centralizing configuration and simplifying client code.

  • Dependency injection promotes loose coupling and testability by injecting dependencies rather than creating them within classes.

  • Loose coupling: UserService doesn't depend on specific notification implementations, promoting flexibility and testability.


With the Open-Closed Principle under our belt, we’re now equipped to build software that evolves gracefully, adding new features without fracturing existing functionality. Buckle up, because next we'll explore the Liskov Substitution Principle, the key to ensuring seamless replacements within our code's architecture. Prepare to witness the power of subclasses that truly stand in for their parents.