Rails Tip: Use Polymorphism to Extend your Controllers at Runtime

Metaprogramming in Ruby comes in for quite a bit of stick at times, the accusation being that code which modifies itself at runtime can be hard to understand. As Martin Fowler recently described, there’s a sweet spot where you use just enough to get some of the incredible benefits that Ruby offers, without leaving behind a minefield for future developers who’ll have to maintain your code.

One of my favourite techniques uses the Object#extend method, which allows you to mix in the methods from a module to a specific instance of a class at run-time. In my quest to eliminate as much conditional logic as possible from my code, I’ve seen a common pattern emerge a few times. Here’s an example from a refactoring session I paired on with my colleague craig.

We start with a Rails controller which handles user authentication. Over the passing iterations, it has grown to support not only bog-standard logins from users of the main web application, but a form that’s displayed on a 3rd-party partner site, as well as during the installation of a rich-client GUI app. All these clients need slightly different behaviour – different templates or layout to be rendered, and different destination pages to redirect to when the login has succeded.

Sadly the hackers passing through this controller have not been great boy scouts, and the code has started to get pretty unpleasant. This code is simplified for clarity:

{{lang:ruby}}
class SessionsController < ApplicationController

  def new
    if params[:installer]
      render :layout => 'installer_signup', :action => 'installer_signup')
    else
      render :layout => 'modal'
    end
  end

  def create
    if params[:username].blank?
      flash[:error] = "Please enter a username"
      return render_new_action
    end

    unless user = User.authenticate(params[:username], params[:password])
      flash[:error] = "Sorry, that username was not recognised"
      return render_new_action
    end

    set_logged_in_user(user)

    if params[:installer]
      @username = user.username
      return render(:template => 'installer_done', :layout => 'installer_signup' )
    elsif params[:third_party]
      return render(:template => "third_party/#{params[:third_party]}")
    else
      return redirect_to(success_url)
    end
  end
end

Notice how the conditional logic has a similar structure in both actions. Our refactoring starts by introducing a before_filter which works out the necessary extension:

{{lang:ruby}}
class SessionsController < ApplicationController

  before_filter :extend_for_client

  ....

  private

  def extend_for_client
    self.extend(client_exension_module) if client_exension_module
  end

  def client_extension_module
    return InstallerClient if params[:installer]
    return ThirdPartyClient if params[:third_party]
  end

  module InstallerClient
  end

  module ThirdPartyClient
  end
end

Notice that we don’t bother extending the controller for the ‘else’ case of the conditional statements – we’ll leave that behaviour in the base controller, only overriding it where necessary.

Now let’s extract the client-specific code out of the create action into a method that we’ll override in the modules:

{{lang:ruby}}
class SessionsController < ApplicationController

  ...

  def create
    if params[:username].blank?
      flash[:error] = "Please enter a username"
      return render_new_action 
    end

    unless user = User.authenticate(params[:username], params[:password])
      flash[:error] = "Sorry, that username was not recognised"
      return render_new_action 
    end

    set_logged_in_user(user)

    handle_successful_login
  end

  private 

  def handle_successful_login
    if params[:installer]
      @username = user.username
      return render(:template => 'installer_done', :layout => 'installer_signup' )
    elsif params[:third_party]
      return render(:template => "third_party/#{params[:third_party]}")
    else
      return redirect_to(success_url)
    end
  end

  ...

Finally, we can the client-specific code into the appropriate module, leaving the default behaviour in the controller:

{{lang:ruby}}
class SessionsController < ApplicationController

  before_filter :extend_for_client

  def new
    render :layout => 'modal'
  end

  def create
    if params[:username].blank?
      flash[:error] = "Please enter a username"
      return render_new_action 
    end

    unless user = User.authenticate(params[:username], params[:password])
      flash[:error] = "Sorry, that username was not recognised"
      return render_new_action 
    end

    set_logged_in_user(user)

    handle_successful_login
  end

  private 

  def handle_successful_login
    return redirect_to(success_url)
  end

  private

  def extend_for_client
    self.extend(client_exension_module) if client_exension_module
  end

  def client_extension_module
    return InstallerClient if params[:installer]
    return ThirdPartyClient if params[:third_party]
  end

  module InstallerClient
    def new
      render :layout => 'installer_signup', :action => 'installer_signup')
    end

    private 

    def handle_successful_login
      @username = user.username
      return render(:template => 'installer_done', :layout => 'installer_signup' )
    end
  end

  module ThirdPartyClient
    def handle_successful_login
      return render(:template => "third_party/#{params[:third_party]}")
    end
  end
end

Polymorphism is one of the power-features of an object-oriented language, and Ruby’s ability to flex this muscle at run-time opens up some really elegant options.

Published by Matt

I write software, and love learning how to do it even better.

Join the Conversation

1 Comment

  1. Very interesting approach to this problem. I’m dealing with a similar situation where I want to send different forms of xml to different clients and I was thinking about:

    @things.to_xml(:api => '123')

    OR

    @things = blah
    if x
      render 'api1'
    elsif y
      render 'api2'
    else
      render 'api3'
    end

    but your approach feels cleaner, though for simplicity I might just do something like

    before_filter :determine_api
    
    def determine_api
      @template = 'xyz'
    end
    

Leave a comment

Your email address will not be published. Required fields are marked *