I recently discussed on the implementation and use of counter cache. The case is simple and usual:

class Deal
  has_many :coupons
end

class Coupon
  belongs_to :deal
end

Since we display a list of Deals and their associated coupons, we introduced a counter_cache to remove N+1 queries from the following view:

<% @deals.each do |deal| %>
  <div>
    <%= deal.coupons.count %>
  </div>
<% end %>

so by introducing a counter cache we can now write:

class Coupon
  belongs_to :deal, counter_cache: true
end
<% @deals.each do |deal| %>
  <div>
    <%= deal.coupons_count %>
  </div>
<% end %>

and our N+1 query is gone.

But we should always be aware that this is a “cache”, and if you programmed for more than 15 minutes in your life, you know the golden rule:

CACHE = BUGS

So what if your coupons are a limited resource? What if there are money attached to those coupons? Do you still want to rely on a cached value? The answer is NO.

Given the following code:

class Deal
  def claim
    with_lock do
      available = initial_quantity - coupons.count
      coupons.create!(user:) if available > 0
    end
  end
end

always perform a real count on the resource and do not rely on the cached value. In this case, use coupons.count and not coupons_count.

This of course must come with a test. Here is an example:

describe "#claim" do
  context "when the counter_cache is not up-to-date" do
    let(:deal) { create(:deal, initial_quantity: 2) }
    before do
      create_list(:coupon, 2, deal: deal)
      Coupon.delete_all # this is a real-world scenario. We often do data-migration or use SQL instructions directly
    end

    it "allows claiming nevertheless" do
      expect(deal.coupons.count).to eq(0)
      expect(deal.available_quantity).to eq(0) # wrong!
      deal.claim
      expect(deal.coupons.count).to eq(1)
    end
  end 
end

If you are not a big fan of cached value and Rails cache columns, you can also have a look at https://github.com/djezzzl/n1_loader.

The code would look as follow in our case:

class Deal
  n1_optimized :coupons_count do |deals|
    total_per_deal = Coupon.group(:deal_id).where(deal: deals).count.tap { |h| h.default = 0 }
    deals.each do |deal|
      total = total_per_deal[deal.id]
      fulfill(deal, total)
    end
  end
end

and you can use:

<% @deals.each do |deal| %>
  <div>
    <%= deal.coupons_count %>
  </div>
<% end %>

Now, this solution has the advantage to always use the real count. There’s no cache involved and therefore the value is guaranteed to always be the real count.

The disadvantage is, of course, the additional query, which might more expensive and you need a bit more code.

Next time you need to solve an N+1 problem, you might consider one of these two options.