Ruby Tidbit: String, the original value object

Comments

Recently, a really great article was published over on the Code Climate Blog. Titled “7 Patterns to Refactor Fat ActiveRecord Models”, it’s a must read for everyone who works with Rails. If you haven’t read it, go do so.

Seriously. I’ll wait.

Anyway, at the very top of this list is a recommendation to extract value objects, and it got me thinking about a pattern I really like, which I wanted to share with you today.

In my day to day work, my value objects most often represent some special kind of string. A “slug” for a URL, a tag name, a specially formatted record name… whatever.

When that formatting logic needs to be reused, I’ve often seen code that places it into a mixin. Instead, I’ve taken to creating a value object by subclassing string, allowing simple reuse of the formatting logic anywhere in the codebase, and the ability to check the class of the object, to see if it’s been formatted. A couple of examples:

Aformentioned “slug”:

class Slug < String

  def initialize(object)
   object = object.to_s.downcase.gsub(/[^0-9a-z]+/, '-').gsub(/^-|-$/, '')
   super
  end

end

A formatted “record name” (name attribute on an ActiveRecord object) – I use a unique index on the column, so I want to make sure the records are formatted consistently to avoid differences in spacing (that people might not immediately notice) from causing success/failure:

class RecordName < String

  def initialize(object)
   object = object.to_s.strip.gsub(/\s+/, ' ')
   super
  end

end

Ruby treats String subclasses just like strings. Unlike the kinds of equality tests you might implement in your own objects, no check is first made that both classes are the same, and it doesn’t matter which side of the operator you put them on. So:

Slug.new("Unformatted Text!") == 'unformatted-text' # => true
'unformatted-text' == Slug.new("Unformatted Text!") # => true

Now, you can check that the string you’re receiving has been formatted elsewhere in the codebase without a regexp, and format it if needed:

str = Slug.new(str) unless str.is_a? Slug

If you’re concerned about mutability, you can always freeze in the initializer – I prefer to adhere to a contract that I won’t mutate these objects. Or, with some additional work, the mutator methods could be updated to support the formatting requirements, too.

Now, a final example that yields somewhat mixed results: a refactoring of the Rating class in the article at Code Climate.

A case could be made that a Rating is, in fact, a special kind of string. It has a value from A-F, allows comparison against other things in string form, hashes like its string value, etc. The comparison order being reversed, however, removes much of the benefit of “being a string,” since we can’t use strings and ratings on either side of all operators, as above.

class Rating < String

  def initialize(object)
    object = cost_to_rating(object) if Numeric === object
    object = object.to_s.upcase
    raise ArgumentError, "Ratings are A-F!" unless ('A'..'F').include? object
    super
  end

  def <=>(other)
    val = super and -val
  end
  alias better_than? >
  alias worse_than? <

  private

  def cost_to_rating(cost)
    case cost
    when 0..2
      'A'
    when 2..4
      'B'
    when 4..8
      'C'
    when 8..16
      'D'
    else
      'F'
    end
  end

end

To be clear, if your object differs significantly enough from a string (as above) then you probably want to reconsider using this pattern. For quick and easily reusable formatting consistency that fits your domain model, however? The original value object, String, is hard to beat.

[Update] To be even clearer, this pattern is for value objects – that is, you shouldn’t be changing them. As nicholaides correctly points out in the comments below, Ruby does some voodoo to optimize certain string operations. Don’t expect your custom initializer to be called on objects instantiated as a result of things like String#gsub.

comments powered by Disqus