Resources to help guide architectural decisions in Rails apps

My previous post outlined a trivial example where I refactored some basic, boring business logic into a service object, or transaction script (depending on who you ask).

It set off a lot of debate on Twitter and Hacker news about the topic. I was quite surprised. At the very least it means that many others have felt the pain of having rails dictate your applications core architecture.

Signs that rails has dictated your core architecture

1. Upgrading major versions of rails is an enormous, painful process.

When Rails 4 drops, you should be able to upgrade your app on day one. If you cannot, or it will require a large amount of refactoring to get there, that is a smell that your app is too reliant on the architecture given to you.

Plenty of people remember the painful process it was to upgrade a Rails 2.x app to Rails 3. Hell, there was even a gem released to help guide the process! That fact alone should be sending red flags up in your head. Rails is a fantastic framework for RESTful, CRUDdy apps, but just like anything else, things change. Internal APIs change. The names of things you might have relied upon change.

2. All the use cases of your application are tied up in ActiveRecord models.

It is a good idea to start breaking up your application’s business logic into service objects. Service objects are essentially plain ol’ ruby classes that can encapsulate a process happening in your system.

Imagine a complex use case:

In our CRM application, when we convert a lead to a deal, the lead will become a contact, we will send a lead converted email, and we will carry over the lead source and set that as the deal’s source. The email will only be sent, however, if the lead source has a dollar value set.

Where should that logic exist? Should it exist in a Model? Well, probably not.

There are many reasons that this interaction does not belong in a model:

  • This interaction involves many models. If you have one ActiveRecord model instantiating and calling methods on another ActiveRecord model, then the first model probably knows too much!
  • It is unclear which model this interaction goes into. Should it be the Deal? Lead? Arguments could be made for both.
  • We need to perform an external action (sending an email) based upon the state of an attribute in a 3rd model, the lead source.

Ok fine then, that logic should go in the controller! Maybe that makes sense!

Let’s implement the above use case.

class LeadsController < ActionController
  def convert
    @lead = Lead.find params[:id]
    @lead_source = @lead.lead_source
    @deal = Deal.create(name: @lead.name, source: @lead_source, lead: @lead)
    @lead.touch(:converted_at)
    @lead.update_attribute(deal_id: @deal.id)
    UserMailer.lead_converted_email(@lead, @deal).deliver if @lead_source.value.present?

    respond_to ...
  end
end

Now you could argue that this is fine. All of the logic is encapsulated into the controller. However everyone knows that you should keep your controllers skinny.

Furthermore the above logic is in a place that is difficult to test. You need to bring in the full rails stack in order to test this code, which makes testing slow.

A good alternative is a service object whose job is solely to perform the convert action.

class ConvertLead
  def initialize(lead, lead_source)
    @lead, @lead_source = lead, lead_source
  end

  def convert!
    @deal = Deal.create(name: @lead.name, source: @lead_source, lead: @lead)
    @lead.touch(:converted_at)
    @lead.update_attribute(deal_id: @deal.id)
    send_lead_converted_email if @lead_source.has_value?
  end

  private

  def send_lead_converted_email
    UserMailer....deliver
  end
end

Since we have extracted this logic outside of rails, we can test it in isolation, which means we can test it fast, which brings me to my next point:

3. Your tests are slow, and you are testing your database too much

If you have tests that are reliant on the state of your database, that is a sign that you are not testing business logic, you are testing your database.

expect { Deal.convert! }.to change { Lead.count }.by(1)

Trust me, your database works. It will happily persist objects all the live long day. It should not be used as an intermediary to testing your business logic.

Going back to our example with the controller, testing that is a much easier process. All we need to do is assert that we have handed control to our service object:

describe LeadsController, "#convert" do
  it "hands control to our service object" do
    convert_lead = stub
    convert_lead.should_receive(:convert_lead!)
    ConvertLead.should_receive(:new).with(lead, lead_source).and_return(convert_lead)
    put :convert
  end
end

All we need to assert is that the message got there, with the correct args. ConvertLead is tested in isolation, outside of Rails.

Resources that can help

There are plenty of resources around learning software architecture.

Conference Presentations

Screencasts

Books

Blogs and blog posts

Github repos