2. Open-Closed Principle

2. Open-Closed Principle

Balancing Extension and Stability in Software Design

Premise

Picture your favorite storybook – vivid characters, captivating plots, and a world that unfolds with every turn of the page. Now, imagine you have a superpower! A superpower through which you can add new chapters to this beloved story without altering the existing narrative. Every new adventure seamlessly integrating with the said storyline, introducing fresh characters and plot twists that enhance the overall experience. Your ablility to add new chapters without altering the existing narrative, is akin to the essence of the Open-Closed Principle in software development.

In software engineering, the above discussed superpower is equivalent of having access to the code base of the software application! This concept is a powerful design principle that empowers developers to extend a system's behavior without modifying its existing code.

Formal definition

The Open-Closed Principle (OCP), coined by Bertrand Meyer, suggests that

A class should be open for extension but closed for modification.

In simpler terms, this means you should be able to add new functionality without altering the existing codebase.

Practical Example

Let's take a look at a code base which doesn't follow the Open-Closed Principle:

public class ShoppingCart {
    public double calculateTotalPrice(List<Item> items) {
        double totalPrice = 0;
        for (Item item : items) {
            if (item.getType().equals("Book")) {
                totalPrice += item.getPrice() * 0.9; // 10% discount on books
            } else if (item.getType().equals("Electronic")) {
                totalPrice += item.getPrice() * 1.2; // 20% markup on electronics
            }
            // Imagine more types and calculations added here...
        }
        return totalPrice;
    }
}

Clearly in the above code, as and when any new Item type is added, more type calculations would need to be added. This kind of code not just violates the Open-Closed Principle by touching the existing code, but also makes maintenance of the code a tough nut to crack!

A better approach would be such, which would not require you to touch exisitng code, yet allow code extensibility. Let's take a look at the code below and observe how it offers flexibility to add more item types without touching (and breaking) the existing code:

interface DiscountStrategy {
    double applyDiscount(double price);
}

// Discount strategy for Books
class BookDiscountStrategy implements DiscountStrategy {
    @Override
    public double applyDiscount(double price) {
        return price * 0.9; // 10% discount on books
    }
}

// Discount strategy for Electronics
class ElectronicMarkupStrategy implements DiscountStrategy {
    @Override
    public double applyDiscount(double price) {
        return price * 1.2; // 20% markup on electronics
    }
}

// Shopping cart class
class ShoppingCart {
    private final List<Item> items;

    public ShoppingCart(List<Item> items){
        this.items = items;
    }

    public double calculateItemPrice(Item item) {
        // Dynamic behaviour basis DiscountStrategy types
        DiscountStrategy strategy = item.getDiscountStrategy();
        return strategy.applyDiscount(item.getPrice());
    }

    public double calculateTotalPrice(){
        double totalPrice = 0.0;
        for(Item item: items)
            totalPrice += calculateItemPrice(item);
        return totalPrice;
    }
}

Here, the ShoppingCart class is open for extension by accepting new DiscountStrategy implementations, adhering to the Open-Closed Principle. So if tomorrow, there comes a need to add capability to process new items of type food, you just need to add the following snippet and the application would run without breaking a sweat:

// Discount strategy for Foods
class FoodDiscountStrategy implements DiscountStrategy {
    @Override
    public double applyDiscount(double price) {
        return price * 0.95; // 5% discount on food items
    }
}

I have provided the full code you can try for yourself. You can also refer to this code in this gist.

import java.util.ArrayList;
import java.util.List;

// Interface for discount strategy
interface DiscountStrategy {
    double applyDiscount(double price);
}

// Discount strategy for Books
class BookDiscountStrategy implements DiscountStrategy {
    @Override
    public double applyDiscount(double price) {
        return price * 0.9; // 10% discount on books
    }
}

// Discount strategy for Electronics
class ElectronicMarkupStrategy implements DiscountStrategy {
    @Override
    public double applyDiscount(double price) {
        return price * 1.2; // 20% markup on electronics
    }
}

// Shopping cart class
class ShoppingCart {
    private final List<Item> items;

    public ShoppingCart(List<Item> items){
        this.items = items;
    }

    public double calculateItemPrice(Item item) {
        // Dynamic behaviour basis DiscountStrategy types
        DiscountStrategy strategy = item.getDiscountStrategy();
        return strategy.applyDiscount(item.getPrice());
    }

    public double calculateTotalPrice(){
        double totalPrice = 0.0;
        for(Item item: items)
            totalPrice += calculateItemPrice(item);
        return totalPrice;
    }
}

// Item class
class Item {
    private final String type;
    private final double price;
    private DiscountStrategy discountStrategy;

    public Item(String type, double price, DiscountStrategy discountStrategy) {
        this.type = type;
        this.price = price;
        this.discountStrategy = discountStrategy;
    }

    public String getType() {
        return type;
    }

    public double getPrice() {
        return price;
    }

    public DiscountStrategy getDiscountStrategy(){
        return discountStrategy;
    }
}

public class Main {
    public static void main(String[] args) {
        // List of items
        List<Item> items = new ArrayList<Item>();

        // Creating Book items and discount
        DiscountStrategy bookDiscount = new BookDiscountStrategy();
        Item book = new Item("Book", 50.0, bookDiscount);
        items.add(book);

        // Creating Electronic items and discount
        DiscountStrategy electronicStrategy = new ElectronicMarkupStrategy();
        Item tv = new Item("Electronic", 100.0, electronicStrategy);
        items.add(tv);

        // Building a shopping cart
        ShoppingCart shoppingCart = new ShoppingCart(items);

        // Calculating total price for items
        double totalPriceForBook = shoppingCart.calculateItemPrice(book);
        double totalPriceForTv   = shoppingCart.calculateItemPrice(tv);
        double totalCartPrice    = shoppingCart.calculateTotalPrice();

        // Displaying results
        System.out.println("Total Price for Book    : $" + totalPriceForBook);  // Should be 45
        System.out.println("Total Price for TV      : $" + totalPriceForTv);    // Should be 120
        System.out.println("Total Price entire cart : $" + totalCartPrice);     // Should be 165
    }
}

Advantages

  • Flexibility

    Easily extend functionality without altering existing code

  • Maintainability

    Promotes cleaner code by avoiding frequent modifications to existing classes

Caveat/Note

  • Abstraction Overhead

    Introducing interfaces and strategies can add complexity

  • Learning Curve

    Developers need to be familiar with the strategy pattern

Summary

Embracing the Open-Closed Principle liberates your code from the troubles of constant modification. By allowing for seamless extension, it fosters flexibility and maintainability. While introducing abstractions may initially seem daunting, the long-term benefits far outweigh the overhead.