Using custom ActiveRecord events/callbacks

By Zach Dennis on 05 01 2009

Sometimes you are presented with a situation where you should use custom callbacks/events in your ActiveRecord model.

For example, consider the case where you need to ship the line items of an Order after it has been paid. Do you put the logic for creating a shipment in a controller, a model, or in an observer? If you use an observer, should you rely on the standard ActiveRecord callbacks like after_save?

Let's take a brief walk through each possibility in a progression that takes us from what may seem like the easiest solution to implement to what gives us the simplest, most maintainable code.

Using a controller to house this kind of logic violates SRP and leads to unnecessarily complex controller actions. As an added penalty it makes writing controller examples much more painful as there are more code execution paths. I don't think you should put your controller in the position of needing examples, but that's for another post.

This can lead to nasty looking actions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class OrderPaymentsController < ApplicationController
def create
order = Order.find params[:id]
if order.make_payment(params[:amount])
if order.paid_in_full?
shipment = OrderShipment.new :order => order
if shipment.save
...
else
...
end
else
...
end
end
end
end

There's got to be a better approach. Let's consider pushing this down into the Order model. This moves us in the spirit of skinny controller, fat model . After all an order knows when it has been paid:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Order < ActiveRecord::Base
has_many :payments
after_save :check_order_for_shipment

def make_payment(amount)
# ...
end
private

def check_order_for_shipment
if paid_in_full?
shipment = OrderShipment.create! :order => order
end
end
end

While this has the benefit of keeping the controller simple it really only moves the problem. Now our model becomes more complex. It violates SRP by including functionality that it shouldn't be responsible for. Why should an order know when and how to create a shipment? Practically speaking this will make writing and maintaining the Order model and its examples more difficult.

I'd rather extract the behaviour out into an observer that can be responsible for listening to an event and then kicking off the action of creating the shipment. Doing this won't add unnecessary complexity to the Order model. Let's extract it out in an OrderObserver:

1
2
3
4
5
6
7
8
9
class OrderObserver < ActiveRecord::Observer
observe Order

def after_save(order)
if paid_in_full?
shipment = OrderShipment.create! :order => order
end
end
end

This isolates the responsibility in the OrderObserver. It also lets us write examples for this behaviour in isolation (Pat Maddox's no-peeping-toms plugin is a great tool for isolating observer examples from model examples).

It's reasonable to stop here and be satisfied, but the solution can still be improved with minimal effort. Rather than relying on the after_save callback in the OrderObserver it would be clearer to introduce a paid_in_full callback. This removes an unnecessary conditional as well as explicitly expresses the intent of the callback. This requires that we update both the OrderObserver and the Order model.

ActiveRecord already includes all of the necessary wiring to trigger events so we just need to update the Order model to fire the paid_in_full event at the right time:

1
2
3
4
5
6
7
8
9
10
class Order < ActiveRecord::Base
has_many :payments

def make_payment(amount)
payments.create :amount => amount
if paid_in_full?
notify :paid_in_full
end
end
end

The OrderObserver only needs to be updated to have a callback for the paid_in_full event:

1
2
3
4
5
6
7
class OrderObserver < ActiveRecord::Observer
observe Order

def paid_in_full(order)
shipment = OrderShipment.create! :order => order
end
end

Now when a payment is made an event will be fired and the OrderObserver will listen for it and start the process of creating an order shipment. This gives the code clean separation, meaningful names, and an ability to easily open and extend the system later should more functionality need to be added when an order is paid in full.

While it's not always necessary to create custom callbacks and events there are times when it adds value to the code by keeping the code clean, maintainable, and extendable. This is a technique I've been using for long time and I hope you might benefit from it as well.