Open/Closed Principle (OCP)

The definition of OCP that I had mentioned in this blog is this:

Software entities (classes, functions, modules, or methods) should be open for extension but closed for modification. This allows you to add new functionality without changing existing code.

Let us understand this principle in depth, with the example of Mobile phones. Just like we had an interface and a class implementing that interface in the SRP blog example, we introduce similar interface and a class that implements the same. The code snippet below is written in Typescript.

interface MobilePhone {
    brand: string;
    model: string;
    price: number;

    // Additional method to describe the phone
    getDescription(): string;
}

class BasicMobilePhone implements MobilePhone {
    brand: string;
    model: string;
    price: number;

    constructor(brand: string, model: string, price: number) {
      this.brand = brand;
      this.model = model;
      this.price = price;
    }

    getDescription(): string {
      return `${this.brand} ${this.model} is priced at ₹${this.price}.`;
    }
  }

/** 
This time we have the MobilePhone interface with three properties 
and one function to describe the phone. The getDescription() function tells 
us more about the brand and its pricing in Indian Rupees.
*/

Let us define another class that includes all filtering functions based on brand,model,price and a variety of other combinations. This class is defined as PhoneFilterUtils.

We have the filterByPriceRange() function that takes in an array (or inventory) of MobilePhone objects and produces a filtered array of phones that lie within a certain price range.

Note that, filterByPriceRange is a utility function. What do you mean by that?

Utility Functions: When you have methods that operate on data passed to them, rather than on data stored within an instance of a class, it’s common to make them static. This is why filterByPriceRange is static. In the PhoneFilterUtils class, the filterByPriceRange method is static, so it can be used directly via the class name without needing to create a PhoneFilterUtils object saving memory.

class PhoneFilterUtils {
    static filterByPriceRange(phones: MobilePhone[], minPrice: number, maxPrice: number): MobilePhone[] {
      return phones.filter(phone => phone.price >= minPrice && phone.price <= maxPrice);
    }
  }

The above classes BasicMobilePhone and PhoneFilterUtils follow the SRP easily and so far, everything looks hunky-dory.

Looking at the filtering Utility class, we currently have the filter based on price alone. If we consider the other two attributes, then we conclude that we create two more similar functions based on model and brand.

Before we look further into the code, we need to understand that as the number of attributes increase from 3 to 6 to 9 or even more, the number of functions that are required to filter the products based on 1,2,3 or more attributes increase quickly.

We fall back upon the combinatorics formula C(n,k) where n is the total attributes of the product and k is the number of combinations of filters taken k at a time from n.

Meaning, if we have 3 attributes the possible filters for product could be solo filters (based on just one attribute) at a time or 2 filters (Any 2 out of 3) or all 3 attributes considered at a time.

The combinatorics formula for the above problem will give us the sum total of filtering functions possible. So, for just one attribute considered at a time it is C(3,1) = 3 ways. For 2 attributes considered out of 3 we have C(3,2) = 3 ways. For all 3 attributes considered we have C(3,3) = 1 ways.

So total number of filtering functions that exist are 3+3+1 = 7.

Thus, our PhoneFilterUtils class has 7 functions which is a manageable number. But if the dynamic nature of the mobile phone market ensures that the company needs to do more data analytics, based on a higher number of attributes, then the number of filtering functions in the PhoneFilterUtils class grow quickly making the issue of extensibility tougher to handle.

Looking at the above combinatorics formula, we can also say that the total number of possible filtering functions is 2^n - 1 where n is the number of attributes. So, for 3 attributes the answer is 2^3 - 1 = 7.

Say, if we change the interface and the class defined by adding three additional properties, because they form the new core of the business, then we shall see soon that the number of filtering functions become too much to write and test properly.

Say we add three more attributes and revamp the interface. The revised interface and the class are mentioned below.

interface MobilePhone {
    brand: string;
    model: string;
    price: number;
    screenSize: number;
    batteryCapacity: number;
    cameraMegapixels: number;

    // Additional method to describe the phone
    getDescription(): string;
}

class MobilePhone {
  constructor(
    public brand: string,
    public model: string,
    public price: number,
    public screenSize: number,
    public batteryCapacity: number,
    public cameraMegapixels: number
  ) {}

  getDescription(): string {
    return `${this.brand} ${this.model} - Price: ₹${this.price}, Screen: ${this.screenSize} inches, Battery: ${this.batteryCapacity}mAh, Camera: ${this.cameraMegapixels}MP`;
  }
}

From the above combinatorics calculation, we have the total possible filtering functions possible = 2^6 - 1 or 63.

Now our PhoneFilterUtils class will have 63 such functions, in total!!

The quick rise in the total number of functions is also called as state space explosion.

To make lives better, we will craft a nice solution that takes care of this problem. The solution, shown below will complete the OCP problem as well.

Each attribute in the interface is a specification and we will now develop the "Specification Pattern" solution that solves the OCP problem.

The following steps are required to solve this problem.

Step 1: - Define interface for the MobilePhones (Already done above)

Step 2: - Define the MobilePhone class that implements this interface (Already done above)

Step 3: - create a specification interface - common for all specifications. The specifications or specs are the attributes defined in the interface.

Step 4: - create concrete specifications classes for each attribute.

Step 5: - Create specification combinator classes (the key step) - to combine multiple specifications

Step 6: - Create a Filter class

//Step 3: create a specification interface 
interface Specification<T> {
  isSatisfied(item: T): boolean;
}
//We use Typescript generics denoted by <T>

/** 
T is a placeholder for a data type that will be specified who you use the 
generic class,function,or interface.
<T> can be replaced by string,number,boolean etc. This gives us 
flexibility to write any type of specification class above.
*/

Step 4: - create concrete specifications classes for each attribute.

// Step 4: - create concrete specifications classes for each attribute.

//We have defined 6 attributes - defining 6 classes - one each for
// every specification

// BrandSpecification --- we define  the isSatisfied function 
//We try to match each phone's brand in the inventory with the brand that
//we are interested in
class BrandSpecification implements Specification<MobilePhone> {
  constructor(private brand: string) {}

  isSatisfied(item: MobilePhone): boolean {
    return item.brand === this.brand;
  }
}

//Similar to BrandSpecification above, we are checking for the 
//phone's model. 
class ModelSpecification implements Specification<MobilePhone> {
  constructor(private model: string) {}
  isSatisfied(item: MobilePhone): boolean {
    return item.model === this.model;
  }
}

//Here we have a range of prices for comparison
class PriceSpecification implements Specification<MobilePhone> {
  constructor(private minPrice: number, private maxPrice: number) {}
  isSatisfied(item: MobilePhone): boolean {
    return item.price >= this.minPrice && item.price <= this.maxPrice;
  }
}

//Similar to PriceSpecification we have the screen sizes range
class ScreenSizeSpecification implements Specification<MobilePhone> {
  constructor(private minSize: number, private maxSize: number) {}
  isSatisfied(item: MobilePhone): boolean {
    return item.screenSize >= this.minSize && item.screenSize <= this.maxSize;
  }
}

//Similar to PriceSpecification, here we are interested in the
//battery capacity
class BatteryCapacitySpecification implements Specification<MobilePhone> {
  constructor(private minCapacity: number) {}
  isSatisfied(item: MobilePhone): boolean {
    return item.batteryCapacity >= this.minCapacity;
  }
}

//Similar to BatteryCapacitySpecification above, we are 
//looking for every phone in the inventory to satisfy the mega pixels
//range
class CameraMegaPixelsSpecification implements Specification<MobilePhone> {
  constructor(private minMegaPixels: number) {}
  isSatisfied(item: MobilePhone): boolean {
    return item.cameraMegapixels >= this.minMegaPixels;
  }
}

Step 5: - Create specification combinator classes (the key step) - to combine multiple specifications

Now comes the key part. We have the six specifications defined above. We need to combine them in any form and remain flexible for future requirements.

For the sake of brevity, let us have 6 attributes - A,B,C,D,E and F. Say a customer visits the phone website, and she wishes to know all the phones that we have (in the inventory) that satisfy both A and B but either E or F or both.

Every single customer can come up with any such complex requirement. Now to solve this jigsaw puzzle, we define two more specification classes and this forms the core of the specification pattern that we are interested in.

class AndSpecification<T> implements Specification<T> {
  constructor(private specs: Specification<T>[]) {}

//every specification needs to be satissfied
  isSatisfied(item: T): boolean {
    return this.specs.every((spec) => spec.isSatisfied(item));
  }
}

class OrSpecification<T> implements Specification<T> {
  constructor(private specs: Specification<T>[]) {}

  //any one specification is satified - job done!!
  isSatisfied(item: T): boolean {
    return this.specs.some((spec) => spec.isSatisfied(item));
  }
}

//Look at the AndSpecification class and the function - isSatisfied. 
//We have a constructor that takes an array of specifications. Here we have 
//six of them. The isSatisfied function will take in an argument (specification)
//Each such specification will be compared with its own isSatisfied function 
//and produce the result. 

//Similarly, the OrSpecification class does the job of taking an array
//of specifications and produces a result that satisfies
//at least one of the phone specifications.

//Don't worry - We will have problems at the end of the blog that depict 
//how to use these classes and functions to search through our 
//inventory and solve each customer's unique seearch.

Step 6: - Create a Filter class


class FilterCombination<T> {
  filter(items: T[], spec: Specification<T>): T[] {
    return items.filter((item) => spec.isSatisfied(item));
  }
}

//The filter() function will use the And/OR specification defined in step 5
//and use all the items from the phone inventory to filter out the 
//phones for us.

We have this tiny inventory for explaining the logic.


//Array of Mobile phones that satisfy the interface - 6 attributes
const inventory: MobilePhone[] = [
  new MobilePhone("Apple", "iPhone 14", 79900, 6.1, 3095, 12),
  new MobilePhone("Samsung", "Galaxy S23", 74999, 6.2, 4000, 50),
  new MobilePhone("Google", "Pixel 7", 59999, 6.3, 4355, 50),
  new MobilePhone("OnePlus", "9 Pro", 64990, 6.7, 4500, 48),
  new MobilePhone("Apple", "iPhone 13", 64999, 6.1, 3095, 12),
  new MobilePhone("Apple", "iPhone 15 Pro", 129000, 6.1, 3095, 48),
  new MobilePhone("Samsung", "Galaxy S21", 69999, 6.2, 4000, 50),
];

To solve our filtering problem - we will use these three steps.

//create a filter instance
const filter = new FilterCombination<MobilePhone>();

//define specification -- samples of possible specifications
const priceSpec = new PriceSpecification(12000, 65000);
const modelSpec = new ModelSpecification("iPhone 13");
const combineSpec = new AndSpecification<MobilePhone>([priceSpec, modelSpec]);

//apply the filter on the inventory with the specifications or their
//combinations
const filteredPhones = filter.filter(inventory, combineSpec);

//We can use the filtered array of phones and show it to the 
//customers and wait for him/her to buy!!
filteredPhones.forEach((phone) => {
  console.log(phone.getDescription());
});

Suppose Customer 1 comes up with this requirement.

I want "phones that are of Apple and have a screen size larger than 6 inches".

We will use the above three steps to solve our problem.


//Firstly, we define the specs. The customer is looking for Apple 
//phones
const appleSpec = new BrandSpecification("Apple");

//The customer wants the screen sizes to be above 6 inches
//Since the upper bound can be anything, we use "Infinity" available
//in Typescript
const largeScreenSpec = new ScreenSizeSpecification(6, Infinity);

//We combine the specifications of the potential customer using 
//the AndSpecification. The array of specifications is used 
const appleAndLargeScreenSpec = new AndSpecification([
  appleSpec,
  largeScreenSpec,
]);

//We used the AND specification because we want phones that 
//satisfy both the brand and the screen specifications
const appleAndLargeScreenPhones = filter.filter(
  inventory,
  appleAndLargeScreenSpec
);

//Just for display purposes
console.log(
  "\nPhones that are apple and have a screen size larger than 6 inches\n"
);

// The filtered array describes each phone available for the 
//customer
appleAndLargeScreenPhones.forEach((phone) => {
  console.log(phone.getDescription());
});

Suppose Customer 2 comes up with this requirement.

"I am interested in Phones priced in the range 60000 and 80000 (Indian Rupees) and have a battery capacity greater than 3000 mAh."

Again we use our three-step procedure to come up with the solution.


//There are three specifications mentioned. Break down the 
//information provided by the customer. You will come up 
//with these specifications related to price and baterry 
//specifications. We again have to use the AND specification 
//since both conditions ought to be satisfied.
const priceSpec2 = new PriceSpecification(60000, 80000);
const batteryCapacity = new BatteryCapacitySpecification(3000);

const priceAndBatterySpec = new AndSpecification([priceSpec2, batteryCapacity]);

//We now apply this AND specification on the inventory and produce a filtered
//array

const priceAndBatteryPhones = filter.filter(inventory, priceAndBatterySpec);

console.log(
  "\nPhones priced in the range 60000 and 80000 and have a battery capacity greater than 3000 mAh ---\n"
);

priceAndBatteryPhones.forEach((phone) => {
  console.log(phone.getDescription());
});

Example 3: We will now have the OR specification explained for Customer 3 who comes up with this requirement - "I am interested in Samsung phones or phones that are priced below 60,000 Indian rupees.

Again we take help of the three-step procedure to come up with the filtered array for the customer.

//Define the specifications
const samsungSpec = new BrandSpecification("Samsung");

//We use -Infinity in Typescript to define the range for the price
const priceBelow = new PriceSpecification(-Infinity, 60000);

//Or specification in action now!! 
const samsungOrPriceBelow60KSpec = new OrSpecification([
  samsungSpec,
  priceBelow,
]);

//The filtered array of phones from our inventory
const samSungOrPriceBelow60KPhones = filter.filter(
  inventory,
  samsungOrPriceBelow60KSpec
);
console.log(
  "\n\nPhones that are either from Samsung or have a price below 60,000\n"
);

//the phone array displayed for the customer to pick-and-choose from
samSungOrPriceBelow60KPhones.forEach((phone) => {
  console.log(phone.getDescription());
});

We have now the final customer in our journey that wants this - "Samsung phones of Galaxy S21 model priced in the range 50000 to 70000 of screen size at least 6.2 inches"


//There are 4 requirements from the user. We deined the 
//samsung specification in example 3. We simply borrow it here

//The customer wants Galaxy S21 phones
const modelSpec2 = new ModelSpecification("Galaxy S21");

//price range of the customer
const priceRange = new PriceSpecification(50000, 70000);

//the screen size requirement. Again -Infinity comes to our rescue
const screenSize = new ScreenSizeSpecification(-Infinity, 6.2);

//The AND specification array of 4 filters - samsungSpec from problem 3
const samsungPhonesSpec = new AndSpecification([
  modelSpec2,
  screenSize,
  priceRange,
  samsungSpec,
]);

//we got the Samsung phones and hopefully the customer buys one!!
const samsungPhones = filter.filter(inventory, samsungPhonesSpec);
console.log(
  "\n\n  Samsung phones of Galaxy S21 model priced in the range 50000 to 70000 of screen size at least 6.2 inches\n"
);

//describe the phones to the customer
samsungPhones.forEach((phone) => {
  console.log(phone.getDescription());
});

We solved a problem with 63 odd possible filters using a powerful pattern called the

specification pattern. But the mystery remains "Where is the OCP here ?" Did we digress from the topic ? No. The Conclusion below explains it all.

Conclusion

Summary - Each specification class checks a specific attribute or condition.

Combinators - AndSpecification and OrSpecification combine multiple specifications.

FilterClass - FilterCombination applies the specifications to filter items.

This implementation adheres to the Open/Closed principle by allowing you to extend the filtering capabilities with new specifications and combinators without modifying existing code.

We can extend the filtering logic by creating new specifications without modifying existing specifications or the filtering mechanism.

For example, if we need to add a new specification like FiveGSpecification, we simply create a new specification class for it.

The existing specifications and filtering logic remain unchanged. Thus OCP is maintained with new specifications or filtering logic without needing to change the existing code.

This approach minimises the risk of introducing bugs when new features or attributes are added, as the existing functionality remain untouched. Modifications are needed in terms of new classes for specifications being added and existing classes or logic remain unchanged and stable.

This is the GITHUB LINK for the OCP example above. I have written the code interspersed with the comments. The reader is encouraged to read this blog multiple times to cement the understanding. The OCP example is supplemented by the separation pattern in this code example. There are two different concepts sharing a symbiotic relationship. Happy learning!!