Understanding SOLID once and for all | Part 02 – (OCP)

Motivation

Hey folks, how’s it going?

This is the second post in the series where I’m sharing my real-world experience with SOLID principles in a straightforward and down-to-earth way. In the first post, I talked about SRP and showed how small violations can make code maintenance harder. If you haven’t seen it yet, go check it out!

Today, we’re taking a step further with the Open–Closed Principle (OCP), the pillar that teaches us how to extend behavior without modifying what’s already working.

Brief Overview

This term was coined by Bertrand Meyer in 1988 and popularized by Robert C. Martin (Uncle Bob). OCP is usually summarized by the phrase:

“Software entities should be open for extension, but closed for modification.”

At first glance, it seems paradoxical how something can be open for extension and closed for modification at the same time? The secret lies in one word: abstractions (interfaces, behaviours, callbacks).

As systems grow larger, both in file size and complexity, it becomes essential to use abstractions to expand behavior without changing the core implementation. In other words, the base code “never changes.”

Example – A function that calculates tax on a product

  • Can the calculation change? Yes, but it’s unlikely.
  • What are the variables? Product value + Tax rate
  • Which of these might change? Tax rate

These are the types of questions I ask myself to identify variation points in the implementation. In this case, the how to calculate is unlikely to change, but the tax rate might.

Here’s a typical implementation of such a calculation:

function calculate(price), do:
  price * @tax_rate or 0.10

The pseudo-code above is a simple and direct implementation. However, it has a downside: every time the tax rate changes, we have to modify the function, which can cause unintended issues elsewhere in the system.

Now, imagine we did this instead:

function calculate_tax(price, tax_function), do:
  tax_function(price)

In this case, we don’t need to change the core logic when requirements change. We pass a function as a parameter to calculate the new value. This allows us to implement new features through new modules or classes without touching already-tested code, thus expanding functionality safely.

OCP in Practice

1. Discount Strategy in Go

Excerpt from the solid-go-examples repository:

// Interface that defines the variation point
type Discount interface {
    Apply(price float64) float64
}

// No discount implementation
type NoDiscount struct{}
func (NoDiscount) Apply(price float64) float64 { return 0 }

// Percentage discount implementation
type PercentageDiscount struct{}
func (PercentageDiscount) Apply(price float64) float64 { return price * 0.1 }

// Core function that doesn’t change when new discounts are added
func CalculatePrice(price float64, d Discount) float64 {
    return price - d.Apply(price)
}

Usage in main.go:

noDisc := NoDiscount{}
percentageDisc := PercentageDiscount{}

fmt.Println(CalculatePrice(100, noDisc)) // 100
fmt.Println(CalculatePrice(100, percentageDisc)) // 90

Why does this follow OCP?

CalculatePrice is closed for modification—we didn’t touch it to add PercentageDiscount. At the same time, the system is open for extension because we can add new types that implement Discount (e.g., BlackFridayDiscount) at any time.

2. Payment Processor in Elixir

File: 02_open_closed_principle.ex from the solid_elixir_examples repo:

defmodule PaymentProcessor do
  def process(order, tax_calculator) do
    tax = tax_calculator.(order)
    total = order.amount + tax
    {:ok, %{order: order, tax: tax, total: total}}
  end
end

# Different tax rules without changing the module
PaymentProcessor.process(%{amount: 100}, fn o -> o.amount * 0.1 end)
PaymentProcessor.process(%{amount: 100}, fn o -> o.amount * 0.2 end)

Why does this follow OCP?

PaymentProcessor delegates tax calculation to a function received as a parameter. When a new regulation appears, we just create another function (or module) and pass it in—no need to touch the processing logic.

Tips for Adopting OCP

Changing Situation OCP-Friendly Approach
New payment methods Define a PaymentGateway interface and implement new adapters without altering existing ones.
New pricing rules Use callback functions or the strategy pattern to encapsulate logic—this improves maintainability and testability.
New reports Apply the Template Method pattern or use plug-ins to generate reports instead of editing the base class.

Golden tip: Find the variation point in your code—this is usually the part that needs to be encapsulated.

Signs of OCP Violation

  • Growing switch/case blocks whenever new data types appear.
  • Tests breaking in a cascade when only one requirement changes.
  • Constant changes in the core domain (more internal rule changes = greater need for OCP).

Conclusion

Applying the Open–Closed Principle isn’t about “never touching code again.” It’s about minimizing high-risk changes and isolating variability. The clearer the boundary between core and extensions, the safer and faster it will be to evolve the system.

In the next article, we’ll explore the Liskov Substitution Principle (LSP).

Meanwhile, check out the full repositories with examples in Elixir and Go:

Still have questions? Or maybe you disagree with something? Drop a comment!

Similar Posts