Persistence is an implementation detail

Tyler Jennings, Dec 09, 2010

Persistence is an implementation detail, well it should be anyway. The reality is that in most applications (and frameworks) persistence is treated as a first order concern. In Rails, what makes us think that weaving our application's model through the guts of Active Record is a good idea? It isn't, but it is convenient.

That is where a lot of Rails developers fall down, myself included. Active Record is so damn convenient. All application start simple and in these simple applications AR provides everything you need - CRUD, some lifecycle hooks, and a great DSL for building queries. Wiring up a web application this way with Rails is intoxicatingly easy.

As a software system grows and business processes get more complex the simple AR model becomes insufficient. We, as Rails developers, don't plan well for this coming insufficiency. We also fail to react quickly enough when it becomes an issue. The final nail in the coffin is the loss of convenience. Pushing Active Record into the bowels of the system - where at some point it always should be - means giving up some of the convenience we've come to depend on.

How about a concrete example? Lets say you're getting started on an e-commerce system. Quite naturally you have a User who purchases things through an Order. Creating orders might look something like this:

def create 

@order = Order.new(params[:order])

 if @order.save 

 redirect_to(@order, :notice => 'Order was successfully created.') 

 else 

 render :action => "new" 

end 

 end

What is wrong with this code? Absolutely nothing. Right now it solves the problem - creating an order for the current user - quite sufficiently. It also offers us a lot of convenience. When an Active Record instance gets together with Active Support magic happens. Forms automatically repopulate, errors are displayed, and params flow into our models with ease. All this free stuff is incredibly timesaving when you're trying to get your app launched. It also makes stepping outside Rails' sweet spot feel like a huge pain, even when it is necessary.

Fast forward six months. You want to increase conversion for new users by consolidating the checkout / signup process into a single form. Now things are getting interesting. We need to coordinate a process across multiple entities (Order, User, and Credit Card) and all of that needs to work through a single controller action and form. Now, you could keep the simple code we saw in the previous example using nested forms. Rails will support you doing this and it feels like you're working with the framework. However, this means all the logic of coordinating this process is going to end up in a lifecycle hook on the Order in addition to having a questionable location in the model. Orders managing the creation of users and credit cards? It doesn't make sense. It certainly isn't something that should be happening when you call order.save. However, I see this kind of stuff all the time. The domain takes a back seat to convenience and persistence-oriented language in the controller.

There should never, ever be cross-entity business logic invoked from lifecycle hooks.


Instead, I suggest the following:

def purchase 

@purchase = SingleClickCheckout.purchase(current_user, params[:purchase_form]) 

 if purchase.success? 

 redirect_to(@purchase, :notice => 'Your purchase was successful')

else

render :action => "new" 

 end

end

This is a domain service. An object that takes responsibility for a business process that does not fit cleanly into a single entity. This approach lets us interact more naturally with Order, User, and CreditCard instances without the fear of errant lifecycle behavior doing unexpected things. We can also design our service so that it speaks well to the domain we're trying to model. Finally, we can put the logic in a meaningful place that speaks to the problem we're addressing. Now, the down side here is we need to build a purchase class that works well with ActiveSupport and is able to aggregate errors from multiple entities. I believe the former can be done easily in Rails 3 with ActiveModel, but the latter would still need to be rolled by hand. That's not a terrible amount of work, yet another reason to move to Rails 3. If you'd like to read more about Domain Services I suggest Domain-Driven Design by Eric Evans.

If you feel like your model is getting unwieldy don't be afraid to buck Rails tradition in favor of a richer, more expressive solution. Remember, persistence is an implementation detail not a first-order concern of the model. You'll find the little time investment necessary to get Rails to play nice with your rich model will be well worth the cost. Also, Domain Services represent just one of many OO modeling techniques that are underutilized in Rails applications. Value objects are especially underused, and already supported in Rails via composed_of. Look for more places to use these as well, I guarantee you'll find them.

Rss-icon Rss-icon-over
Archive

Archive