We are working to add more thorough progress tracking to upcase.com.
Chad and I were pairing on the backend for this feature, and we created a model
called Status
, that belongs both to a User
and to an Exercise
. This is a
many-to-many relationship that holds a state
attribute. That way we can query,
for each exercise, what its state
is for the currently signed in user.
When a user starts an exercise, we create a Status
object, with a state
of
"Started"
. If there is no related Status
, then the exercise was not yet
started (and we display to the user "Not Started"
).
Initial Implementation: Model method
Initially we implemented the following method in the Exercise
model:
class Exercise < ActiveRecord::Base
def state_for(user)
if status = status_for(user)
status.state
else
"Not Started"
end
end
def status_for(user)
statuses.where(user: user).order(:created_at).last
end
end
This made the tests green, and now it was time to refactor. The model was
responsible of knowing what to display in the view for an exercise that was not
started (the "Not Started"
string). This was obvious while writing tests. We
moved from integration into unit tests, and discovered we were asserting the
label shown to the user inside of the model spec.
First Refactoring: View Helpers
We felt this belonged to the View layer, and moved the method into a helper:
module ExercisesHelper
def state_for(user:, exercise:)
if status = exercise.status_for(user)
status.state
else
"Not Started"
end
end
end
At this point, Chad and I pushed up our latest changes and opened a pull request. The feedback we received provided us with an even better solution:
Second Refactoring: Null Objects
The helper’s conditional decides what is the state
for a Status
that doesn’t
yet exist. The state
of a NullStatus
. We chose to name this null object
NotStarted
because it makes explicit why we need it, rather than just
denoting what it is.
The third and best implementation we found is:
class NotStarted
def state
"Not Started"
end
end
class Exercise < ActiveRecord::Base
def status_for(user)
statuses.where(user: user).order(:created_at).last || NotStarted.new
end
end
In the view we always show state
, and the corresponding object will always
tell us:
<%= exercise.status_for(current_user).state %>
We Replaced Conditional with Polymorphism. No wonder the reviewer was also the author of the Polymorphism blog post, thank you Joe! Thank you twice.
What’s next
Upcase subscribers get access to the Upcase GitHub repo, where they are welcome to see all new features, improvements, and refactorings as we make them.
If you already have access, you may check the pull request with the whole discussion (PR #914). Otherwise, you can always get access to this and more thoughtbot content and repositories through Upcase.
Enjoy!