Refactoring in Rails Using the Single Responsibility Principle

| Shey Sewani | Toronto

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.