In Progress
Unit 1, Lesson 1
In Progress

From Hash to Value Object

The Value Object pattern is one we’ve referenced numerous times on this show, but usually in the service of other topics. Today, we’re turning the spotlight directly onto this foundational pattern, with help from guest chef Tom Dalling. Tom is going to show us how Value Objects can function as contracts, firming up the expectations at the integration points of our code. In the process, you’ll also learn how to use his ValueSemantics gem to build Value Objects. Enjoy!

Video transcript & code

Replacing Hashes With Value Objects using ValueSemantics

Let's say we're writing an integration---some code that fetches data from an external service, over the network.

We might make a request.

Check if the response is ok.

And then return the contents of the response, which, in this case, is https://www.rubytapas.com/t/json.

Otherwise, return nil.


    module WidgetService
      def self.find(widget_id)
        response = HTTP.get("https://example.com/widgets/#{widget_id}")
        if response.ok?
          response.parse[:data]
        else
          nil
        end
      end
    end

We're done, right? It fetches a big blob of JSON that contains all the data we need. But there are a few problems with this return value.

What exactly is this return value? It's probably a Hash, but what are its contents? Which keys can we rely on being there? Which values might be nil? What parts of our app could break if the external service starts returning a slightly different JSON response? These are important questions, with no clear answers visible in the code.

Let me propose a slightly different approach---one that addresses these problems.

Let's replace this big blob of JSON with a value class. There are many ways we could implement this, but today I'm going to show you how to achieve this with the value_semantics gem.

Let's start by considering which parts of the JSON response we actually need. That is, what do we want to guarantee to the rest of the application?

Maybe we only need the id, name, and created_at. This is a good start. Instead of returning some big ambiguous Hash, we can return a Widget object, which is guaranteed to have these three attributes.


    class WidgetService
      class Widget
        include ValueSemantics.for_attributes {
          id
          name
          created_at
        }
      end

      # ... other code here
    end

Lets go back and replace the return value with this new value object.


    module WidgetService
      # ... other code here

      def self.find(widget_id)
        response = HTTP.get("https://widgets.example.com/widgets/#{widget_id}")
        if response.ok?
          attrs = response.parse[:data].slice(:id, :name, :created_at)
          Widget.new(attrs)
        else
          nil
        end
      end
    end

This works. It returns an object with the attributes we specified. But we can improve upon this.


    widget = WidgetService.find(123)

    widget # => #
    widget.id # => 123
    widget.name # => "Foo"

What is a widget id? Some systems use integer ids, some use strings. We happen to know that these ids are integers, ...

So let's add a validator for that.

What about name? Let's say that it's a string, but it is optional, so it might be nil.

We can add a validator for that too.

The last attribute, created_at, is a time, and it's not optional.


    class WidgetService
      class Widget
        include ValueSemantics.for_attributes {
          id Integer
          name Either(String, nil)
          created_at Time
        }
      end

      # ... other code here
    end

But when we test this now, we run into a little hiccup. It throws an error.


    WidgetService.find(123)
    # ~> ArgumentError:
    # ~>   Value for attribute 'created_at' is not valid: "2019-04-25T06:00:00+1000"

We said that the created_at attribute is always a Time object, but JSON doesn't have time objects, so it's represented as an ISO8601-formatted string. We don't want to change the created_at validator to be a string, because conceptually it really is a time value. What we want is to convert the string into a Time object. We can achieve this with the coercion feature of the value_semantics gem.

First we specify that this attribute has a coercion method.

Then we define the coercion method. This method takes the unvalidated attribute value as an argument.

If the value is a string, we attempt to parse it, returning a Time object.

Otherwise we allow the value to be returned without being changed. Validation happens after coercion, so we don't need to handle that here.


    class WidgetService::Widget
      include ValueSemantics.for_attributes {
        id Integer
        name Either(String, nil)
        created_at Time, coerce: true
      }

      def self.coerce_created_at(value)
        if value.is_a?(String)
          Time.iso8601(value)
        else
          value
        end
      end
    end

Now, when creating a widget with a string for its created_at attribute, that string will automatically be converted into a Time object.


    widget = WidgetService.find(123)
    widget.created_at # => 2019-04-25 06:00:00 +1000
    widget.created_at.class # => Time

We're done. Let's go back and consider the problems we were trying to improve upon in the original implementation.

What exactly is the return value? Looking at the code, we can clearly see that it's going to be a Widget object, or nil.

What are the contents of this return value? The Widget class documents this well. It has an id, a name, and a created_at. Which attributes can we rely on being there? The three attributes are guaranteed to be present on the widget. Widget objects can not be created with attributes missing.

Which attributes might be nil? We can tell by looking at the code that only the name attribute can be nil. The other attributes are guaranteed not to be nil. For example, if we try to create a widget with a nil id, it will raise an error.

Which leads us to: what parts of our app could break if the external service starts returning a slightly different JSON response?


    widget = { id: 123, name: 'Tom' }
    puts widget[:created_at].year
    # ~> undefined method `year' for nil:NilClass (NoMethodError)

If the widget was still just a hash, then we would most likely get an "undefined method for nil" error when we try to use the unexpected value. To debug this, we would have to dig backwards through the code to try and find where this unexpected value came from originally.


    widget = WidgetService::Widget.new(id: 123, name: 'Tom')
    # ~> Value missing for attribute 'created_at' (ValueSemantics::MissingAttributes)
    puts widget[:created_at].year

With the value object we implemented, unexpected values cause an error to be raised within the integration. Bad values will not escape into the database or the view templates. This is easier to debug.

What we've seen is that this Widget value class will act like a contract between the integration and the rest of the application. It functions as documentation for other developers who will read this code in the future, and also as validation, ensuring that the values are exactly what we expect them to be. And if the contract is broken with unexpected values, debugging is easier, because we get a more descriptive error message, and a more accurate backtrace. Not to mention that our app can treat widgets as proper objects, instead of fiddling with hash keys and square brackets.

In my opinion this little 15-line-of-code class adds a lot of value when it comes to maintainability in the future.

Responses