Skip to main content
  1. Posts/

Ruby Constants vs Class Methods: Why Methods are the Better Default

·7 mins
Justin Smestad
Author
Justin Smestad
Table of Contents

Every Ruby codebase I’ve worked in has classes littered with public constants. DEFAULT_TIMEOUT, VALID_STATES, QUEUE_NAMES. They feel clean and simple. They’re also one of the most common ways teams accidentally couple their code together, making refactoring far more expensive than it needs to be.

This post argues that class methods are almost always a better choice than constants for exposing static values. Not because constants are inherently bad, but because they skip the one thing that keeps a codebase maintainable: an intentional API boundary.

If you’ve ever tried to rename a hash key inside a class and discovered that six other files broke, you’ve felt this problem. Constants are the reason.

Ruby constants vs class methods: three approaches
#

I’ll borrow a scenario inspired by Kir Shatrov’s post on methods vs. constants, modified to use hashes so the coupling problem is more visible.

You’re building a class that manages several queues. You need to keep track of each queue by name. Here are three ways you might do it:

# Option 1 – Constant

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

# Option 2 – Class method

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

    private

    def queue_names
      @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

Option 2 is the better design. Let me show you why.

Performance: constants vs class methods in Ruby
#

In older versions of Ruby, class methods that returned strings had a real performance cost because of duplicated string allocation. With frozen string literals, that’s no longer true for strings.

For non-string values like hashes, there’s a small difference: a method returning a hash literal allocates a new hash on every call, while a constant allocates once. You can close that gap with memoization (@queue_names ||= {...}), which is what I’d recommend for any value that doesn’t need to change between calls. The performance difference is negligible for most code, but it’s worth knowing about.

The bigger point is that the historical performance argument for constants over methods is largely gone. It explains why so much existing Ruby code defaults to constants, but it’s no longer a strong reason to choose them.

Why Ruby constants create coupling
#

Constants are public by default. That means any class in your system can reach into Queue::QUEUE_NAMES and use the raw data structure directly. In most cases, the author never intended for that hash to be part of the class’s public contract. But nothing prevents it, and someone will use it.

Here’s a Scheduler that does exactly that:

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

At first glance, this looks fine. It works. But Queue has lost control of its own internal data structure. The Scheduler is now coupled to the shape of that hash, not just the existence of it.

Watch what happens when Queue needs to evolve. Let’s say you need to add priority levels and richer metadata:

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

This change is completely reasonable. Queue owns the queues, and it should be able to restructure its own data. But Scheduler is now broken because it was counting on QUEUE_NAMES[queue_name] returning an integer, not a hash.

This is a leaky abstraction. The constant exposed an internal detail, another class built on top of that detail, and now you can’t change one without changing the other. Multiply this across a real codebase and you get the kind of refactoring paralysis where every small change cascades into a dozen files.

This is also why senior engineers and tech leads care about API design even for internal classes. It’s not academic purity. It’s the difference between a codebase where you can confidently ship changes and one where every PR is a minefield.

How class methods protect your Ruby API
#

With Option 2, the Queue class controls what data leaves the building. Consumers interact with a method, not a raw data structure:

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

    private

    def queue_names
      @queue_names ||= {reserved: 1, optional: 2}
    end
  end
end

Now Scheduler uses the method:

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

When Queue needs to restructure its internals, it can do so without breaking Scheduler at all:

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

    private

    def queue_names
      @queue_names ||= {
        urgent: {id: 1, name: 'reserved', priority: 1},
        low: {id: 2, name: 'optional', priority: 99}
      }
    end
  end
end

Queue now has complete autonomy over its internal implementation. It can restructure, rename, add fields, or change storage mechanisms. As long as get_queue returns the same shape, nothing downstream breaks. That’s what an API boundary gives you.

More reasons to prefer class methods over constants
#

Constants can’t be garbage collected. A constant lives in memory for the lifetime of the process. For small hashes this doesn’t matter. For large lookup tables or configuration objects, it can. A class method only allocates when called (and with frozen literals, even that’s cheap).

Constants are awkward to override in tests. Ruby does let you redefine a constant, but it throws a warning when you do, and the ergonomics are clunky. RSpec’s stub_const exists specifically to work around this. A class method is just a method. You can stub it, mock it, or override it in a subclass with a simple allow(...).to receive(...). Testing becomes straightforward instead of a workaround.

Constant name collisions are a real footgun. If User::DEFAULT and Account::DEFAULT both exist, it’s easy to grab the wrong one, especially in modules or inherited contexts where Ruby’s constant lookup walks the ancestor chain. Class methods don’t have this problem because the method name is scoped to the class that defines it.

Why Option 3 doesn’t help
#

Option 3 (constant plus a wrapper class method) looks like a compromise, but it gives you all the downsides of Option 1 with none of the protection of Option 2. The constant is still public. Any code can bypass the method and access Queue::QUEUE_NAMES directly. The method is just a hint that the author would prefer you use it, with no enforcement.

The one situation where Option 3 makes sense is as a migration step. If you already have a constant that other code references, you can add the class method, update consumers to use it, and then make the constant private or remove it. But that’s a transition state, not a destination.

What about Ruby’s private_constant?
#

Ruby does offer Module#private_constant, which restricts access to a constant from outside the class. Used with Option 1, it gets you to roughly the same outcome as Option 2. You’d still need a public method for consumers to access the value, which brings you back to the class method pattern anyway.

It’s a valid tool. I’ve been writing Ruby for well over a decade, and I can count on one hand the number of times I’ve seen private_constant used in production code. Class methods are idiomatic. They’re what other Ruby developers expect. When you’re making a design choice, going with the grain of the language and its community matters.

The real point
#

This isn’t really about constants vs. methods. It’s about whether your classes have intentional APIs or accidental ones.

Every time you make a constant public (which is the default), you’re implicitly promising that its structure will never change. That’s a strong commitment to make accidentally. Class methods let you be deliberate about what you expose and give you room to evolve your implementation without breaking the rest of the system.

The cost of switching is close to zero. The cost of not switching shows up later, when refactoring is slow and every change has a blast radius you didn’t expect. In my experience leading teams through large codebases, the teams that treat internal API boundaries seriously are the ones that ship faster over time. The shortcuts compound in the wrong direction.