The secret to Rails OO design
UPDATE: I also have a follow-up here.
I often tell people that I learned Ruby via Rails. This is pretty much the worst way to do it, but Iād learned so many programming languages by then that it didnāt hinder me too much. The one thing that it did do, however, was give me a slightly twisted sense of how to properly design the classes needed in a Rails app. Luckily, I obsessively read other peopleās code, and Iāve noticed that thereās one big thing that is common in most of the code thatās written by people whose design chops I respect.
This particular thing is also seemingly unique to those people. Itās not something that people who donāt write good code attempt, but do badly. Itās like a flag, or signal. Now, when I see someone employ this, I instantly think āthey get it.ā Maybe Iām giving it too much credit, but this advanced design technique offers a ton of interconnected benefits throughout your Rails app, is easy to imploy, and speeds up your tests by an order of magnitude or more. Unfortunately, to many beginning Rails devs, itās non-obvious, but I want you to write better code, and so Iām here to ābreak the secret,ā if you will, and share this awesome, powerful technique with you.
Itās called the āPlain Old Ruby Domain Object.ā
Yep, thatās right. A Ruby class that inherets from nothing. Itās so simple that it hides in plain sight. Loved by those whoāve mastered Rails, Plain Old Ruby Objects, or āPOROsā as some like to call them, are a hidden weapon against complexity. Hereās what I mean. Examine this āsimpleā model:
class Post < ActiveRecord::Base
def self.as_dictionary
dictionary = ('A'..'Z').inject({}) {|h, l| h[l] = []; h}
Post.all.each do |p|
dictionary[p.title[0]] << p
end
dictionary
end
end
We want to display an index page of all our posts, and do it by first letter. So we build up a dictionary, and then put our posts in it. Iām assuming weāre not paginating this, so donāt get caught up in querying for all Posts. The important thing is the idea: we can now display our posts by title:
- Post.as_dictionary do |letter, list|
%p= letter
%ul
- list.each do |post|
%li= link_to post
Sure. And in one way, this code isnāt bad. Itās also not good: Weāve mixed a presentational concern into our model, which is supposed to represent buisness logic. So letās fix that, via a Presenter:
class DictionaryPresenter
def initialize(collection)
@collection = collection
end
def as_dictionary
dictionary = ('A'..'Z').inject({}) {|h, l| h[l] = []; h}
@collection.each do |p|
dictionary[p.title[0]] << p
end
dictionary
end
end
We can use it via DictionaryPresenter.new(Post.all).as_dictionary
. This has tons of benefits: weāve moved presentation logic out of the model. Weāve already added a new feature: any collection can now be displayed as a dictionary. We can easily write isolated tests for this presenter, and they will be fast.
This post isnāt about the Presenter pattern, though, as much as I love it. This sort of concept appears in other places, too, āthis domain concept deserves its own class.ā Before we move to a different example, letās expand on this further: if we want to sort our Posts by title, this class will work, but if we want to display, say, a User, it wonāt, because Users donāt have titles. Furthermore, weāll end up with a lot of Posts under āA,ā because the word āaā is pretty common at the beginning of Posts, so we really want to take the second word in that case. We can make two kinds of presenters, but now we lose that generality, and the concept of ādisplay by dictionaryā has two representations in our system again. You guessed it: POROs to the rescue!
Letās change our presenter slightly, to also accept an organizational policy object:
class DictionaryPresenter
def initialize(policy, collection)
@policy = policy
@collection = collection
end
def as_dictionary
dictionary = ('A'..'Z').inject({}) {|h, l| h[l] = []; h}
@collection.each do |p|
dictionary[@policy.category_for(p)] << p
end
dictionary
end
end
Now, we can inject a policy, and have them be different:
class UserCategorizationPolicy
def self.category_for(user)
user.username[0]
end
end
class PostCategorizationPolicy
def self.category_for(post)
if post.starts_with?("A ")
post.title.split[1][0]
else
post.title[0]
end
end
end
Bam!
DictionaryPresenter.new(PostCategorizationPolicy, Post.all).as_dictionary
Yeah, so thatās getting a bit long. It happens. :) You can see that now each concept has one representation in our system. The presenter doesnāt care how things are organized, and the policies only dictate how things are organized. In fact, my names sorta suck, maybe it should be āUsernamePolicyā and āTitlePolicyā, actually. We donāt even care what class they are!
It goes further than that in other directions, too. Combining the flexibility of Ruby with one of my favorite patterns from āWorking Effectively with Legacy Code,ā we can take complex computations and turn them into objects. Look at this code:
class Quote < ActiveRecord::Base
#<snip>
def pretty_turnaround
return "" if turnaround.nil?
if purchased_at
offset = purchased_at
days_from_today = ((Time.now - purchased_at.to_time) / 60 / 60 / 24).floor + 1
else
offset = Time.now
days_from_today = turnaround + 1
end
time = offset + (turnaround * 60 * 60 * 24)
if(time.strftime("%a") == "Sat")
time += 2 * 60 * 60 * 24
elsif(time.strftime("%a") == "Sun")
time += 1 * 60 * 60 * 24
end
"#{time.strftime("%A %d %B")} (#{days_from_today} business days from today)"
end
end
Yikes! This method prints a turnaround time, but as you can see, itās a complex calculation. Weād be able to understand this much more easily of we used Extract Method a few times to break it up, but then we risk polluting our Quote class with more stuff thatās only relevant to pretty turnaround calculation. Also, please ignore that this is also presentation on the model; we just care that itās a complex bit of code for this example.
Okay, so hereās the first step of this refactoring, which Feathers calls āBreak Out Method Object.ā You can open your copy of āWorking Effectively With Legacy Codeā and turn to page 330 to read more. If you donāt have a copy, get one. Anyway, I digress. Hereās the plan of attack:
- Create a new class for the computation
- Define a method on that class to do the new work.
- Copy the body of the old method over, and change variable references to instance variables.
- Give it an initialize method that takes arguments to set the instance variables used in step 3.
- Make the old method delegate to the new class and method.
Iāve changed this slightly for Ruby, since we canāt Lean On The Compiler, and a few of Feathersā steps are about doing this. Anyway, letās try this on that code. Step 1:
class Quote < ActiveRecord::Base
def pretty_turnaround
#snip
end
class TurnaroundCalculator
end
end
Two:
class TurnaroundCalculator
def calculate
end
end
Three:
class TurnaroundCalculator
def calculate
return "" if @turnaround.nil?
if @purchased_at
offset = @purchased_at
days_from_today = ((Time.now - purchased_at.to_time) / 60 / 60 / 24).floor + 1
else
offset = Time.now
days_from_today = @turnaround + 1
end
time = offset + (@turnaround * 60 * 60 * 24)
if(time.strftime("%a") == "Sat")
time += 2 * 60 * 60 * 24
elsif(time.strftime("%a") == "Sun")
time += 1 * 60 * 60 * 24
end
"#{time.strftime("%A %d %B")} (#{days_from_today} business days from today)"
end
end
I like to give it a generic name at first, and then give it a better one in step 5, after we see what it really does. often our code will inform us of a good name.
Four:
class TurnaroundCalculator
def initialize(purchased_at, turnaround)
@purchased_at = purchased_at
@turnaround = turnaround
end
def calculate
#snip
end
end
Five:
class Quote < ActiveRecord::Base
def pretty_turnaround
TurnaroundCalculator.new(purchased_at, turnaround).calculate
end
end
Done! We should be able to run our tests and see them pass. Even if ārun our testsā consists of manually checking it outā¦
So whatās the advantage here? Well, we now can start the refactoring process, but weāre in our own little clean room. We can extract methods into our TurnaroundCalcuator class without polluting Quote, we can write speedy tests for just the Calculator, and weāve split out the idea of calculation into one place, where it can easily be changed later. Hereās our class, a few refactorings later:
class TurnaroundCalculator
def calculate
return "" if @turnaround.nil?
"#{arrival_date} (#{days_from_today} business days from today)"
end
protected
def arrival_date
real_turnaround_time.strftime("%A %d %B")
end
def real_turnaround_time
adjust_time_for_weekends(start_time + turnaround_in_seconds)
end
def adjust_time_for_weekends(time)
if saturday?(time)
time + 2 * 60 * 60 * 24
elsif sunday?(time)
time + 1 * 60 * 60 * 24
else
time
end
end
def saturday?(time)
time.strftime("%a") == "Sat"
end
def sunday?(time)
time.strftime("%a") == "Sun"
end
def turnaround_in_seconds
@turnaround * 60 * 60 * 24
end
def start_time
@purchased_at or Time.now
end
def days_from_today
if @purchased_at
((Time.now - @purchased_at.to_time) / 60 / 60 / 24).floor + 1
else
@turnaround + 1
end
end
end
Wow. This code I wrote three years ago isnāt perfect, but itās almost understandable now. And each of the bits makes sense. This is after two or three waves of refactoring, which maybe Iāll cover in a separate post, becuase this was more illustrative than I thought⦠anyway, you get the idea. This is what I mean when I say that I shoot for roughly five-line methods in Ruby; if your code is well-factored, you can often get there.
This idea of extracting domain objects that are pure Ruby is even in Rails itself. Check out this route:
root :to => 'dashboard#index', :constraints => LoggedInConstraint
Huh? LoggedInConstraint?
class LoggedInConstraint
def self.matches?(request)
current_user
end
end
Whoah. Yep. A domain object that describes our routing policy. Awesome. Also, validations, blatantly stolen from omgbloglol:
def SomeClass < ActiveRecord::Base
validate :category_id, :proper_category => true
end
class ProperCategoryValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless record.user.category_ids.include?(value)
record.errors.add attribute, 'has bad category.'
end
end
end
This isnāt a plain Ruby class, but you get the idea.
Now, you might be thinking, āSteve: This isnāt just for Rails! Youāve lied!ā Why yes, actually, youāve caught me: this isnāt the secret to Rails OO, itās more of a general OO design guideline. But thereās something special about Rails which seems to lure you into the trap of never breaking classes down. Maybe itās that lib/
feels like such a junk drawer. Maybe itās that the fifteen minute examples only ever include ActiveRecord models. Maybe itās that more Rails apps than not are (WARNING: UNSUBSTANTIATED CLAIM ALERT) closed source than open, so we donāt have as many good examples to draw upon. (I have this hunch since Rails is often used to build sites for companies. Gems? Sure? My web app? Not so much. I have no stats to back this up, though.)
In summary: Extracting domain objects is good. They keep your tests fast, your code small, and make it easier to change things later. I have some more to say about this, specifically the ākeeps test fastā part, but Iām already pushing it for length here. Until next time!