Ruby Class Methods are a better design choice than Constants

Preface

Constants are a valuable lanaguage construct and this article is not intended to say they are bad. What this article aims to explain is how using a constant without first considering a classes API exposes your class to unintended coupling in the future making refactoring more difficult.

Example

I will use an example from a post by Kir Shatrov titled Methods vs constants for static values in Ruby as a basis for explaining why Class Methods are preferred over Constants in Ruby. I have modified his example to use Hashes to better explain the coupling that occurs with overusing constants in a class design.

Let's assume you are building a class responsible for managing several queues. To keep track of each queue, your implementation could look like the following:

# Option 1 - Constant

class Queue
  QUEUE_NAMES = {reserved: 1, optional: 2}
end

# Option 2 - Class Method

class Queue
  class << self
    def get_queue(name)
      self.queue_names[name]
    end

    private

    def queue_names
      {reserved: 1, optional: 2}
    end
  end
end

# Option 3 - Both

class Queue
  QUEUE_NAMES = {reserved: 1, optional: 2}
  def self.queue_names
    QUEUE_NAMES
  end
end

This article is to explain why Option 2, using Class Methods, is preferred.

Why Option 1 is so prevalent

As Kir Shatrov explained:

In older versions of Ruby, Option 2 would had poor performance due duplicated string allocation - however, with frozen string literals, this is no longer a thing and the performance of all options is the same.

Now in this case, we are using Hashes so this optimization does not apply. However it is good to know that is why you see this code when strings are used.

What is wrong with the constant approach?

Constants by default (see caveat section for private_constant details) are "leaky abstractions" that expose the internal workings to the outside world. In most cases it is not the intent of the author for this data to be used anywhere but outside the class. Unfortunately, nothing prevents outside code from using the data structure. For example, let's say you wrote this Scheduler class:

class Scheduler
  def schedule_job(queue_name, job)
    queue = Queue::QUEUE_NAMES[queue_name.downcase]
    run_on(queue, job)
  end

  def run_on(queue, job)
    # ...
  end
end

You may be wondering what is so bad about this. Well the Queue class has lost control of it's internal data structure QUEUE_NAMES. Let's say Queue needs to be refactored to make use of some new paramenters:

class Queue
  QUEUE_NAMES = {
    urgent: {id: 1, name: 'reserved', priority: 1}, 
    low: {id: 2, name: 'optional', priority: 99}
  }

  # ...
end

This change should be completely fine to make as Queue is responsible for the queue's themselves. However the Scheduler is now broken because its implementation is coupled on the previous data structure of Queue::QueueNames. This is what is known as a leaky abstraction. So how do we solve it? We use Option 2, class methods.

How class methods solves this problem

By using class methods in place of our constants in order for Scheduler to be written at all, Queue would need to expose the data though it's API explicitly through a get_queue method:

class Queue
  class << self
    def get_queue(name)
      self.queue_names[name]
    end

    private

    def queue_names
      {reserved: 1, optional: 2}
    end
  end
end

Then Scheduler would look like this:

class Scheduler
  def schedule_job(queue_name, job)
    queue = Queue.get_queue(queue_name.downcase)
    run_on(queue, job)
  end

  def run_on(queue, job)
    # ...
  end
end

Now we make the same changes we made in the previous example, but notice we can now make this change without any impact to the Scheduler class.

class Queue
  class << self
    def get_queue(name)
      queue = self.queue_names.find do |priority, details|
                details[:name] == name
              end
      {queue[:name] => queue[:priority]}
    end

    private

    def self.queue_names
      {
        urgent: {id: 1, name: 'reserved', priority: 1}, 
        low: {id: 2, name: 'optional', priority: 99}
      }
    end
  end
end

As you can see Queue has completely autonomy to change its internal implementation as needed. It also can decide to update how it's external API looks and like we did above, maintain backwards-compatibility as things change.

Other Arguments in favor of Class Methods

  1. Constants cannot be garbage collected as they must always be present.
  2. Constants cannot be redefined which restricts the ability to test a class under different execution paths. -- This is often the case when you want to test an implementation against a mock value that simulates various failure scenarios.
  3. Care must be taken with constant naming to avoid fetching the wrong one. If one class has a constant User::DEFAULT and another class has Account::DEFAULT, care must be taken to make sure you can never grab the wrong DEFAULT constant during a lookup.

Ruling out Option 3

This approach is redundant as you will see, it has all the pitfalls of Option 1 with none of the solutions offered by Option 2. The tiny benefit is the code is providing a hint to the consumer that they would prefer the usage of the class method, but have no promises that request will be obeyed.

It can be helpful in a situation where Option 1 was chosen and, due to outstanding circumstances, you are unable to update all references to the constant at the same time. It should be used only as a last-resort approach even in this situation. A better approach long-term would be using private_constant which is explained below.

Disclaimer / Caveats

Since posting this article, there have been several things I glossed over in the original article that I wanted to post here:

  1. Ruby offers Module#private_constant which when used in Option 1 will get you to the same outcome as Option 2.
    • You should consider which would work best in a given situation based on the data you are working with. The end result will be the same: you have protected the API from unexpected coupling to outside classes.
    • Why did I not include it as Option 4? In writing Ruby for well over a decade, I can count on one hand the number of times I have seen private_constant used.
  2. The code examples used in this article are basic by design. If performance is the priority in an implementation, you must better understand what data structures you are using inside a class and what operations that class needs to make upon that data.