A good use of Rails Model Callbacks
What it’s For…
Processing some input on the same model. Let’s say we have a User
model with a full_name
field. Since this is user-entered data and we can’t control what a user enters, we may end up getting a bunch of extra white spaces that will cause problems in our queries.
In this case, we could use a before_save
callback to process that data before it is saved to our database.
# app/models/user.rb
class User
before_save :strip_whitespaces
def strip_whitespaces
self.full_name = full_name.strip
end
end
This is a simple transformation that saves us some headaches with our data and does not reach beyond this model. That’s why this callback feels safe to do.
What it’s Not For…
I’m not an expert and this can be hotly debated. So I’ll offer you my opinion. Callbacks are sneaky. Unlike other methods, they’re not necessarily easy to spot or predict. Using them makes it easy to get blindsided by surprise behavior and side effects.
Recently, I wanted to build out some assets when a new user set up an account. I did the quick and easy thing first – I set up an after_create
callback on my User
model like so:
# Don't do this
class User
after_create :build_all_the_things
def build_all_the_things
# builds all the things for this user
Category.build_default(self)
Topic.build_default(self)
FavoriteCatPhoto.build_default(self)
end
end
This approach was handy to play with my proof of concept, but the moment I started testing, I ran into trouble. All of my tests were broken because every time I created a user
in any of my specs (which is a lot of times), I was also building out all of these extra assets and all of my original test assumptions were now wrong (but in reality, they weren’t). It slowed down my test suite and just made unit tests clunky. My User
model now had to know about these other models that were part of this building method. Plus, SURPRISE!. An unsuspecting developer (a.k.a. future me) would need to know this in advance to work in this test suite. I considered overriding callbacks for the test environment, but that was a red flag, so I chose another path forward.
A Better Way
To solve my problem, I made a service object & method that handled building out all of the assets. Then, I called that method from the registrations_controller
so these assets would be built out only once and only when a user registered for an account. This kept my User
unit tests clean and separated those asset-building concerns into their own unit-testable service object.
# app/controllers/registrations_controller.rb
def create
user = User.find_by(id: params[:id])
if user.save?
# Add in a service object here amongst the other post-save actions
AccountSetupService.generate_user_assets(user)
else
# do other stuff
end
end
Is this approach the answer every time? Nah. But it worked for this situation and I’m glad I came to this solution because my code is much cleaner now.