DEV Community

MetaDave 🇪🇺
MetaDave 🇪🇺

Posted on • Updated on

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 ...

Top comments (8)

Collapse
 
philnash profile image
Phil Nash

Nice use of refinements. I've not seen them around much, but this is a good example of how they can really clean up an approach.

Collapse
 
databasesponge profile image
MetaDave 🇪🇺 • Edited

Thanks Phil. I like the way they put the Ruby core objects on a level playing field with your own application objects, and let them add the behaviour you want.

Having said that, I'd probably draw a line at doing anything like:

module EasyFinder
  refine Integer do
    def to_book
      Book.find(self)
    end
  end

  refine String do
    def to_book
      Book.find(self.to_i)
    end
  end
end

... if I thought anyone was ever going to see my code.

I like the semantics, but it's a bit "out there".

 
databasesponge profile image
MetaDave 🇪🇺

In itself, adding responses to a class clearly does not constitute type checking, it is simply "adding responses to a class".

So are you saying that needing to reference the module that adds the behaviour constitutes a form of type checking?

 
databasesponge profile image
MetaDave 🇪🇺

Refinements depend on the type

Everything in some way depends on the type, because the type defines the messages that can be received and the responses. That is not type checking, though, that is just code.

Other types, no matter whether they respond to / implement this functionality are rejected

If they respond to the correct message then they are not rejected by my ISBN class, because my ISBN class is not type checking. The refinements are just a way of adding the required responses that is safer than monkey patching, but ultimately they just define behaviour.

I would be keen on hearing other opinions, though.

Collapse
 
ben profile image
Ben Halpern

Super enlightening Ruby post 👌

 
databasesponge profile image
MetaDave 🇪🇺

Yes, it is a duck check, but I don't want a type check or a duck check. Or any kind of check, really.

What I'm describing is how to achieve the aim of not doing those checks. If that is not an aim that someone shares then they of course are free to implement a different methodology.

Collapse
 
databasesponge profile image
MetaDave 🇪🇺

Indeed, Ruby does conversions very well, as you show in the first code sample.

I do like case, but again it's a type check. I'd rather avoid them if I can. It's good code that, though.

 
databasesponge profile image
MetaDave 🇪🇺

I don't see it as anything other than enabling 100% duck typing in the application. I'm ensuring that the application depends only on the ability to respond, not on the underlying object type.