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:
- 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. - 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)
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.
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:
... if I thought anyone was ever going to see my code.
I like the semantics, but it's a bit "out there".
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?
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.
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.
Super enlightening Ruby post π
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.
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.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.