Refactoring When the Logic Starts to Grow

| 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’d written. I was really happy with the implementation—until the requirements changed and the code started to look gross.

I still wanted to keep the code easy to read. It was a fun refactor, and I figured it was worth sharing.

With the new requirements, we had to send different messages depending on how close a book was to its due date—sometimes urgent, sometimes just a friendly reminder, and a special case for books due today.

The presenter already felt like it was at its limit. Adding more conditions made it even harder to read, and it felt like the right time to clean things up.

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

A new class gives the behavior a name, a clear boundary, and a place to grow. All the logic for generating return messages now lives in the ExpiryMessages class.

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

The ExpiryMessages class isn’t pretty — it’s full of conditionals and special cases. But that mess is now contained. The refactor led to a presenter that’s easier to scan and understand, and BookDueSoonPresenter is once again a lightweight object that simply exposes data, the way presenters should in templates.

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

Giving the expiration message its own class made the presenter easier to read, the message logic easier to test, and — maybe most importantly — gave that group of conditions a name and a place to live. The same treatment can be applied to the subject method, but going deeper felt like overkill for a blog post.