On ActiveRecord callbacks, setters and derived data
We’ve already written in Arkency a few times about callbacks alternatives in Rails and what kind of problems you can expect from them. But I still see them being used in the wild in many scenarios so why not write about this topic a bit one more time with different examples.
Double method invocation from a controller
class Controller def update @cart = Cart.find(params[:id]) @cart.update_attributes!(...) @cart.update_tax head :ok end end
This is a pattern that I’ve seen quite often. Predefined (existing) methods from
ActiveRecord::Base are used to set some attributes. Often with the combination of
accepts_nested_attributes_for being used to edit some objects deeper in the tree.
And then derived-data must be recomputed such as maybe taxes, maybe sums, counters, discounts, it varies between applications. An example can be that sales tax in US depends on the shipping address. So when you set it, you would like to have taxes recalculated (in
Cart, or whatever you call it in your app).
The usual reason why those calculations are kept in a database is that we don’t want them to change in the future when prices or taxes or discount amounts change etc. So we want to compute and store the derived data based on current values. The other reason is that it might make calculating reports on DB faster and/or easier.
In such case instead of using
attributes= to set the address and then calling
update_tax to trigger recalculations, it’s better to have a single, public, intention revealing method such as
I must say that having a public interface, in which no matter the order of calling methods or their arguments, I always end with a correct state (or an exception clearly indicating an incorrect usage of the object), is a must-have for me. Write your objects so that either the order does not matter, or you prevent the wrong order by keeping an internal state. I believe the word I am looking for is commutative.
It also makes refactoring flows much much easier. If you decide to change your checkout process so that you provide discount before or after the address, it won’t matter because your code will always properly recalculate the derived-data. If you decide to split a big screen which allowed changing 10 values into 2 smaller screens you will feel safe things still work just fine.
Callback in a model for re-calculating values
Another typical example looks like this:
class Order before_save :set_amount def add_line(...) # ... end private def set_amount self.amount = line_items.map(&:amount).sum end end
It’s the same issue as before; just automated a little bit more, because when you call
save! the method will get called automatically. But you still can’t write your tests like:
order.add_line(product) expect(order.amount).to eq(product.price)
Instead, you need to trigger re-computation manually or save:
order.add_line(product) order.save! expect(order.amount).to eq(product.price)
order.add_line(product) order.set_amount # can't be private expect(order.amount).to eq(product.price)
which is no fun at all (at least for me).
How can you avoid it? Add meaningful, intention-revealing methods which you are going to use such as
update_line etc which will re-compute derived data such as
tax. Make those domain operations explicit instead of hiding them somewhere in callbacks. Remember that you can often overwrite Rails setters or getters to call
super and then continue with your job.
class Wow < ActiveRecord::Base def column_1=(val) super(val) self.sum = column_1 + column_2 end def column_2=(val) super(val) self.sum = column_1 + column_2 end end
This is especially useful when you have many calculations that need to be evaluated again.
class Wow < ActiveRecord::Base def column_1=(val) super(val) compute_derived_calculations end def column_2=(val) super(val) compute_derived_calculations end private def compute_derived_calculations self.sum = column_1 + column_2 self.discounted = sum * percentage_discount self.tax = (sum - discounted) * 0.02 self.total = sum - discounted + tax end end
Today I had 6 such values 😉.
Why do we deal with those problems?
I believe the root problem of those issues is that by default there are so many
public methods in
ActiveRecord subclasses. Every attribute, every association, all of that is public and anyone can change it from anywhere. In a situation like that, you as a developer need to be responsible for making the real API used by your application smaller, limited and predictable.
I gotta say when I watched some code from other languages (or maybe frameworks would be a more accurate depiction as this is not Ruby’s fault) it is much more common to have encapsulated methods which protect rules and trigger computing of derived data. In Rails, it’s rather common to set anything in columns and worry about it during validation phase or when it’s time to save the object and heavily rely on callbacks for that. I prefer my objects to be OK all the time.
I hope I won’t lose you here. But do you use React.js? Do you know how it is best when
render is a pure function based only on component’s
props? The result of calling
render in React is derived data which should always give the same result for the same arguments.
You can think about methods like these in the same way:
def compute_derived_calculations self.sum = column_1 + column_2 self.discounted = sum * percentage_discount self.tax = (sum - discounted) * 0.02 self.total = sum - discounted + tax end
There are values you can set such as
def column_2=(val) super(val) compute_derived_calculations end
And there are derived values that you get such as
total which are automatically recomputed. I am sure you that is obvious to you, it’s just
ActiveRecord does not make it easy for you at all, so we need to make some effort.
It’s just about cohesive aggregates
If you follow us and read the Domain-Driven Design ebook you might recognize that the refactorings that I mention, they bring us closer to having nice Aggregates which protect their internal rules all the time.
More about this
If you enjoyed that story, subscribe to our newsletter. We share our everyday struggles and solutions for building maintainable Rails apps which don’t surprise you.
Also worth reading: