Skip to content

Instantly share code, notes, and snippets.

@zacstewart
Created December 4, 2012 23:26
Show Gist options
  • Save zacstewart/4210223 to your computer and use it in GitHub Desktop.
Save zacstewart/4210223 to your computer and use it in GitHub Desktop.

Breaking table–model–controller symmetry

A while ago I hit a lull in my skill advancement as a Rails developer. I had long since learned to think of my resources as resources. I did my best to limit my controller actions the basic CRUD methods. I considered the "fat model, skinny controller" mantra to be sacrosanct. But I was still often finding myself going way out of my way to implement otherwise mundane features, for example sending mail after saving an object or adding a routine accept/reject to something.

I realized that one simple assumption was holding me back: that there should be a one-to-one ratio of database tables, models and controllers. Discovering the power of additional controllers, decorators, service objects, form objects and other supplements to the standard models, view and controllers of Rails has been a boon to my productivity and I'd like to share a few patterns I've found useful.

Extracting auxiliary objects

Let's say you want to add a notification system to your application. You want users to have a notification area listing all their unread notifications and you want them to receive an email every time they get a new one. At first, it might be easy enough just to create the notification and then send mail from the controller, but as the events which cause a notification to be created grow, you find yourself writing that same few lines again and again. It's time to abstract that away.

One way to do this would be to use an after_create hook on your Notification model to send mail. This would jive with the "fat model, skinny controller" principal, it even DRYs up your code, but that doesn't mean it's right. Using ActiveRecord callbacks to manipulate external objects can obfuscate intent and make testing difficult. If you abuse them, you can turn your application into confusing callback spaghetti.

A clearer way to abstract this feature is to create a supporting object, i.e. a Notifier:

<script src="https://gist.github.com/4210223.js?file=1-notifier.rb"></script>

Here's how you could use it from a controller:

<script src="https://gist.github.com/4210223.js?file=2-friendships_controller.rb"></script>

An excellent post on this topic that I've drawn on heavily is 7 Patterns to Refactor Fat ActiveRecord Models by Bryan Helmkamp.

As a side note, if you're curious as to where to organize these bits, you can't really hurt yourself by creating another directory under app/. I often have directories like decorators and services there.

Exposing additional resources

Sometimes you want to perform a simple operation on a resource–for example, administrator approval of a blog comment. In terms of your model, all you want to do is flip an "approved" boolean on the comment. It can be temping to just add another action to your controller, route a POST request to it and call it a day. The authors of RESTful Web Services refer to this as overloaded POST. You're essential trying to augment HTTP with a new method. In some cases, this may be appropriate but more often it's indicative of poor resource design.

Another solution that a more REST-minded developer might come to is to utilize the HTTP PUT (or more correctly, PATCH) method and the update controller action. Rails makes this pretty easy by letting you include form parameters and specify the method of a hyperlink using link_to. This too is less than ideal, though. For one, you're going to end up with a lot of conditional logic within the update method. If you're using something like the state_machine gem you can end up with some funky looking code like this:

<script src="https://gist.github.com/4210223.js?file=3-comments_controller.rb"></script>

While seeming more "RESTful" at first, this may actually be worse than overloaded POST. You could call this overloaded PUT and while POST is allowed to be kind of a wildcard, PUT is expected to be idempotent: whether you do it once or a million times, the outcome should be the same. Approving a comment a million times is bordering on nonsensical.

Willem van Bergen studied this pain point in his post RESTful thinking considered harmful and concluded that the best solution for these kind of transactional update operations is overloaded POST. He made the excellent observations that not all updates are equal, REST does not equal CRUD, and updating a resource does not always correspond to the UPDATE operation in a database–all concepts that Rails literature tends to conflate. However, I feel it's too early to throw in the towel and declare REST inadequate your problem space.

A more appropriate solution is just to expose another resource, another noun. You don't have to approve a comment, you can create an approval for it. An approval doesn't have to map directly to a database table of approvals to be a valid resource, either.

You can add a couple subordinate resources, "acceptance" and "rejection," to your comments resources in routes.rb:

<script src="https://gist.github.com/4210223.js?file=4-routes.rb"></script>

And add two controllers like this one:

<script src="https://gist.github.com/4210223.js?file=5-acceptances_controller.rb"></script>

This effectively dismisses all the conditional logic previously handled by update, or a least relegates it to a matter of routing.

Objects for complex forms

Occasionally, you need to create and persist more than one ActiveRecord model at once. For example creating a User and a new Blog for them upon signing up. The built-in solution for this is accepts_nested_attributes_for. This solution works, but how it does has always seemed incredibly opaque to me. Furthermore, it isn't very flexible and becomes more confusing per each level of nested resources.

Another tricky situation is when you want to create ActiveRecords in a way that diverges from the typical CRUD actions. For example if you have a blog application similar to Tumblr that lets you reblog a blog post. A reblog is essentially a duplicate of the original, potentially with some modification or addition to its attributes, for example adding a citation of original author and keeping a pointer back to the original. You could use the patterns explored in the previous section to expose a reblog resource for each blog, but in this case you'd probably end up with a pretty complicated ReblogsController.

A simpler solution that can address both of these situations is to roll your own form object. Basically, all you need is something that quacks like an ActiveRecord model and can turn the parameters you provide it into the models that you need. To achieve that, you just need to mixin a few parts of ActiveModel. I also like to use a gem called Virtus to provide ActiveRecord-like attributes so that you can easily instantiate an object using the params attribute.

<script src="https://gist.github.com/4210223.js?file=6-reblog.rb"></script>

Now you can create a simple ReblogsController to use it:

<script src="https://gist.github.com/4210223.js?file=7-reposts_controller.rb"></script>

As you can see, there are occasionally situations that can be addressed by leaning on some classical object-oriented patterns and RESTful practices beyond of Rails' core design. Remember: your database schema is not your application. That said, you don't want to use these techniques like gravy and make your app incomprehensible and err on the side of being too non-standard.

Since applying these techniques I've noticed a marked decrease in those situations where I could build something that got the job done but didn't feel right. I spend much less time deliberating over minutia and more time thinking about the larger goal.

class Notifier
def initialize(notification)
@notification = notification
end
def self.notify(params)
new(Notification.new(params)).save
end
def save
@notification.save && deliver_email
end
private
def deliver_email
NotificationMailer.notification(@notification).deliver
end
end
class FriendshipController < ApplicationController
def create
@friendship = Friendship.new(params[:friendship])
if @friendship.save
Notifier.notify(notifiable: @friendship)
redirect_to @friendship, notice: 'Your friend request has been sent'
else
render 'new'
end
end
end
def update
@comment = Comment.find(params[:id])
case params[:comment][:state]
when 'accepted'
if @comment.accept
flash[:success] = t('comments.accepted_successfully')
else
flash[:error] = t('comments.accept_failed')
end
when 'rejected'
if @comment.reject
flash[:success] = t('comments.rejected_successfully')
else
flash[:error] = t('comments.reject_failed')
end
end
redirect_to @comment.post
end
Contrived::Application.routes.draw do
resources :posts do
resources :comments do
resource :acceptance, only: :create
resource :rejection, only: :create
end
end
end
class AcceptancesController < ApplicationController
def create
@comment = Comment.find(params[:comment_id])
if @comment.accept
flash[:success] = t('comments.accepted_successfully')
else
flash[:error] = t('comments.accept_failed')
end
redirect_to @comment.post
end
end
class Reblog
extend ActiveModel::Naming
extend CarrierWave::Mount
include ActiveModel::Conversion
include ActiveModel::Validations
include Virtus
attribute :user_id, Integer
attribute :post_id, Integer
validates :user_id, :post_id, presence: true
def save
if valid?
persist!
true
else
false
end
end
def persisted?
false
end
private
def persist!
@original = Post.find(post_id)
@repost = Post.new(
user_id: user_id,
body: reblogged_body(@original),
original_id: @original.id)
@repost.save
end
def reblogged_body(original)
<<-EOS
#{original.body}
via #{original.user.name}
EOS
end
end
class RepostsController < ApplicationController
def create
@post = Post.find(params[:reblog][:post_id])
@reblog = Reblog.new(params[:reblog])
if @reblog.save
flash[:success] = t('reblogs.rebloged_successfully')
else
flash[:error] = t('reblogs.reblog_failed')
end
redirect_to posts_path
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment