You might be asking, why would you need to look for changed attributes? Is that possible in Ruby on Rails? On more than one occasion I’ve had a requirement that reads like the next sentence.
We need x to happen, but only when a particular attribute on model y changes.
The requirement means you need to watch model y and trigger x if the particular attribute changes.
Changed Attributes Example
Imagine, for a second, that you work on an e-commerce site. The e-commerce store is a custom Ruby on Rails application with products for sale. Because this is a Rails application, the products are backed by an ActiveRecord model.
Let’s bring the example requirement from earlier into the e-commerce context.
We need to send a price change email, but only when the price of a product changes.
In this example, the product has some representation of a price and a Mailer for sending price alert emails.
So, what are some ways you could approach this?
I’ll show you two ways you can achieve this in Ruby on Rails.
The most straightforward approach would be to look at a price before you update it. If the price of the product changes after you save it, you can trigger an email to send.
class ProductsController < ApplicationController def update @product = Product.find(params[:id]) old_price = @product.price if @product.update(product_params) send_price_change_email redirect_to product_path(@product.id) else render :edit end end private def product_params params.require(:product).permit(:price) end def send_price_change_email(old_price) if @product.price != old_price ProductMailer.price_change_email(@product).deliver_now end end end
I like this approach for only one reason: It’s easy to look at the controller to see what’s happening.
However, I don’t like this strategy for a couple of reasons.
1. The controller
action is responsible for too much.
The seven extra lines aren’t much, right? Maybe not now. However, consider that you’re only working with one attribute on the product model. If each attribute cost you seven lines, five properties would equate to thirty-five new lines of code.
You’ll also find the functionality littered throughout the update action. There are temporary variables introduced into your controller, passed into other methods. Finally, the controller now has to worry about sending an email to users.
with too much responsibility are hard to test.
Controllers are one of the more challenging aspects of a Rails application to test. The environment has to be setup Just Right™ for the test to run successfully.
Adding business logic to controllers make testing them even more challenging.
You want a controller test to check that a controller returns the proper status and response. This type of test shouldn’t have to concern its self with business logic.
So, if controllers aren’t a good solution to this problem, what’s next? Mighty glad you asked.
2. ActiveModel::Dirty + Callbacks
Ruby on Rails has solved the changed attributes problem using a Ruby Module named “Dirty.” The Dirty module “provides a way to track changes in your object in the same way as Active Record does.”
This module introduces a new API into your model that allows you to peek and see if a property has changed on your model.
product = Product.last product.price_changed? #=> false product.price = 100 product.price_changed? #=> true
🤔 So, that’s pretty handy. This module opens up an opportunity to use an ActiveModel Callback. After a products price is updated or saved in the database, you need to email a list of users.
class Person < ApplicationRecord # Callbacks after_save :send_price_watch_email private def send_price_watch_email if price_changed? ProductMailer.price_watch_email(self).deliver_later end end end
The new code adds a callback to the product that will send the email if the product’s price changed.
There are few things I like about the approach.
1. The controller can shrink back down.
By moving the code looking for changed attributes to the model, the controller can refactor back down to a subset of its original code.
class ProductsController < ApplicationController def update @product = Product.find(params[:id]) if @product.update(product_params) redirect_to product_path(@product.id) else render :edit end end private def product_params params.require(:product).permit(:price) end end
2. The model is responsible for its attributes.
In the previous example, the controller had to figure out the state of the model before and after an update. However, one of the fundamentals of Object Oriented Programming is that objects are responsible for messages and state. Including the Dirty module introduces a handful of state and methods (messages) to the Product concerning changed attributes.
easy easier to test.
By including the new functionality, your unit test can test that the email gets sent. Using RSpec, it might look something like this.
context 'when the price changes' do it 'should send an email' do product = Product.create(price: 200) expect(ProductMailer).to receive(:price_alert_email) product.update(price: 300) end end
Bonus: I'd argue this technique is the Ruby on Rails' Way to do it.
There are two things I’m hesitant about with this approach.
1. Callbacks can cause unexpected side-effects.
As your application grows or it becomes longer since you’ve worked with a model, callbacks can cause unexpected behavior. When you first wrote that code, it was expected behavior. However, when you come back to it after a while, it’s easy to forget about that one line callback making things awry.
2. Should the Product model be responsible for sending email?
Separation of Concerns is an age-old debate between developers. In fact, most of what this article you’re reading is about is that very topic.
Who should be responsible for sending this email out after changed attributes?
For the sake of this article, and your time, I’m going to say that the Product model shouldn’t be responsible for sending an email. However, if the model is that small, I wouldn’t frown upon that approach.
However, in my experience models are rarely that small. Eventually, you may need to check for multiple changed attributes and do more even more based on the price. At this point, reaching for a Service Object is my recommendation. More on service objects, soon!
There is no “one true way” to write code. In fact, YMMV (your mileage may vary) is very appropriate for this discussion. To find the “best way” for you is to write the code. If something doesn’t work as well as you thought it would, you can try again.