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 endAnd 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:
- Create a view helper that grabs the user's comments from the DB
- Add a new field to the User AR model to hold the comments
- Use Plain Old Ruby Object (PORO) models on top of AR models and use service objects
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 endI 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
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 endLook 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 endYou 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.