On ActiveRecord callbacks, setters and derived data

… and check why 5600+ Rails engineers read also this

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 Order, Sale, 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 update_attributes! or 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 shipping_address= setter.

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 add_line, remove_line, update_line etc which will re-compute derived data such as amount or 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.

far-far-reaching analogy

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 state and 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 column_1 or column_2

  def column_2=(val)
    super(val)
    compute_derived_calculations
  end

And there are derived values that you get such as sum, discounted, tax and 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:

You might also like