written by primehammer

Open-Closed Principle in Ruby

Open-closed is one of the SOLID principles, an acronym originally coined by Uncle Bob. In this post I will cover what open-closed is, why it is important and how it applies to Ruby.

What is open-closed?

“Software entities (classes, modules) should be open for extension, but closed for modification”. The idea of open-closed is to write classes/modules that don’t have to change when new requirements come in. Instead of modifying the internals, functionality can be extended by adding new entities.

This can be achieved by choosing the right design patterns. For example, using the strategy pattern is a good way to write open-closed code. I will come back to this later, with an example.

Why is it important?

The benefit of writing open-closed code is that it makes it easier to handle new requirements. Modifying the internals of a class/module is more difficult and error prone than creating a new class/module to extend functionality. Let’s look at some code, to demonstrate this:

Open-closed in Ruby

Recently I worked on a project where I was asked to create a service that accepts a country ISO code and calls a different service, depending on the country. I wanted to implement the strategy pattern so I needed a way to modify part of the algorithm depending on the country. The most obvious way to handle this requirement was to use conditional logic:


class Location
  def initialize(country_code:, default_location_api: DefaultLocationApi)
    @country_code = country_code
    @default_location_api = default_location_api
  end

  def results
    client.response
  end

  private

  def client
    client_klass.new
  end

  def client_klass
    if @country_code == "GB"
      GbApi::Client
    else
      @default_location_api
    end
  end
end

The problem with this approach is that future developers will need to modify the client method every time they want to add functionality for a new country. The Location class is open for modification and closed for extension, which is the opposite of what we want.

This is going to cause a maintenance headache as the class grows in size. Imagine opening this file, to find a 100 line if-statement with logic for 50 countries. Large conditionals are also difficult to test. Let’s refactor this code to follow the open-closed principle:


class Location
  def initialize(country_code:, default_location_api: DefaultLocationApi)
    @country_code = country_code
    @default_location_api = default_location_api
  end

  def results
    client.response
  end

  private

  def client
    client_klass.new
  end

  def client_klass
    "#{@country_code.downcase}_api::Client".camelize.safe_constantize || @default_location_api
  end
end

In this example, I’ve used some meta-programming to guess the class name based on the country. This enables future developers to extend the functionality of Location by adding new classes under app/lib/api_clients/. This is achieved by making the country_code part of the class constant that gets initialized.

All client classes inherit from the same base class so developers can write new clients to implement country specific logic. Countries that don’t need any specific logic will fall back to the default_api_client.

Rails developers will be used to this pattern. If you follow the conventions then you can easily extend Rails without modifying its internals.

That’s all

I hope this post has demonstrated how to use the open-closed principle and the benefits of applying it to your code. Happy hacking and don’t forget to read up on the other SOLID principles!