Logo

dev-resources.site

for different kinds of informations.

Flexible Ruby Value Object Initialisation

Published at
1/21/2019
Categories
ruby
oo
valueobject
refine
Author
databasesponge
Categories
4 categories in total
ruby
open
oo
open
valueobject
open
refine
open
Author
14 person written this
databasesponge
open
Flexible Ruby Value Object Initialisation

Problem

Value objects in Ruby are lovely things, and invaluable in a complex application.

But one issue that always seems to trip me up is correctly initialising from other objects, so while we might want to write:

class ISBN
  def initialize(value)
    @isbn_string = value
  end

  def etc
  end
end

... it turns out in practice that sometimes you're creating a value object based on string input, and sometimes it might make sense to store the value in the database as a numeric but you can be sure that a controller will receive it as a string, and other times it already is a value object.

Enough of all that nonsense – you need to be able to initialise your value object based on almost anything that might represent its value.

Making it so

So what do you think of this?

class ISBN
  def initialize(value)
    @isbn_string = if value.is_a? String
                     value.gsub(/[^[:digit:]]/,"")
                   elsif value.is_a? Integer
                     value.to_s
                   elsif value.is_a? NilClass
                     ""
                   end
  end
end

Pretty bad.

One of the principles of object-oriented design that you might pick up along the way is that this sort of code is A Bad Thing, because we're not supposed to care about what type/class an object is, only about what it can respond to.

Now if we were sending the a message #address to an object, we'd be fine – we'd just expect the Person/Company/Owner/Invoice etc classes to implement that method, and because they are our own domain objects and we write the code for them, we can define whatever behaviour we want on them.

But what do we do when the object is one of the Ruby core classes? String, Integer, or even NilClass?

One answer is to monkey patch, but by now I think we all know that that is Also A Bad Thing and that we should instead be implementing a refinement.

How about this?

module ISBNInitializerExtensions
  refine String do
    def to_isbn_string
      gsub(/[^[:digit:]]/,"")
    end
  end

  refine Integer do
    def to_isbn_string
      to_s
    end
  end

  refine NilClass do
    def to_isbn_string
      ""
    end
  end
end

It's not too bad, as the ISBN initialisation then becomes:

class ISBN
  using ISBNInitializerExtensions
  def initialize(value)
    @isbn_string = value.to_isbn_string
  end

  def etc
  end
end

We can scatter ISBN.new(obj) around the system and the ISBN class doesn't need insights into the cleansing for a string or a number. It assumes that the input value has implemented it.

But we don't do Integer.new("78") very much, do we? We like "78".to_i instead. (OK, we can do the explicit method, but only when we need the specific behaviour that that brings with it. Otherwise we just send #to_i).

We also need to know the name of the class that implements the ISBN object, and how to initialise it, but maybe that's not such a big deal.

Taking it a little further, what about:

module ISBNInitializerExtensions
  refine String do
    def to_isbn
      ISBN.new(gsub(/[^[:digit:]]/,""))
    end
  end

  refine Integer do
    def to_isbn
      ISBN.new(to_s)
    end
  end

  refine NilClass do
    def to_isbn
      ISBN.new("")
    end
  end
end

This module becomes the location in the system where we convert core Ruby classes to the application-defined class ISBN, and the only place where we need to do refactoring if we wanted to change the name of the ISBN class.

And we could implement similar logic for #to_zip_code, #to_country, #to_currency, etc., with similar benefits.

And then, secure in the knowledge that the classes which might need to return it have already implemented the clean-up, we can write:

class ISBN
  def initialize(value)
    @isbn_string = value
  end

  def etc
  end
end

And we're back where we started, with a nice, clean initialisation and the ability to:

  "978-3-16-148410-0".to_isbn
  9783161484100.to_isbn
  nil.to_isbn

.. as long as we have first added using ISBNInitializerExtensions to the class or module where we want to use it (which admittedly seems to make it tricky in Rails views for some reason).

For completeness, we can ...

class ISBN
  def initialize(value)
    @isbn_string = value
  end

  def to_isbn
    self
  end

  def to_s
    @isbn_string
  end
end

This does make value object definitions in Rails a thing of very little code:

class Book
  using ISBNInitializerExtensions

  def isbn
    @_isbn ||= self[:isbn].to_isbn
  end

  def isbn=(obj)
    self[:isbn] = obj.to_isbn.to_s
  end
end

Summary

So there are two options here:

  1. Refine the clean-up of the parameters, taking them out of the initialiser, and letting us do ISBN.new(obj) for fairly arbitrary classes of obj.
  2. Refine the complete transformation, allowing obj.to_isbn within any class or module where we have invoked the use of the appropriate refinement.

One further point: to more explicitly hint that this is our application's refinement on the behaviour of the object, would it be wise to have a naming convention that uses "as" instead of "to", for example 9783161484100.as_isbn? At a push you could claim that the "as" is an acronym for "application specific", if you really wanted ...

Featured ones: