A Refactoring Story
November 15, 2016Last spring, development started up again on a side business that I'm involved in. I'd hardly touched the two apps that run the business in over 3 years, and they needed some love. Back in May I discussed wrestling with the question of whether to Refactor or Rewrite these apps. I decided to refactor, and I don't regret the decision. What follows is one small refactoring that I did.
Context
TalentSoup is a marketplace. It connects professional and amateur actors, actresses, and models with producers and photographers. Talent sign up, upload photos, and fill out a basic profile. They are then matched with open projects in their area that they are a fit for.
Each talent sees a Talent Dashboard when they first log in. The Dashboard has two main components. The first is a listing of all open projects across TalentSoup. The second, a listing of projects for which they are under consideration to be cast.
The data driving these two components comes from a separate, internal project management app we've written. This app exposes a REST-ful API that TalentSoup calls to get the data it needs to display the Dashboard.
Dashboard Behavior: How It Should Work
If it's the user's first time seeing the Dashboard, we want to display a "welcome message" which has tips on how to get the most out of their TalentSoup account. There are two cases which are a "first-time view" of the Dashboard. The first is a new user signing up and being immediately redirected to the Dashboard. The second case is an existing user who is viewing the Dashboard for the first time after we had made significant UI changes to the old Dashboard. We want them to see the "welcome message" too, since the changes were so radical. On subsequent visits, they'll all see the real dashboard.
To keep track of whether a user had viewed the "welcome message", I
added a boolean column, wc_view
, (default
false
) to the users table (wc
stands for
"Webcomp", which is what we call Talent portfolios). When the welcome message has
been viewed, wc_view
becomes true
. Looking
back, this column is unhelpfully named. Something like
welcome_message_viewed
would have been clearer.
Initial Code
Before the refactor, the controller action driving the Dashboard code looked liked this (URLs are not real, obviously):
class TalentController < ApplicationController
def dashboard
if current_talent.wc_view
begin
@open_projects = HTTParty.get(
"https://commissary.dev/open-projects",
body: "",
output: 'json'
)
@availability_requests = HTTParty.get(
"https://commissary.dev/availability-requests/#{current_talent.id}",
body: "",
output: 'json'
)
rescue
@availability_requests = []
@open_projects = []
end
else
current_talent.wc_view = true
current_talent.save
render template: "talent/welcome"
end
end
end
Code Review
I cringe looking at this, but it's how I wrote code 4-5 years ago. You have to start somewhere.
If I were reviewing this code in a pull request today, I would make the following comments:
-
The
dashboard
action is not a REST-ful route, according to Rails idioms. It should extracted to it's own controller . -
The controller is updating
current_talent
(anActiveRecord
object) directly. This means it has to know which columns mean what. The knowledge about how the application should determine whether or not a Talent has viewed the "welcome message" should be pushed down to the Talent class itself (or some other class?), but shouldn't live in the controller. -
The knowledge about how to mark a talent as having viewing the "welcome message" should also live in the Talent class itself (or another class), but not in the controller.
-
The two
HTTParty
calls to the external Commissary service means the controller knows everything about where the endpoints are, and what to do in case of an error. This isn't the worst approach, but could be improved by moving these calls to a separate class. Doing so would also give us the benefit of being able to use those classes in other places.
Generally speaking, this code violates a number of rules that I (now) try to abide by:
Violates Rails idioms.
Spreads too much knowledge about implementation details.
Misses the opportunity to extract interesting functionality (calls to the external Commissary service) into small classes that can be reused.
Is ugly to look at.
The Refactor
The first thing I did was move all the "has Talent viewed their
welcome message before?" logic out of the controller. I can get around
how badly the wc_view
column is named by wrapping it in an
intention-revealing method.
# app/models/talent.rb
class Talent < ActiveRecord::Base
def first_webcomp_view?
!wc_view
end
def first_webcomp_viewed
update_column(:wc_view, true)
end
end
# test/models/talent_test.rb
class TalentTest < ActiveSupport::TestCase
def setup
@talent = talents(:luke_skywalker)
end
test "#first_webcomp_view? is true when wc_view is true" do
@talent.update_column(:wc_view, true)
assert @talent.wc_view
refute @talent.first_webcomp_view?
end
test "#first_webcomp_view? is true when wc_view is false" do
@talent.update_column(:wc_view, false)
refute @talent.wc_view
assert @talent.first_webcomp_view?
end
test "#first_webcomp_viewed sets wc_view to true" do
@talent.update_column(:wc_view, false)
@talent.first_webcomp_viewed!
assert @talent.wc_view
refute @talent.first_webcomp_view?
end
end
Now I am able to clean the controller up, and rework the logic with my new methods. The controller will be a bit easier to read, and the implementation details have been effectively hid:
class TalentController < ApplicationController
def dashboard
if current_talent.first_webcomp_view?
current_talent.first_webcomp_viewed
render template: "talent/welcome"
else
begin
@open_projects = HTTParty.get(
"https://commissary.dev/open-projects",
body: "",
output: 'json'
)
@availability_requests = HTTParty.get(
"https://commissary.dev/availability-requests/#{current_talent.id}",
body: "",
output: 'json'
)
rescue
@availability_requests = []
@open_projects = []
end
end
end
end
Now, the details about what columns Talent has, and what they mean, are pushed down into the Talent class itself. If those columns change, or if what it means to have made the initial view of your Dashboard changes, we can change the underlying implementation and are less likely to need to touch the controller.
Next, I re-worked the two calls to the external Commissary service. I'm going to detail the "open projects" path, since it's shorter.
# app/services/commissary.rb
module Commissary
ENDPOINT = "https://commissary.dev/"
def self.endpoint_for(action)
"#{ENDPOINT}#{action}"
end
end
# app/services/commissary/open_projects.rb
module Commissary
class OpenProjects
attr_accessor :http_client
def self.fetch
self.new.fetch
end
def initialize
@http_client = HTTParty
end
def fetch
http_client.get(
Commissary.endpoint_for("open-projects"),
body: '',
output: 'json'
).map { |project| OpenStruct.new(project) }
rescue
[]
end
end
end
This is a simple class, but there's more going on that might initially appear. The
first is that there's an attr_accessor
for an http_client
. You
can't initialize the class with a custom http_client
(the "default" is
HTTParty
), but you can set one after initialization. I can't take credit
for this pattern; I learned it (and many other things!) from
Brandon Hilkert. The
motiviation for using this pattern will become apparent in the test
below.
I also convert the response into an array of OpenStruct
objects. This is purely for the convenience of using dot notation in
my views.
One of the advantages of this approach is that it makes the
Commissary::OpenProjects
class easier to test. There are of
course many ways to stub/mock out an HTTP request, but using this
method, we don't need any external libraries! We can do it all in plain
Ruby:
require 'test_helper'
class Commissary::OpenProjectsTest < ActiveSupport::TestCase
test "#fetch with a good response" do
client = Commissary::OpenProjects.new
client.http_client = FakeHTTParty
open_projects = client.fetch
assert_equal 1, open_projects.count
project = open_projects.first
assert_equal "987654321", project.id
assert_equal "An amazing project", project.name
# other assertions omitted for brevity
end
test "#fetch when an exception occurs" do
client = Commissary::OpenProjects.new
client.http_client = FakeHTTPartyWithException
open_projects = client.fetch
assert_equal [], open_projects
end
class FakeHTTParty
def self.get(*args)
[
{
id: "987654321",
name: "An amazing project",
}
]
end
end
class FakeHTTPartyWithException
def self.get(*args)
raise "Oops!"
end
end
end
We've "mocked" an HTTP request to an external service without using a gem like VCR or Webmock. The disadvantage to this approach is that, if the response from the Commissary service changes, I can't just blow away my cassettes, as I could if I were using VCR, regenerate them, and fix my tests. I'll have to fix my fake client(s) also. However, weighed against the overhead of adding another gem to my app, it's a tradeoff I am willing to make. Additionally, in writing a simple mock HTTP client like this, you become familiar with the shape of the response coming back from the external service in a way you don't when using VCR, for example. That's been my experience, having used VCR extensively.
The other interesting thing in Commissary::OpenProjects
is the fetch
class method, which is just:
def self.fetch
self.new.fetch
end
This is a pattern I learned from Michael Klett. It
can be more work than it's worth sometimes, but in this case, since the constructor for
Commissary::OpenProjects
takes no args, having this class method saves us from doing
Commissary::OpenProjects.new.fetch
, which I find aesthetically objectionable!
Our refactored controller action now looks like:
class TalentController < ApplicationController
def dashboard
if current_talent.first_webcomp_view?
current_talent.first_webcomp_viewed
render template: "talent/welcome"
else
@open_projects = Commissary::OpenProjects.fetch
# refactoring not shown for this
@availability_requests = Commissary::AvailabilityRequests.fetch(
talent_id: current_talent.id
)
end
end
end
The final step was to move this action (part of a larger controller with many other non-RESTful routes) into it's own controller:
# config/routes.rb
resource :talent_dashboard, only: [:show]
# app/controllers/talent_dashboards_controller.rb
class TalentDashboardsController < ApplicationController
def show
if current_talent.first_webcomp_view?
current_talent.first_webcomp_viewed
render template: "talent/welcome"
else
@open_projects = Commissary::OpenProjects.fetch
@availability_requests = Commissary::AvailabilityRequests.fetch(talent_id: current_talent.id)
end
end
end
The motivation here is to adhere to Rails idioms. Additionally, smaller controllers are easier to read, and therefore easier to get context about. I don't mind an app with a lot of small controllers that do one or two things well.
Conclusion
This code could be improved even further by extracting the methods on
the Talent
class into a plain Ruby object (a "service"
object, in the parlance of our times). I decided against this because
the Talent
class, although it represents the main user of
the app, is not a god object.
If, in the future, it feels like Talent
is growing out of
control, I could see the value in such an extraction.