A lot of discussion has been going around in recent months with regards to the best approach to dealing with larger project structures in Rails. One approach to this has been discussed as DCI. DCI is an interesting concept but has some serious issues. One of which is over-complexity. You will suddenly find yourself jumping around files way more than anyone should ever need to. Not to mention it tries to take advantage of Ruby's injection methods such as extend
. These methods are not very performant and actually can kill ruby's code cache.
Matt Swanson wrote a blog post on his blog "Half-Baked Thoughts on Ruby Web Architecture" that discusses some ideas he came across while working on his new project Stringer. In his blog he discusses the idea of using "Repositories" to store similar query logic. While these are growingly popular concepts to reduce code clutter in your "fat models" as well as better organize your code, they all can create drawbacks. One of which can be needing to jump around a lot of files just to figure out what actually is going on. Another can be seen in controller callbacks, which can cause a jumpy, non-linear code flow appearence. This makes the code more difficult to trace through.
Services are a concept that has become widely popular in the Grails Community and Architecture. They are a very powerful tool that I think merit consideration for use in your Rails stack. Let's not get into the Grails vs. Rails argument as that can be a long one.
A Service is a class responsible for performing a "service" with your objects (This is aptly named isn't it?). It is a great place to store more complex logic rather than creating a fat model, or even a fat controller. Below I will show an example of a General Ledger application and how a service might be used. First let's take a look at the service:
class Gl::TellerService
def initialize(user, company)
@user, @company = user, company
end
def transfer_funds(options)
source_account = @company.gl_accounts.where(:id => options[:source_account_id]).first
destination_account = @company.gl_accounts.where(:id => options[:account_id]).first
return ServiceResponse.error("Source Account not found!") if source_account.blank?
return ServiceResponse.error("Destination Account not found!") if destination_account.blank?
return ServiceResponse.error("Source and Destination Account cannot be the same!") if destination_account.id == source_account.id
return ServiceResponse.error("Amount must be specified!") if options[:amount].blank? || options[:amount].to_f != 0
period = period_for_transaction(source_account.company, options[:transaction_date] || Time.now)
if period.blank?
return ServiceResponse.error("GL Period not found for the specified transaction date.")
end
options.merge! period
options.merge!({:created_by_id => @user.id, :created_by => @user.full_name})
debit = true
if(options[:amount].to_f < 0)
debit = false
options[:amount] = -options[:amount].to_f
end
source_transaction = create_transaction(source_account, options, destination_account, !debit)
destination_transaction = create_transaction(destination_account, options, source_account, debit)
begin
Gl::Account.transaction do
source_transaction.save!
destination_transaction.save!
end
rescue ActiveRecord::RecordInvalid
response = ServiceResponse.error("Invalid Transaction Records")
response.add_errors_from_record(source_transaction)
response.add_errors_from_record(destination_transaction)
return response
end
return ServiceResponse.success([source_transaction, destination_transaction])
end
...
end
Above we have created a service responsible for performing tasks on a Ledger account. In this case we named it the Gl::TellerService
. It is important to pick a good naming convention and stick with it. Some people don't like using the class type in the name, but in this case it makes it abundantly clear what this object's responsibility is and as such may reduce the need for a programmer to even need to drill down to this depth. The first thing I want to talk about is the constructor of this service. Really, you can do this anyway you like but in this case, what we are doing is setting up a teller for a specific user and company. In the real world, most tellers typically know who their performing a service for. While this service can perform several other methods in the real world, we are going to stick to one method transfer_funds
. Now lets take a look at the controller:
class Gl::TransactionsController < Gl::ApplicationController
respond_to :html, :xml, :json, :js
before_filter :load_account, :except => [:create]
def index
@transactions = @account.transactions.order("transaction_date DESC, created_at DESC").limit(200)
respond_with @transactions
end
def new
end
def create
#Initialize Our Service
teller = Gl::TellerService.new(@current_user,@current_company)
#Request Our Teller to Transfer Funds
response = teller.transfer_funds({:source_account_id => params[:source_account_id],:account_id => params[:account_id], :memo => params[:memo],:transaction_date => params[:transaction_date], :amount => params[:amount]})
#Track the result
@transactions = response.data
if !response.successful?
flash.now[:error] = response.errors.join(", ")
end
respond_with @transactions do |format|
format.js {
if response.successful?
render :action => "create"
else
render :action => "new"
end
}
end
end
private
def load_account
@account = @current_company.gl_accounts.where(:id => params[:account_id]).first
if @account.blank?
flash[:error] = "Account not found!"
redirect_to gl_accounts_url and return false
end
end
end
In the create
method we initialize a service to create a transaction. Then we ask the service to transfer funds. It seems pretty clear as to what is going on here within the scope of my controller. Finally, we check the ServiceResponse
object for a success state and map the relevant data to our responder. This controller method could further be reduced by implementing a custom Responder
object if so desired, but the example is left here for clarity.
That is really all there is to using services. Typically I just create an app/services
folder to store my service classes. What is nice about this concept is it just adds 1 layer to your concept of MVC and fits a wide margin of logic.
Note on ServiceResponse:
The ServiceResponse Class is a custom object I created with custom static initializers that populate a more strongly typed property structure. It is entirely up to you how you send back the result of your service. Sometimes a simple HashMap
will do.
Above you saw a real world example of how a service might be used in Rails. It is also important to note, you can grossly overdo it. Not all projects need to use services, and not all controller actions need services. Services should only really be used when your action or model becomes too complex to easily scan through and understand. It is simply a tool, to help better organize larger sections of code. In the above example you may have noticed the def index
action did NOT use a Service. It simply performed an active_record query and returned the result. No black magic going on there.
There are several concepts of how to organize your code going around. But it is important to note with ALL of these concepts that you can take it way too far. Only abstract when you need to. Don't overplan for it and put every chunk of logic you have in a Service or even a Concern for that matter.