Hamed Asghari

home

Skinny Rails Controllers

26 Jan 2014

Many times when we try to keep our controllers skinny, we end up moving our business logic to the model. In general, this is good practice but there are cases where models are not a good fit to our needs either.

One such case is when we need to trigger mailers upon certain user actions. For example:

 1 class PostsController < ApplicationController
 2   def create
 3     @post = Post.new permitted_params
 4 
 5     respond_to do |format|
 6       if @post.save
 7         PostMailer.notify(@post).deliver
 8         format.html { redirect_to @post, notice: 'Successfully created Post' }
 9       else
10         format.html { render action: 'new' }
11       end
12     end
13   end
14 end

One approach I've personally been guilty of in the past is tying mailer invocations to the model lifecycle:

1 class Post < ActiveRecord::Base
2   after_create do
3     PostMailer.notify(self).deliver
4   end
5 end

Unfortunately this approach has several pitfalls, the main one in my use case being that multiple sources in our application can trigger the model lifecylce callbacks and it becomes a maintenance headache trying to prevent unwanted execution of these actions that really should only be triggered when instigated by a user request.

Fortunately for us though, Rails is natively equipped with a great notification system, namely ActiveSupport::Notifications. By taking advantage of this notification system and controller after_action callbacks, we will create a mailer module using the observer pattern.

 1 module MailerCallbacks
 2   module ControllerExtensions
 3     def self.included(base)
 4       base.after_action do |controller|
 5         ActiveSupport::Notifications.instrument(
 6           "mailer_callbacks.#{controller_path}##{action_name}", controller: controller
 7         )
 8       end
 9     end
10   end
11 
12   module Listener
13     def listen_to(action, &block)
14       ActiveSupport::Notifications.subscribe("mailer_callbacks.#{action}") do |*args|
15         event = ActiveSupport::Notifications::Event.new(*args)
16         controller = event.payload[:controller]
17         controller.instance_eval(&block)
18       end
19     end
20   end
21 end

Now all we need is to create an initializer and register our mailer callbacks:

 1 # config/initializers/mailer_callbacks.rb
 2 ActiveSupport.on_load(:action_controller) do
 3   include MailerCallbacks::ControllerExtensions
 4 end
 5 
 6 class MailerListeners
 7   extend MailerCallbacks::Listener
 8 
 9   # register as many listeners as you would like here
10 
11   listen_to 'posts#create' do
12     PostMailer.notify(@post).deliver if @post.persisted?
13   end
14 end

From a code maintenance point of view, most if not all of our mailer invocations could now be found in a single source file.

This is a general purpose pattern that I've adopted in different contexts that allows me to keep my controllers skinny and isolate independent concerns.


comments powered by Disqus