Refactoring in Rails Using the Single Responsibility Principle
This post is for Rails developers learning object-oriented design.
A few weeks ago, someone I was mentoring was impressed by a presenter I had written. It separated formatting logic from design, and it looked great.
Then the requirements changed. We needed to send different messages depending on how close a book was to its due date — urgent messages, friendly ones, even a special case for books due today. The presenter started to grow, the logic got messier, and it became clear we needed a better way to organize things.
The Original Presenter Class
class BookDueSoonPresenter
attr_reader :user, :book
def initialize(user:, book:)
@user = user
@book = book
end
def user_name
user.name
end
def subject
"Reminder: Book Due Soon"
end
def book_title
book.title
end
def due_date
book.due_date.strftime("%B %d, %Y")
end
def days_left
(book.due_date.to_date - Date.today).to_i
end
def return_message
messages.fetch(message_key) % { days: pluralized_days_left }
end
def message_key
days_left <= 2 ? :urgent : :friendly
end
def messages
{
urgent: "Please return it as soon as possible -- it's due in just %{days}!",
friendly: "You still have %{days} left."
}
end
def pluralized_days_left
"#{days_left} #{'day'.pluralize(days_left)}"
end
end
Extraction
That’s when I shared a technique I’ve used before: pulling logic into its own class. The idea is simple — a class should do one thing. When it starts behaving like more than one, or when the rules keep growing in different directions, that’s the time to split it up. In this case, it’s not just to make the presenter smaller, but to also give this growing behavior a name and a boundary.
class ExpiryMessages
def initialize(book, time: Date.today)
@book = book
@time = time
end
def return_message
case days_left
when ..-1
overdue_message
when 0
due_today_message
when 1..2
urgent_message
else
friendly_message
end
end
def days_left
(@book.due_date.to_date - @time.to_date).to_i
end
def pluralized_days_left
"#{days_left.abs} #{'day'.pluralize(days_left.abs)}"
end
def overdue_message
"This book is overdue by #{pluralized_days_left}!"
end
def due_today_message
"This book is due today!"
end
def urgent_message
"Please return it as soon as possible — it's due in just #{pluralized_days_left}!"
end
def friendly_message
"You still have #{pluralized_days_left} left."
end
end
Smaller Presenter
class BookDueSoonPresenter
attr_reader :user, :book
def initialize(user:, book:)
@user = user
@book = book
end
def user_name
user.name
end
def subject
"Reminder: Book Due Soon"
end
def book_title
book.title
end
def due_date
book.due_date.strftime("%B %d, %Y")
end
def return_message
ExpiryMessages.new(book).return_message
end
end
What started as a clean presenter got cluttered as the logic grew — and that’s normal.
By giving the expiration message its own class, we made the code easier to read and test, and gave the logic a clear name and room to grow without making changes to the rest of the app.