Refactoring Rails into Service Objects

One of the things that I dislike about Rails is how some actions feel untestable. Take, for example, the act of grabbing the current user.

# /app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  helper_method :current_user

  private

  def current_user
    @current_user ||= User.find(session[:user_id]) if session[:user_id]
  end
end

This is bog-standard code that you'd see in a lot of Rails tutorials and people's codebases. What I don't like about it is that the current_user method is not easily testable. Heck, which test file do you put the tests in?

Another example is the login/logout functionality that exists in the same boilerplate way.

# /app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.where(:email => params[:email]).first

    if user and user.authenticate(params[:password])
      session[:user_id] = user.id
      redirect_to root_url
    else
      session[:user_id] = nil
      render :new
    end
  end

  def destroy
    session[:user_id] = nil
    flash[:success] = I18n.translate('logout.success')
    redirect_to root_url
  end
end

Fairly common eh? And yet, should the business logic of handling login and logout functionality be handled (and tested) in the controller? No, and this is a code smell.

At this point I like to create a service object that handles some common functionality found in all the above controllers, but with a nod to being easily testable and self-contained from a controller scope. Another added benefit is that the service object is typically devoid of any Rails framework calls so testing can happen quickly and without side effects because each dependency is injected into the object at runtime.

# /app/services/session_service.rb
class SessionService
  def initialize (session, user_finder = User, session_key = :user_id)
    @session = session
    @user_finder = user_finder
    @session_key = session_key
  end

  def login (email, password)
    logout
    return false if email.nil? or email == ''
    return false if password.nil? or password == ''

    user = @user_finder.where(:email => email).first

    if user and user.authenticate(password)
      @session[@session_key] = user.id
    end

    return logged_in?
  end

  def logged_in?
    not @session[@session_key].nil?
  end

  def user
    return nil if not logged_in?
    @user_finder.find(@session[@session_key])
  end

  def logout
    @session[@session_key] = nil
  end
end

That's a lot of code, but each method is deceptively simple. I'm using contructor injection to specify the session, the session key and the object that knows how to find user records. Sensible defaults are given so that callers don't need to specify everything (this is one thing that Ruby does well versus, say, Java). The session and session_key are used to look up the user ID in the session.

I keep all service objects in a directory called app/services so that (a) they are loaded automatically by Rails and (b) they are in a common place within the codebase. This is in contrast to putting code in lib, which I find to cause search nightmares. The corresponding tests for each service object are held in test/services.

To test the service object you will first need to tell Rake to use the test/services directory. Put the following code in a file in the lib/tasks directory and Rake will pick it up (give the file a .rake extension too).

# /lib/tasks/test_services_dir.rake
namespace :test do
  desc "Test services classes"
  Rake::TestTask.new(:services) do |t|
    t.libs << "test"
    t.pattern = 'test/services/**/*_test.rb'
  end
end

service_task = Rake::Task['test:services']
test_task = Rake::Task[:test]
test_task.enhance { service_task.invoke }

With that file in place, you can now do rake or rake test:services and your service objects will be tested.

So how does one test a service object? It's quite easy since the majority of the dependencies are injected. This allows you to use tools like mocha to mock out the method calls that you don't care about, freeing you from having to tie yourself into the Rails framework. Here is what the test file looks like.

# /test/services/session_service_test.rb
require 'test_helper'

class SessionServiceTest < ActiveSupport::TestCase
  setup do
    @user = users(:basic_user)  # password: "password"
    @session = {}
    @session_key = 'id'
    @user_finder = mock()
    @service = SessionService.new(@session, @user_finder, @session_key)
  end

  teardown do
    session = nil
    user_finder = nil
    @service = nil
  end

  test 'login returns false if email nil' do
    assert_equal false, @service.login(nil, 'password')
  end

  test 'login returns false if password nil' do
    assert_equal false, @service.login('test@example.com', nil)
  end

  test 'login returns false if email blank' do
    assert_equal false, @service.login('','password')
  end

  test 'login returns false if password blank' do
    assert_equal false, @service.login('test@example.com', '')
  end

  test 'login returns false if email is incorrect' do
    @user_finder.expects(:where).returns([])
    assert_equal false, @service.login('blah@example.com', 'password')
  end

  test 'login returns false if password is incorrect' do
    @user_finder.expects(:where).returns([@user])
    User.any_instance.expects(:authenticate).returns(false)
    assert_equal false, @service.login('test@example.com', 'wrong')
  end

  test 'login returns true if email and password are correct' do
    @user_finder.expects(:where).returns([@user])
    User.any_instance.expects(:authenticate).returns(true)
    assert_equal true, @service.login('test@example.com', 'password')
  end

  test 'logged_in returns false if session empty' do
    assert_nil @session[@session_key]
    assert_equal false, @service.logged_in?
  end

  test 'logged_in returns true if session contains id' do
    @session[@session_key] = 1
    assert_equal true, @service.logged_in?
  end

  test 'user returns guest user when no session exists' do
    @session[@session_key] = nil
    assert_equal GuestUser, @service.user.class
  end

  test 'user returns valid user when session exists' do
    @session[@session_key] = 1
    @user_finder.expects(:find).returns(@user)
    assert_equal @user, @service.user
  end

  test 'logout resets session' do
    @session[@session_key] = 1
    @service.logout
    assert_nil @session[@session_key]
  end

  test 'login_as sets the session' do
    assert_nil @session[@session_key]
    @service.login_as(1)
    assert_equal 1, @session[@session_key]
  end
end

Putting this service object to use allows us to simplify the current_user, login and logout functionality displayed previously. It also allows me to be more confident that each code branch will be tested properly.

# /app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :exception

  helper_method :current_user

  private

  def current_user
    service = SessionService.new(session)
    @current_user ||= service.user
  end
end
# /app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  before_filter :load_session_service

  def new
  end

  def create
    if @session_service.login(params[:email], params[:password])
      redirect_to dashboard_url
    else
      render :new
    end
  end

  def destroy
    @session_service.logout
    flash[:success] = I18n.translate('logout.success')
    redirect_to root_url
  end

  private

  def load_session_service
    @session_service = SessionService.new(session)
  end
end

One last feature of using a service object such as SessionService is that the handling of the session object for holding user IDs (denoting whether the user is logged in or out) is kept in one place. If you should ever change session[:user_id] to session[:admin_id] without this service object, you'd need to search through your codebase to find all instances, whereas with a service object it is all held in one place.