Ruby Web Architecture for Large Projects (Services)

Posted by David Estes on Jul 23, 2013

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

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.

Too much Abstraction

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.