Samy's blog

Today I Learned: SOLID - Open-Closed Principle (OCP)

Today I Learned: SOLID - Open-Closed Principle (OCP)

So Continuing on my list of learning about the SOLID principles:

  • S - Single-Responsiblity Principle
  • O - Open-Closed Principle (you are here)
  • L - Liskov Substitution Principle
  • I - Interface Segregation Principle
  • D - Dependency Inversion Principle

Today I'm going to write about one that starts with an O, Its concept might seem confusing at first, so I will try to illustrate as best as I can with a simple example.

The second principle states: You should be able to extend a class's behavior, without modifying it. or in other words: "Types are open for extension but they are closed for modification".

Example time:

Let's say we have a list of Products with some properties, like color, size, and name:

package main

import "fmt"

type Color int

const (
	red Color = iota
	green
	blue
)

type Size int

const (
	small Size = iota
	medium
	large
)

type Product struct {
	name string
	color Color
	size Size
}

And our manager comes and says, that list is nice and all, but we need a way to filter the products on our ultra nice web page that we are building, a lot of clients are asking to filter by color, do you think it can be done?

well, you say, ok..simple enough I will just create a filter type or function and get to work!

type Filter struct {}

func (f *Filter) filterByColor(
	products []Product, color Color)[]*Product {
	result := make([]*Product, 0)

	for i, v := range products {
		if v.color == color {
			result = append(result, &products[i])
		}
	}

	return result
}

So all good, we can test it like this:

func main_() {
	apple := Product{"Apple", red, small}
	tree := Product{"Table", green, large}
	house := Product{ "Bed", blue, large}

	products := []Product{apple, tree, house}

	fmt.Print("Green products :\n")
	f := Filter{}
	for _, v := range f.filterByColor(products, green) {
		fmt.Printf(" - %s is green\n", v.name)
    	}
}

Looking good! then our manager comes later asks: hey! that filter by color thing you did is working great, some of our clients want to filter by size now, do you think that's possible?

🤔, Ok you say, ill just add another method to the Filter struct!

func (f *Filter) filterBySize(
	products []Product, size Size) []*Product {
	result := make([]*Product, 0)

	for i, v := range products {
		if v.size == size {
			result = append(result, &products[i])
		}
	}
	return result
}

Perfect!,....but..a few weeks later your manager comes again to your desk (or over instant messaging) and says: hey! those filters have been working great and our clients love the features you have added, but some are wondering if they can combine some of the filters, like size and color.

you say: ok, ill get back to you when it is done! so you go back to the Filter struct and add yet another method

func (f *Filter) filterBySizeAndColor(
	products []Product, size Size,
	color Color)[]*Product {
	result := make([]*Product, 0)

	for i, v := range products {
		if v.size == size && v.color == color {
			result = append(result, &products[i])
		}
	}

	return result
}

its easy to see by now how this can get out of control over time, making the API of the Filter struct confusing to use, to both you and other developers of that interface, it also becomes very bloated over time., we keep going back to the struct and we modify it every time we need to add functionality, this is what the Open-Closed Principle tries to prevent.

Another approach:

Let us try to replicate our functionality in another way, taking advantage of the Golang feature of having functions as types

let's say we want to create a new struct, and let's call it ImprovedFilter and we add a FilterBy method to it, this method will receive a list of Products and a Predicate function, that will be used to filter the products based on whatever conditions makes it return true

type ImprovedFilter struct {}

func (f *ImprovedFilter ) FilterBy(
	products []Product, predicate func (p *Product) bool) []*Product {
	result := make([]*Product, 0)
	for i, v := range products {
		if predicate(&v) {
			result = append(result, &products[i])
		}
	}
	return result
}

so we can see it in action like this (full code):

package main

import "fmt"

type Color int

const (
	red Color = iota
	green
	blue
)

type Size int

const (
	small Size = iota
	medium
	large
)

type Product struct {
	name string
	color Color
	size Size
}

type Filter struct {
}

func (f *Filter) filterByColor(
	products []Product, color Color)[]*Product {
	result := make([]*Product, 0)

	for i, v := range products {
		if v.color == color {
			result = append(result, &products[i])
		}
	}

	return result
}

func (f *Filter) filterBySize(
	products []Product, size Size) []*Product {
	result := make([]*Product, 0)

	for i, v := range products {
		if v.size == size {
			result = append(result, &products[i])
		}
	}

	return result
}

func (f *Filter) filterBySizeAndColor(
	products []Product, size Size,
	color Color)[]*Product {
	result := make([]*Product, 0)

	for i, v := range products {
		if v.size == size && v.color == color {
			result = append(result, &products[i])
		}
	}

	return result
}


type ImprovedFilter struct {}

func (f *ImprovedFilter ) FilterBy(
	products []Product, predicate func (p *Product) bool) []*Product {
	result := make([]*Product, 0)
	for i, v := range products {
		if predicate(&v) {
			result = append(result, &products[i])
		}
	}
	return result
}



func main() {
	apple := Product{"Apple", green, small}
	tree := Product{"Tree", green, large}
	house := Product{ "House", blue, large}

	products := []Product{apple, tree, house}
	fmt.Print("Green products (old) way:\n")
	f := Filter{}
	for _, v := range f.filterByColor(products, green) {
		fmt.Printf(" - %s is green\n", v.name)
	}
	// ^^^ BEFORE

	
	// vvv AFTER
	fmt.Print("Green products (new) way:\n")
	greenFilter := func (p *Product) bool{
		return p.color == green
	}
	
	imf := ImprovedFilter{}
	for _, v := range imf .FilterBy(products, greenFilter ) {
		fmt.Printf(" - %s is green\n", v.name)
	}
	
	//large and gree
	largeAndFilter := func (p *Product) bool{
		return p.color == green &&  p.size == large
	}
	
	for _, v := range imf .FilterBy(products, largeAndFilter ) {
		fmt.Printf(" - %s is large and green \n", v.name)
	}	 
}

So that's pretty much it, our Improved filter now has a Clean API with no surprises, it's also more flexible due to being able to filter based on custom criteria on the fly, and will not surprise any new dev with RandomNewFilter on a GitHub PR.

I hope anyone who is reading my short learning summaries finds them useful somehow...and as always:

A bit of tinkering a day keeps the boredom away. 🚀

ko-fi

- 4 toasts