Wednesday, September 7, 2011

Get out of my Controller! And from Active Record, too!

I wrote about Running Rails Rspec Tests Without Rails a couple of months ago. The examples I used were very high level and focused on stubbing out Rails in my tests in order to achieve rapid feedback.

A couple of months have passed and the topic is getting more and more buzz thanks to Corey Haines and Robert "Uncle Bob" Martin.

I've been getting many questions on how I abstract away from Rails' Active Record, how do I use service objects to lighten up my controllers. I'll try to describe all that in this blog post.

Imagine an application where you have topics that people can comment on. The Active Record models are something like this:
class User < ActiveRecord::Base
  # These fields are defined dynamically by ActiveRecord
  attr_accessor :id, :full_name
end

class Discussion < ActiveRecord::Base
  # These fields are defined dynamically by ActiveRecord
  attr_accessor :id, :title, :body, :comments
end

class Comment < ActiveRecord::Base
  # These fields are defined dynamically by ActiveRecord
  attr_accessor :id, :text, :entered_by
end
And here is their relationships:

This is pretty simple: Discussion has many comments and a comment was entered by a user. Fantastic!
But what do you do when your customer comes to you and asks you to get not only the comments for a given discussion but she would like to see each user with their comments made on the specific discussion.

Here is the page layout:



The Active Record models will perfectly match the view hierarchy on the left. But you are looking at the same data from a different angle on the right hand side.
How are you going to get that data into the view?

Here are some of your options:
  1. Create a view helper that grabs the user's comments from the DB
  2. Add a new field to the User AR model to hold the comments
  3. Use Plain Old Ruby Object (PORO) models on top of AR models and use service objects
Number one is beyond bad. You are actually iterating through the users and hitting the database for every single user to get their comments. BAD! Never do that! It's a very expensive operation: connection is opened, query is executed, AR models are built up from the result set. You already have the data in memory. Use it!

Number two is better but I don't like that either. By adding a field to the User AR model you can do all the data processing in the controller and present that data to the view. This way the view iterates over the users and for each user it iterates over its comments. There is no lookup from the view but you are polluting the AR model with a field that is specific to one particular view. The User AR model is a core object in your application, you want to keep it very clean. Other developers should not be puzzled by an attr_accessor called :comments.

Here is what I'd do: create small model objects that wrap the AR models. Use service objects to populate these POROs and prepare them exactly as the view needs it. Then the view is very simple: it iterates over these model objects and uses their properties.
I call these PORO objects DTOs or Data Transfer Objects. They serve custom data from the model to the view.

Here is how a UserDTO looks:
module DTO
  class User
    attr_reader :source_object, :id, :full_name
    attr_accessor :comments
    def initialize(source_object)
      @source_object = source_object
      @id = source_object.id
      @full_name = source_object.full_name
    end
  end
end
I keep a reference to the original AR model through the @source_object variable. Whatever field I can populate from the source object I do that in the object's initializer. But in our case there is an extra field that does not exist in the source model: comments. This field is declared but not yet populated. The service object will take care of that.

The controller's index action has to do three things:
  • Get the currently viewed discussion from the database
  • Retrieve all the users
  • Find the users' comments under the current discussion
You could place all the code into the controller's action, but you'll have a bloated controller thats very hard to test and the logic will be impossible to reuse.
I use very granular service objects from the controller.
# Services used in the app
module Service
  class FindsDiscussion
    def self.for(id)
      # This is very high level
      ::DTO::Discussion.new(Discussion.find(id))
    end
  end

  class FindsUsers
    def self.all
      User.all.map { |user| ::DTO::User.new(user) }
    end
  end

  class SetsComments
    def self.on_users(users, comments)
      # There is no trip to the DB!
      users.each do |user|
        user.comments = comments.select do |comment|
          user.source_object.id == comment.source_object.entered_by
        end
      end
    end
  end
end
Look at how small they are! The first and second service looks up data in the database, but the third one is using an in memory lookup. This is how I am saving the trip to the data store.
SRP is strictly followed, these little logic classes are super easy to test and using them from the controller is straightforward:
class DiscussionsController < ApplicationController
  attr_reader :users, :discussion

  def index
    @users = Service::FindsUsers.all
    @discussion = Service::FindsDiscussion.for(params[:id])
    Service::SetsComments.on_users(@users, @discussion.comments)
  end
end
You are creating many more small classes, but that's OK. They are easy to understand, easy to test and you can use them like little LEGO blocks to construct the logic your controller needs.

You can find the examples I used in the blog post in this Gist.