Let it REST

Harold Giménez

You are building a boring blog application. A blog has many comments. Your client gives you this user story:

So that I can report inappropriate comments, as a user, I can mark a comment as spam.

So you add a spam boolean attribute to the Comment model, and use the same PUT to comments to handle the update:

class CommentsController < ApplicationController
  def update
    @comment = Comment.find(params[:id])
    if @comment.update_attributes(params[:comments])
      flash[:notice] = "Comment updated successfully"
      # redirect_to ...
    else
      flash[:error] = "Something broke"
      # redirect_to ...
    end
  end
end

spaaaaam

By sending the right params down from the view, this can still work. But a day passes by and people start abusing the darn spam button. This is the internet after all. So your client adds a couple of stories:

So that I’m aware I screwed up, as a user, I receive an email notification when my comments get spammed. So that I can moderate spammed comments, as an admin, I receive an email notification when comments are spammed.

There’s now more going on when we spam a comment. A first shot at implementing these stories could make use of ActiveRecord’s dirty attributes to figure out if the spam boolean changed from false to true, in which case we deliver the two notifications:

class Comment < ActiveRecord::Base
  before_save :send_spam_notifications, :if => marking_as_spam?
  def send_spam_notifications
    Mailer.comment_owner_spam_notification(self).deliver
    Mailer.admin_spam_notification(self).deliver
  end

  def marking_as_spam?
    self.spam_changed? && self.spam?
  end
end

Nice and clean, right? Wrong.I think there’s a couple of problems with this. First of all, we are cluttering our comment class with conditional callbacks. Sometimes you have no choice, but I’d rather avoid them when possible. Secondly, we’re using the same HTTP endpoint and controller action for two very different things. One is that of editing my comment, and the other is for marking a comment as spam — which is arguably an SRP violation (PDF link).

We have a couple of options for cleaning up. We could create a #update_spam_status action on the comments controller, but this deviates from our otherwise RESTful API. Instead, let’s break it out to it’s own resource called Spams, and instead of a PUT to comment, let’s POST to spam:

class SpamsController < ApplicationController
  def create
    @comment = Comment.find(params[:id])
    if @comment.mark_as_spam
      flash[:notice] = "Comment marked as spam"
      # redirect_to ...
    else
      # ...
    end
  end
end

class Comment < ActiveRecord::Base
  def mark_as_spam
    self.spam = true
    send_spam_notifications if valid?
    save
  end
end

Our view can make use of a button_to that POSTs to the spams resource, instead of relying on updating via the comments resource. Note how a RESTful resource does not need to match 1-to-1 with a model, and this creates a much cleaner interface overall — with a few advantages:

  • You clean up your model by creating a #mark_as_spam method that is clear and to the point, untangling some of the callback soup that can start to build up on models.
  • You are creating a level of abstraction that allows you to code to an interface, making it trivial to change #mark_as_spam‘s implementation to anything else, like an Akismet call, or a more complex Spam class.
  • Splitting it out allows us to properly scope the CommentsController#update’s finder to the current user (@comment = current_user.comments.find(params[:id])), allowing any user to mark comments as spam, and only commenters to update their own comments.
  • You can easily specify a more meaningful flash message for each case.

The question then arises, when should we split out to a new resource? As always, it depends. For the most part it’s whenever it will help clean up your models and controllers. I find that if the update triggers any logic at all, it’s worth breaking out to its own resource and corresponding model method that encapsulates that logic.