Enums as constants in Ruby DSL

Many Rails projects have a “constant” list of some predefined things, such as user roles, food categories, types of apartments, etc. These things are usually called “enums”. In the beginning, people prefer some easy implementation for that. For example, someone can use symbols, like :admin or :leader all over the application code. And that’s perfectly fine… until the time comes for changes. The business decides to rename :admin to :superadmin. On try applying this change in the code the initial implementation might seem not so straightforward. And the problem is that it’s not enough just to rename :admin to :superadmin in all places. :admin can be used in different contexts and might mean not a user role at all. It might be something else, i.e. scope of a controller, or model, or … you get the point, right?

If someone goes forward and does the rename, the whole application should be tested manually. I don’t think, there is someone in the world would be happy doing that, don’t you?

What to do then?

I suggest to define constants for these things called “enums” and keep them in their namespaces. Check out the following Ruby snippet:

module HasEnumConstants
  class ConstantsBuilder
    def initialize(namespace, const)
      @namespace = namespace
      @collection = @namespace.const_get(const)
    end

    def constant(name)
      val = name.to_s.downcase
      @collection.push(val)
      @namespace.const_set(name, val)
    end
  end

  # Introduces DSL for constants definition.
  # The all defined contants are put into the `collection` constant.
  #
  # Usage example:
  #   class User
  #     extend HasEnumConstants
  #
  #     constants_group :KINDS do
  #       constant :ADMIN
  #       constant :GUEST
  #     end
  #   end
  #   User::KINDS # => ['admin', 'guest']
  #   User::ADMIN # => 'admin'
  #   User::GUEST # => 'guest'
  def constants_group(collection, &block)
    const_set(collection, [])
    ConstantsBuilder.new(self, collection).instance_eval(&block)
    const_get(collection).freeze
  end
end

If consume it by ApplicationRecord like below all models obtain the DSL that allows enums definition as constants:

class ApplicationRecord
  extend HasEnumConstants
end

For example, the former :admin key could be defined on User model like this:

class User < ApplicationRecord
  constants_group :KINDS do
    constant :ADMIN
    constant :GUEST
  end
end

Feel how it works:

> User::KINDS # => ['admin', 'guest']
> User::ADMIN # => 'admin'
> User::GUEST # => 'guest'

If apply this practice, all the code refers to :admin should refer to constant User::ADMIN. Now that name is pretty unique as you see (because it’s scoped by User). The chance this thing may mean something else is minimized.

Consider there is a misspelling in the code, i.e. User::AMDIN instead of User::ADMIN. If the code has good coverage, the code will fail immediately during the unit tests run with an adequate exception. Having that exception it’s very easy to understand what’s the problem. And this is why it’s better than the built-in ActiveRecord enums. ActiveRecord enums are weaker in terms of strong types and preventing mistakes during the development process. Basically, it’s not much better than having just a bunch of not related symbols, like in the example of this post beginning.

As one might notice, the module can be used by the other layers of a pure Ruby or Rails application, i.e. controllers, services, mailers, etc.

As practice shows, if it comes to release in production and maintenance it’s better to follow a practice like that as sooner as better.