Legacy Refactor: Separation of Concerns in Sorry Girl Rails app
It’s handy to be applying for jobs because it gives me the excuse to go back to some of my Rails portfolio projects and refactor them. Whenever I undertake a refactor of my own work, there are 2 important people I keep in mind: Past Me and Future Me. Past Me did the darned best she could with what she knew at the time and deserves compassion. Future Me loves easy-to-read and easy-to-maintain code and she deserves to get what she loves. I believe this refactor will leave Future Me appreciating Future Past Me’s choices… or something like that.
Now on to the show…
The app of choice for this refactor is Sorry Girl - The Apology You Needed to Hear, but from Ryan Gosling. The concept of the app is that a user writes a short apology note with a picture of Ryan Gosling to send to a friend via social media.
The basic mechanics are:
- A user submits
apology.body
data on theapology
form. - The
Image
class is prompted to select a random image from theapp/assets/images
directory. - The
apology.image
attribute is set to that image file and theapology
is saved. - After save, the
apologies#show
page is rendered.
Refactoring the ‘Apology’ model
First on my list to refactor was the Apology
model. Two years ago, when I built this app, I didn’t know much about separation of concerns. Looking back at it today, I’ve found a great example of one model knowing too much about another one. It needed to be separated out into 2 models that each have their own responsibilities.
Before: Apology Model
# app/models/apology.rb
class Apology < ActiveRecord::Base
# This model knows what all of the images are. In fact, it is the sole
# location of this knowledge. And it is documented in one very long line.
# Hard-coded. Difficult to maintain. Too unwieldy to scale.
# Go on, scrolllll to the right to see that long line...
IMAGES = ["ryan-gosling01.jpg", "ryan-gosling02.jpg", "ryan-gosling03.jpg", "ryan-gosling04.jpg", "ryan-gosling05.jpg", "ryan-gosling06.png", "ryan-gosling07.jpg", "ryan-gosling08.jpg", "ryan-gosling09.jpg", "ryan-gosling10.jpg"]
# Assigns a random image every time the object is saved.
# That means both 'create' and 'update' assign a random image.
before_save :generate_image
# This is fine until you need to know this character max in other
# parts of the app. Spoiler alert: that happened.
validates :body, presence: true, length: { maximum: 170 }
def generate_image
self.image = IMAGES.sample
end
end
Crawling back into Past Me’s brain, I’ll venture that I built that giant IMAGES
array because it was a simple way to access the files. There was a finite set of images (10) stored in the app/assets/images
directory and this was the easiest way I knew to grab them.
Why It Needs Refactoring
Firstly, there is a long list of hand-entered, hard-coded filenames. Low-hanging fruit? Break it up into separate lines. We are humans after all, and code should be readable:
IMAGES = [
"ryan-gosling01.jpg",
"ryan-gosling02.jpg",
"ryan-gosling03.jpg",
"ryan-gosling04.jpg",
"ryan-gosling05.jpg",
"ryan-gosling06.png",
"ryan-gosling07.jpg",
"ryan-gosling08.jpg",
"ryan-gosling09.jpg",
"ryan-gosling10.jpg"
]
If I want to modify this inventory of photos (and I do), I’ll need to manually edit this list. That’s unpleasant for a list of only 10 images, but it becomes unwieldy if my image inventory were to grow to say, 30, or 300, or a whole API of images. This approach also assumes that I will make no mistakes when entering filenames. I don’t want to make that assumption about any human, even myself. There is still more work to do.
After: Apology Model
# app/models/apology.rb
class Apology < ActiveRecord::Base
# Constant stores character max so it is found easily by a person
# updating this value for the entire app.
CHARACTER_MAX = 170
# Assigns the image upon creation instead of save, otherwise a new
# image is assigned any time the record is edited
before_validation :assign_image, on: :create
# The character max now comes directly from the constant
validates :body, presence: true, length: { maximum: CHARACTER_MAX }
# Adds validation on the image field
validates :image, presence: true
# This ordering was previously in the controller, but belongs here.
scope :ordered, -> { all.order("created_at DESC") }
# Makes the character max easily accessible by other parts of the
# app such as tests, javascript functions, and the view
def character_max
CHARACTER_MAX
end
# Changes from 'IMAGE.sample' to 'Image.sample' because now the
# 'Image' class handles the logic behind the image selection.
def assign_image
self.image = Image.sample
end
end
The need to extract the CHARACTER_MAX
into a constant became evident when I wrote javascript form feedback for this app. As I tinkered with the hard-coded character count over and over, I had to do it in 3 places. That was irritating! When I had to do it a 4th time while writing tests, that was the last straw. Now the CHARACTER_MAX
is set in 1 place and it is referenced in these other places:
# app/views/apologies/_form.html.erb
<%= form_for(@apology) do |f| %>
<!-- Used HERE in the text -->
<span id="character-count"><%= @apology.character_max %></span> characters remaining
<%= f.text_area :body, autofocus: true, required: true, placeholder: "Hey Girl, I'm sorry I was such a jerkface..." %>
<%= f.submit %>
<% end %>
<script>
// Used HERE in the javascript
remainingCharacters(<%= @apology.character_max %>)
</script>
# spec/models/apology_model_spec.rb
RSpec.describe Apology, :type => :model do
let(:apology) { Apology.new(body: 'lorem', image: 'image.jpg') }
# Used HERE to set up testing limitations
character_max = Apology::CHARACTER_MAX
string_at_char_max = 'x' * character_max
string_over_char_max = 'x' * (character_max + 1)
# Used HERE to make a clear explanation of expectations
it "permits up to #{character_max} characters in the body field" do
apology.body = string_at_char_max
expect(apology).to be_valid
apology.body = string_over_char_max
expect(apology).to_not be_valid
end
end
Now for the elephant in the room… well, more like, where did that giant IMAGES
array elephant go? Our Images
array grew up and became a class. Hooray! You go little Images
array, you go!
Refactoring from an Array into an ‘Image’ Model
What’s cool about this refactor is that the interface with the Apology
class barely changes. The clunky array is gone, but the Apology.assign_image
method is nearly identical. That makes it really easy to pluck the Image
responsibilities out and make them more robust.
# app/models/image.rb
class Image
def self.sample
[
"ryan-gosling01.jpg",
"ryan-gosling02.jpg",
"ryan-gosling03.jpg",
"ryan-gosling04.jpg",
"ryan-gosling05.jpg",
"ryan-gosling06.png",
"ryan-gosling07.jpg",
"ryan-gosling08.jpg",
"ryan-gosling09.jpg",
"ryan-gosling10.jpg"
].sample
end
end
Closer, but not there yet.
As I previously bemoaned, there are a couple of problems with that array of image names above: 1) maintenance and 2) scalability – because guess who wants to add 20 more pictures of Ryan Gosling to the app? This girl. And guess who wasn’t going to type in a bunch of new filenames? Me again. You know who’s really good at looking in a folder of images and just telling me the files that are in them? Ruby. Let’s let Ruby do its thing.
Ruby has a nifty Dir
library that let’s us do these kinds of things. The Dir.glob
method lets us search in a specific directory for specific filetypes. Perfect!
# app/models/image.rb
class Image
# Assign the file location and formats to constants so they 1) are
# easy to find for future edits and 2) to make the methods that use
# them more readable.
IMAGES_DIRECTORY = '/assets/images/'
ACCEPTABLE_FORMATS = %w(jpg jpeg png)
# WIP: an array of full filepaths is ALMOST what we need
def self.sample
# Gets an array of image filepaths from the '/assets/images/' directory.
# Ex: 'app/assets/images/ryan-gosling05.jpg'
Dir.glob("*#{IMAGES_DIRECTORY}*.{#{ACCEPTABLE_FORMATS.join(',')}}").sample
end
end
Since I may change my mind about which image file types are acceptable (and because I’m going to need to access them for testing), I’ve made them available as constants. The same goes for the location of the files. With that information in place, I can use Dir.glob
to provide me with an array of image filepaths. That’s almost the same array that we had before, but I didn’t have to type a single filename. :high_five:, Ruby, I love you. This is serious progress.
To convert the array of filepaths into an array of just the filenames, I first reached for regex
. I used the Ruby match
method to take everything after the IMAGES_DIRECTORY
text. That approach looked like this:
def self.image_file_names
# Using 'match' is convenient for regex, but regex is hard to read
array_of_filepaths.map { |path| path.match(/#{IMAGES_DIRECTORY}(.*)/)[1] }
end
Since I am a human, I find regex a bit cumbersome to read. Good thing Ruby has another nifty library to help with that. File
has a handy method called basename
that knows how to extract just the filename out of a filepath. Thanks, Ruby.
def self.image_file_names
# Much easier to read or to google for more info
array_of_filepaths.map { |path| File.basename(path) }
end
After: Image Model
The shiny new Image
class looks like this:
# app/models/image.rb
class Image
IMAGES_DIRECTORY = '/assets/images/'
ACCEPTABLE_FORMATS = %w(jpg jpeg png)
# Use the new array of filenames to grab a .sample
def self.sample
self.image_file_names.sample
end
private
# Map the array into just the image filenames
def self.image_file_names
self.filepaths.map { |path| File.basename(path) }
end
def self.filepaths
Dir.glob("*#{IMAGES_DIRECTORY}*.{#{ACCEPTABLE_FORMATS.join(',')}}")
end
end
With this fancy new scalable Image
structure in place, I added 20 more Ryan Gosling pics, for a total of 30 possible random images to be assigned to an apology, and that’s better for everyone… except maybe Ryan Gosling.
But why didn’t you…?
But why didn’t I make the Image
class a fully-instantiable, database-backed model? Good question. I considered it. I decided that it was overkill to have a whole table dedicated to a single filename
field when I have no plans to allow users to upload their own photos or for an admin person to need this feature. It adds complexity to the database. It adds complexity to the relationship between Apology
and Image
. And for the foreseeable future, it’s not necessary.
But why didn’t I make a @character_max = Apologies::CHARACTER_MAX
instance variable in the apologies_controller
for the apologies views and javascript to access instead of adding it as an attribute on the @apology
object? An instance variable in a controller is wild wild beast. It’s essentially global, which is awesome if you’re able to confirm that there won’t be collisions in other parts of the app. But I’m not app-omniscient (yet… right ;) ) and I’m not sure what technical debt it might bring. So right now, I like to try to release as few wild beasts as possible into the views.