What do you see as the pros and cons of using callbacks for domain logic? (I'm talking in the context of Rails and/or Ruby projects.)
To start the discussion, I wanted to mention this quote from the Mongoid page on callbacks:
Using callbacks for domain logic is a bad design practice, and can lead to unexpected errors that are hard to debug when callbacks in the chain halt execution. It is our recommendation to only use them for cross-cutting concerns, like queueing up background jobs.
I would be interested to hear the argument or defense behind this claim. Is it intended to apply only to Mongo-backed applications? Or it is intended to apply across database technologies?
It would seem that The Ruby on Rails Guide to ActiveRecord Validations and Callbacks might disagree, at least when it comes to relational databases. Take this example:
class Order < ActiveRecord::Base
before_save :normalize_card_number, :if => :paid_with_card?
end
In my opinion, this is a perfect example of a simple callback that implements domain logic. It seems quick and effective. If I was to take the Mongoid advice, where would this logic go instead?
The biggest issue of ActiveRecord callbacks is they encourage Rails developers to introduce undocumented and unexpected side-effects into the the lowest level of the application.
Callbacks are methods that get called at certain moments of an object's life cycle. With callbacks it is possible to write code that will run whenever an Active Record object is created, saved, updated, deleted, validated, or loaded from the database.
In Rails, callbacks are hooks provided by Active Record that allow methods to run before or after a create, update, or destroy action occurs to an object. Since it can be hard to remember all of them and what they do, here is a quick reference for all current Rails 5 Active Record callbacks.
I really like using callbacks for small classes. I find it makes a class very readable, e.g. something like
before_save :ensure_values_are_calculated_correctly before_save :down_case_titles before_save :update_cache
It is immediately clear what is happening.
I even find this testable; I can test that the methods themselves work, and I can test each callback separately.
I strongly believe that callbacks in a class should only be used for aspects that belong to the class. If you want to trigger events on save, e.g. sending a mail if an object is in a certain state, or logging, I would use an Observer. This respects the single responsibility principle.
So in short:
And as always: all advice has to be taken with a grain of salt. But in my experience Observers scale really well (and are also little known).
Hope this helps.
EDIT: I have combined my answers on the recommendations of some people here.
Based on some reading and thinking, I have come to some (tentative) statements of what I believe:
The statement "Using callbacks for domain logic is a bad design practice" is false, as written. It overstates the point. Callbacks can be good place for domain logic, used appropriately. The question should not be if domain model logic should go in callbacks, it is what kind of domain logic makes sense to go in.
The statement "Using callbacks for domain logic ... can lead to unexpected errors that are hard to debug when callbacks in the chain halt execution" is true.
Yes, callbacks can cause chain reactions that affect other objects. To the degree that this is not testable, this is a problem.
Yes, you should be able to test your business logic without having to save an object to the database.
If one object's callbacks get too bloated for your sensibilities, there are alternative designs to consider, including (a) observers or (b) helper classes. These can cleanly handle multi object operations.
The advice "to only use [callbacks] for cross-cutting concerns, like queueing up background jobs" is intriguing but overstated. (I reviewed cross-cutting concerns to see if I was perhaps overlooking something.)
I also want to share some of my reactions to blog posts I've read that talk about this issue:
Mathias Meyer's 2010 post, ActiveRecord's Callbacks Ruined My Life, offers one perspective. He writes:
Whenever I started adding validations and callbacks to a model in a Rails application [...] It just felt wrong. It felt like I'm adding code that shouldn't be there, that makes everything a lot more complicated, and turns explicit into implicit code.
I find this last claim "turns explicit into implicit code" to be, well, an unfair expectation. We're talking about Rails here, right?! So much of the value add is about Rails doing things "magically" e.g. without the developer having to do it explicitly. Doesn't it seem strange to enjoy the fruits of Rails and yet critique implicit code?
Code that is only being run depending on the persistence state of an object.
I agree that this sounds unsavory.
Code that is being hard to test, because you need to save an object to test parts of your business logic.
Yes, this makes testing slow and difficult.
So, in summary, I think Mathias adds some interesting fuel to the fire, though I don't find all of it compelling.
In James Golick's 2010 post, Crazy, Heretical, and Awesome: The Way I Write Rails Apps, he writes:
Also, coupling all of your business logic to your persistence objects can have weird side-effects. In our application, when something is created, an after_create callback generates an entry in the logs, which are used to produce the activity feed. What if I want to create an object without logging — say, in the console? I can't. Saving and logging are married forever and for all eternity.
Later, he gets to the root of it:
The solution is actually pretty simple. A simplified explanation of the problem is that we violated the Single Responsibility Principle. So, we're going to use standard object oriented techniques to separate the concerns of our model logic.
I really appreciate that he moderates his advice by telling you when it applies and when it does not:
The truth is that in a simple application, obese persistence objects might never hurt. It's when things get a little more complicated than CRUD operations that these things start to pile up and become pain points.
This question right here ( Ignore the validation failures in rspec ) is an excellent reason why to not put logic in your callbacks: Testability.
Your code can have a tendency to develop many dependencies over time, where you start adding unless Rails.test?
into your methods.
I recommend only keeping formatting logic in your before_validation
callback, and moving things that touch multiple classes out into a Service object.
So in your case, I would move the normalize_card_number to a before_validation, and then you can validate that the card number is normalized.
But if you needed to go off and create a PaymentProfile somewhere, I would do that in another service workflow object:
class CreatesCustomer
def create(new_customer_object)
return new_customer_object unless new_customer_object.valid?
ActiveRecord::Base.transaction do
new_customer_object.save!
PaymentProfile.create!(new_customer_object)
end
new_customer_object
end
end
You could then easily test certain conditions, such as if it is not-valid, if the save doesn't happen, or if the payment gateway throws an exception.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With