The Open-Closed Principle
In SOLID, the Open-Closed Principle is often the one that generates the most confusion. Its definition seems clear at first glance: a module should be “open for extension, but closed for modification”. However, when you try to apply this principle in practice, questions arise: How do we fix bugs if we can’t modify the code? Aren’t we adding unnecessary complexity by designing for extensions we might never need?
These aren’t trivial questions, and many developers have concluded that the Open-Closed Principle can lead you down the wrong path. The temptation to design for premature flexibility is quite common in teams trying to follow SOLID religiously.
Let’s explore what this principle actually means, how to apply it in Ruby, but most importantly, let’s talk about when not to apply it and why context matters more than the principle itself.
The Promise of the Open-Closed Principle
Bertrand Meyer formulated this principle in 1988 with the intention that software should evolve without rewriting code that already works. The idea is that when you need new functionality, you should be able to add it via extension, not by modifying existing classes.
It’s like building a house: ideally, if you wanted to add a piece of furniture, you wouldn’t have to tear down walls or change the structure. You should be able to add furniture without modifying the underlying build. The Open-Closed Principle applies this same philosophy to code.
A Practical Example: The User Exporter
Imagine an application that needs to export user information. In the initial version, we only need to generate CSV files:
class UserExporter def initialize(users) @users = users end
def export_to_csv # Generate the CSV header csv_content = "id,name,email,created_at\n"
# Add each user as a row @users.each do |user| csv_content += "#{user.id},#{user.name},#{user.email},#{user.created_at}\n" end
csv_content endend
users = User.allexporter = UserExporter.new(users)File.write('users.csv', exporter.export_to_csv)This class works perfectly for its purpose. It is simple, direct, and easy to understand. However, later on, a new requirement arrives: the frontend team needs data in JSON format. Modify the class:
require 'json'
class UserExporter def initialize(users) @users = users end
def export_to_csv csv_content = "id,name,email,created_at\n"
@users.each do |user| csv_content += "#{user.id},#{user.name},#{user.email},#{user.created_at}\n" end
csv_content end
def export_to_json json_data = @users.map do |user| { id: user.id, name: user.name, email: user.email, created_at: user.created_at } end
json_data.to_json endendAlthough this works, we have just violated the Open-Closed Principle, as we had to modify the class. Now imagine another requirement comes in: export in XML format. And then a custom format. And later, someone asks for an Excel format. The class starts to bloat:
require 'json'
class UserExporter def initialize(users) @users = users end
def export_to_csv # ... CSV implementation end
def export_to_json # ... JSON implementation end
def export_to_xml # ... XML implementation end
def export_to_excel # ... Excel implementation end
def export_to_custom_format # ... custom implementation endendEvery new format requires us to modify the original class. This introduces several problems:
- The class grows uncontrollably and loses cohesion.
- Each modification can introduce bugs into code that previously worked perfectly.
- If several developers are working on different formats simultaneously, git conflicts will arise.
Applying the Principle: Extension via Inheritance
A classic way to solve this is by using inheritance. Create a base class that defines the common contract, and then each format is implemented in a subclass:
class UserExporter def initialize(users) @users = users end
# Abstract method that subclasses must implement def export raise NotImplementedError, "Subclasses must implement the export method" end
protected
# Helper method available to all subclasses def user_data @users.map do |user| { id: user.id, name: user.name, email: user.email, created_at: user.created_at } end endend
class CsvUserExporter < UserExporter def export csv_content = "id,name,email,created_at\n"
user_data.each do |data| csv_content += "#{data[:id]},#{data[:name]},#{data[:email]},#{data[:created_at]}\n" end
csv_content endend
class JsonUserExporter < UserExporter def export user_data.to_json endend
class XmlUserExporter < UserExporter def export # NOTE: Build the XML manually for this example. # In production, use libraries like Nokogiri or Builder to # ensure special characters are escaped correctly. xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<users>\n"
user_data.each do |data| xml += " <user>\n" xml += " <id>#{data[:id]}</id>\n" xml += " <name>#{data[:name]}</name>\n" xml += " <email>#{data[:email]}</email>\n" xml += " <created_at>#{data[:created_at]}</created_at>\n" xml += " </user>\n" end
xml += "</users>" xml endend
users = User.allcsv_exporter = CsvUserExporter.new(users)File.write('users.csv', csv_exporter.export)
json_exporter = JsonUserExporter.new(users)File.write('users.json', json_exporter.export)Now, when we need a new format, we simply create a new class. The base class UserExporter remains closed for modification but open for extension. If we want to add an Excel format, we create ExcelUserExporter without touching any existing class.
This approach has clear advantages. Each class has a unique, well-defined responsibility. The code is organized and easier to test because you can test each exporter in isolation. And if there is a bug in the XML exporter, you know exactly where to look.
A More Flexible Alternative: Composition with Strategy Pattern
Inheritance works, but there is another approach that is often more flexible: composition using the Strategy pattern. Instead of having a class hierarchy, we can inject different formatting strategies, which are nothing more than simple objects (POROs) that respond to #format:
class UserExporter def initialize(users, formatter) @users = users @formatter = formatter end
def export # Delegate formatting to the injected strategy @formatter.format(user_data) end
private
def user_data @users.map do |user| { id: user.id, name: user.name, email: user.email, created_at: user.created_at } end endend
class CsvFormatter def format(data) csv_content = "id,name,email,created_at\n"
data.each do |row| csv_content += "#{row[:id]},#{row[:name]},#{row[:email]},#{row[:created_at]}\n" end
csv_content endend
class JsonFormatter def format(data) data.to_json endend
class XmlFormatter def format(data) # NOTE: In production, use libraries like Nokogiri or Builder to # ensure special characters are escaped correctly. xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<users>\n"
data.each do |row| xml += " <user>\n" xml += " <id>#{row[:id]}</id>\n" xml += " <name>#{row[:name]}</name>\n" xml += " <email>#{row[:email]}</email>\n" xml += " <created_at>#{row[:created_at]}</created_at>\n" xml += " </user>\n" end
xml += "</users>" xml endend
users = User.all
csv_exporter = UserExporter.new(users, CsvFormatter.new)File.write('users.csv', csv_exporter.export)
json_exporter = UserExporter.new(users, JsonFormatter.new)File.write('users.json', json_exporter.export)This approach gives us even more flexibility. We can change the formatter at runtime (i.e., without creating a new object) or combine formatters if necessary. The UserExporter class remains closed for modification, but we can extend it by adding new formatters.
Did We Really Need All This?
Up to this point, everything looks perfect. We have followed the Open-Closed Principle to the letter, and our code is extensible and maintainable. But what if you never need anything other than CSV?
Imagine you are working at a small startup. You have a simple feature that exports users to CSV so the support team can import them into Excel when needed. That’s it. There are no plans for more formats. There are no future requirements on the horizon. In this context, does it make sense to build this entire infrastructure of classes, inheritance, and strategies?
You would be over-engineering a solution for a problem you don’t have. And here we arrive at the most important point of this article: the Open-Closed Principle is not an immutable law that you must always follow. It is a tool, and like any tool, there are contexts where it is useful and contexts where it is overkill.
When we started with our simple UserExporter class that only handled CSV, that code was appropriate for its context: easy to read, easy to understand, and it did exactly what we needed. If we don’t need more formats, we shouldn’t complicate it.
Context Matters: When to Apply OCP
That said, there are scenarios where applying the Open-Closed Principle from the start makes a lot of sense. If you are building a public library that other developers will use, extensibility is crucial. If you are designing a plugin system where third parties will add functionality, you need stable interfaces that don’t change.
For example, if you were building a gem for other developers to install and export data, designing it with OCP in mind is sensible. Users of your gem might want to create their own custom formatters without having to fork and modify your code. In that case, the flexibility justifies the additional complexity.
Another context is when you have a large team working on the same code, and several developers need to add different formats simultaneously. In that case, having an extensible architecture prevents everyone from modifying the same file and creating constant conflicts.
The key lies in asking yourself: what is the cost of flexibility versus the cost of rigidity? If changing the code is cheap and fast in your context, you don’t need premature flexibility. But if it is expensive because it affects many users or requires complex coordination, then investing in an extensible design is worth it.
The Technical Reality of Ruby: OCP is Almost Impossible
There is another important aspect we must consider: the very nature of Ruby makes the Open-Closed Principle practically impossible to apply in its strict sense. Jeremy Evans, author of Polished Ruby Programming, dedicates an entire section of his book to explaining why.
In Ruby, all classes are open all the time. You can redefine methods at runtime, add modules with prepend that intercept calls, access private instance variables, or even redefine the behavior of core Ruby methods. The language was explicitly designed to be flexible and dynamic.
In fact, one of the most significant features added in Ruby 2.0 was origin classes, a considerable complexity in the object model to make Module#prepend work, allowing you to override even singleton methods and call super to get the original behavior.
Even if you try to freeze a class, Ruby offers multiple mechanisms to bypass those restrictions. Making it closed for modification but open for extension would require complex tricks like overriding include, prepend, and extend, intercepting method_added, and even then, there would be multiple ways to circumvent these protections.
The conclusion is that Ruby not only ignores the Open-Closed Principle, but it also works actively to ensure that classes are never truly closed for modification.
A Pragmatic Perspective
So, what do we do with all this? My recommendation is to view the Open-Closed Principle not as a rule to follow blindly, but as a warning sign. When a class needs to be modified constantly to add new functionality, it’s a signal that perhaps its design could improve.
But the right question isn’t “Am I following OCP?”, but rather “Is this design helping me or doing the opposite?”. Sometimes, an extra if in a simple class is all you need. Other times, it will be better to refactor towards an extensible design.
In our exporter example, the natural path would be to start with the simple version. When the second format arrives, perhaps you add the method directly. When the third arrives, you start to see a pattern and decide to refactor. That would be the correct process.
The main idea is that you must recognize the signs that code needs to evolve, and know when to invest in flexibility and when to keep things simple.
Conclusion
The Open-Closed Principle teaches us to design by thinking about how our code will change. But it is also important to learn when not to apply it. Over-engineering can be just as harmful as rigid, coupled code.
My practical advice is to start simple. Write the most direct code that solves your current problem. When you start to feel pain when making changes, when you see that modifying a class is becoming risky or complex, then it is the time to consider a more extensible design.
Ruby gives you all the tools to create flexible code when you need it: inheritance, modules, composition, duck typing. But it also gives you the freedom to write simple, direct code when that is sufficient.
The best code isn’t the one that follows the most principles; it’s the one that best adapts to its context. Sometimes that means following OCP. Other times it means ignoring it completely. The key is knowing when to do which.
Test your knowledge
-
What does the Open-Closed Principle state?
-
What is the main problem with a monolithic class?
-
When should you not apply the Open-Closed Principle?
-
What makes the Open-Closed Principle difficult to enforce in Ruby?
-
What’s a key advantage of using the Strategy pattern over inheritance for OCP?