Ruby Tidbit: String, the original value object
CommentsRecently, 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