In Progress
Unit 1, Lesson 1
In Progress

Whole Values in Rails with Justin Weiss

 

In this long-requested episode, guest chef Justin Weiss returns to teach you how to integrate the Whole Value pattern into Rails applications.

Video transcript & code

A while back we started a series of episodes on the CHECKS pattern language by Ward Cunningham. We particularly focused on using the Whole Value Pattern to richly model information such as names and durations.

One question I've received over and over since releasing those episodes is: how can we integrate these ideas into Rails applications? I knew I wanted to answer this question, but the truth is I don't spend a lot of time in Rails code anymore. So instead of risking giving an inadequate answer, I decided to ask my friend Justin Weiss if he could make a guest episode on this topic.

You might remember Justin from Episode #269, when he introduced the tsort Ruby standard library. Justin a master Rails programmer, and he's the author of the book Practicing Rails. In the video you're about to see, he will take you on a fast-paced tutorial of how to augment Rails models with Whole Value attributes, including conversions, queries, and validations, all without fighting against Rails conventions. Enjoy!

In Episode 432 and Episode 433, Avdi built a tiny app for managing school courses. He started by using strings and numbers for course name and duration, but soon ran into a problem. How do you represent a value, along with a unit of time, for duration? How can you provide your own values, and make sure that they make sense within your system?

Using a few patterns, like Whole Value and Exceptional Value, Avdi was able to create a course catalog with robust validation and error handling, including error messages and keeping invalid values around for editing.

But it ended with a cliffhanger: How do you do something like this in Rails? Having a model for complex values and validation is great, but how could that mesh with the validation that Rails already does?

As it turns out, bringing these ideas together is easier than you might think.

First, if we're building a Rails app, we need a Rails app to start with.

We'll skip some of the frontendy stuff we don't need, and get everything set up.

rails new catalog --skip-javascript --skip-action-mailer --skip-action-cable
cd catalog
bin/setup

There's a little bit of housekeeping we need to do before we start.

First, we'll create a directory app/values, and copy all of Avdi's whole value objects into it.

mkdir app/values
cp ../value_objects/* app/values

like this, so our models can use them.

And we'll create a test file we can use to play with these objects.

require './config/environment'
Rails.version  # => 

There we go. Next, we'll generate a course object. Same as Episode #432, it'll have a name attribute and a duration attribute. They'll both be strings, because I can't think of a better way to store them in the database. Plus, we'll be getting strings from the web anyway, so we'll already have to translate from string to Whole Value, and vice versa.

bin/rails generate scaffold Course name:string duration:string
bin/rails db:migrate

We'll start with something just like what Avdi built, it'll be an almost direct copy into Rails. An initialize method, along with a few setters.

require 'name'
require 'duration'

class Course < ApplicationRecord
  def initialize(*)
    super
    write_attribute(:name, Blank.new) unless self.name
    write_attribute(:duration, Blank.new) unless self.duration
  end

  def name=(new_name)
    super(Name(new_name))
  end

  def duration=(new_duration)
    super(Duration(new_duration))
  end
end

And let's try that out:

c = Course.new #
c.name = "Chair Moving 301"
c.name #
c.duration = "2 weeks"
c.duration #
c.duration.exceptional?

Already, we can see that this isn't working the way we expected it to. Even though we're using our Whole Values, Rails is converting them right into strings.

Now, you could hack around this. But it turns out writing Ruby objects to the database is something that Rails already knows how to do, it has to. It does that with dates, with enums, and with primitive types. So what if we could hook into that, and tell Rails how to translate our Whole Value types into something it can save?

We'll start by going back to our course object.

To hook into this translation that Rails knows how to do, we'll create a new subclass of ActiveModel::Type::Value. This class is responsible for translating something the user or database gives us, into a more complex object, and vice versa.

class DurationType < ActiveModel::Type::Value
end

There are two methods on ActiveModel::Type::Value we want to override: cast and serialize.

def cast(value)
end

def serialize(value)
end

We'll start with cast.

When you set an attribute on a model, like course.duration = 2 weeks, cast takes that value, 2 weeks, and transforms it into another object. For example, a String could be transformed into a Date object. This works no matter how you set the attribute, whether it's in code, from params, or from a database column.


So, for the duration attribute, we want to take whatever the user gives us, and pass it along to the Duration class:

def cast(value)
  Duration(value)
end

serialize does the opposite. takes a value object, and translates it into something that can be stored into a database row, or presented to the user. For our Duration object, we already have a .to_s that does exactly what we want -- translates it into a string our Duration object can read back in.

def serialize(value)
  value.to_s
end

So now we have a new ActiveModel type we can use inside our object. To hook it up to our attribute, we use the attribute method inside the Course:

attribute :duration, DurationType.new

This tells the course object that we want the name attribute to go through our new DurationType class, instead of just being a string, like it's defined in the database.

Now that we've done that, we can remove all the stuff we were doing to the duration attribute -- Rails will handle it from here.

So let's try it out.

c = Course.new(name: "Chair Moving 301", duration: "2 weeks")
c.duration #
c.save
Course.last.duration #

Our duration gets transformed into a Duration object... We can save it... and when we load it back in, it becomes an actual Duration type again!

There's something else we get for free here, too:

Course.where(duration: Weeks[2]).first #

We can find objects by actual duration types, not just strings!

There's one thing we're forgetting, though:

Course.new.duration.exceptional? # => 

Yep, we also have to handle nil values. We'll do it the same way Avdi did in Episode 432:

value.nil? ? Blank.new : Duration(value)
Course.new.duration.exceptional? # => 

Better.

class NameType < ActiveModel::Type::Value
  def cast(value)
    value.nil? ? Blank.new : Name(value)
  end

  def serialize(value)
    value.to_s
  end
end
attribute :name, NameType.new

Name works the same way. Create a name type, hook it up to the name whole value in the cast method. Then clear out all the unused methods in Course.

Course.create(name: "Potions")
Course.where(name: Name("Potions")).first

Course.create(name: "Thumbtwiddling 400", duration: "2 months")
Course.where(duration: Months[2]).last

And now we have something that works a lot like what we built in pure Ruby. The class is clean, and it's integrated well into Rails.

There's still one thing missing.

c = Course.new
c.duration = "3 eons"
c.duration.exceptional? # => 
c.valid? # => 

It seems like an exceptional object should be treated as invalid. But it's not.

This is easy to fix. We'll write a custom Rails validator.

There's a class in Rails, ActiveModel::EachValidator.

class ValueValidator < ActiveModel::EachValidator
end

When you inherit from it, you override a method called validate_each.

def validate_each(record, attribute, value)
end

Then, when your tell your model to validate an attribute, it'll call validate_each with the object itself. the attribute name, and the value being checked.

if value.exceptional?
  record.errors[attribute] << (options[:message] || "is invalid: #{value.reason}")
end

Ours is easy to write. If the value is exceptional, we'll add an error message, with the reason it's invalid.

Next, tell the Course object that it should use our Value validator on its Whole Value attributes:

  validates :name, value: true
  validates :duration, value: true

Try it out...

c.duration.valid? # => 
c.errors[:duration] # => 

Looking good!

Going back to our course class, it's small, it's Rails-like, but you can use with it in a more object-oriented way.

We've mostly been playing around with the objects directly, though. Let's see what it looks like inside an app.

We'll start a server...

bin/rails server

Visit the site...

(...here Justin navigates to http://localhost:3000/courses on his local computer to demonstrate...)

screenshot of http://localhost:3000/courses

 

 

Create a course, with an invalid date...

 

 

 

And our errors show up. And if we edit an existing course... We can modify the data, but use it those attributes in our app as if they're real, complex value objects.


class ValueValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    if value.exceptional?
      record.errors[attribute] << (options[:message] ||
                                   "is invalid: #{value.reason}")
      
    end
  end
end

class Course < ApplicationRecord
  attribute :name, NameType.new
  attribute :duration, DurationType.new

  validates :name, value: true
  validates :duration, value: true
  
end

Building your attributes this way adds some overhead. But it encourages you to build a robust domain model, which could make your app easier to think about and maintain over time. There's a big question to ask yourself as you go down this road:

Are the your whole value objects, how they're transformed, whether they're valid, all that stuff, is it inherent in the kind of object itself? Should all names work exactly the same way in your app? Should all durations?

Or does it depend on how you use the type? Do you want names to act differently depending on where they're created?

It's hard to know the answer to that -- it's a balance of robustness against flexibility.

With this kind of integration, though, using Whole Values into Rails is possible, maybe even easy. And that turns that kind of decision into a choice you can make, instead of a direction the framework forces you into.

Thanks for watching, and have an awesome day!

Responses