Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Pros and cons of using callbacks for domain logic in Rails

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?

like image 979
David J. Avatar asked Jun 14 '12 18:06

David J.


People also ask

Why Active Record callbacks are bad?

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.

How does Callback work in Rails?

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.

What is around callback in Rails?

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.


3 Answers

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.

Callbacks

The advantage of callbacks:

  • everything is in one place, so that makes it easy
  • very readable code

The disadvantage of callbacks:

  • since everything is one place, it is easy to break the single responsibility principle
  • could make for heavy classes
  • what happens if one callback fails? does it still follow the chain? Hint: make sure your callbacks never fail, or otherwise set the state of the model to invalid.

Observers

The advantage of Observers

  • very clean code, you could make several observers for the same class, each doing a different thing
  • execution of observers is not coupled

The disadvantage of observers

  • at first it could be weird how behaviour is triggered (look in the observer!)

Conclusion

So in short:

  • use callbacks for the simple, model-related stuff (calculated values, default values, validations)
  • use observers for more cross-cutting behaviour (e.g. sending mail, propagating state, ...)

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.

like image 197
nathanvda Avatar answered Sep 20 '22 16:09

nathanvda


EDIT: I have combined my answers on the recommendations of some people here.

Summary

Based on some reading and thinking, I have come to some (tentative) statements of what I believe:

  1. 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.

  2. 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.

  3. Yes, callbacks can cause chain reactions that affect other objects. To the degree that this is not testable, this is a problem.

  4. Yes, you should be able to test your business logic without having to save an object to the database.

  5. 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.

  6. 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:

Reactions to "ActiveRecord's Callbacks Ruined My Life"

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.

Reactions to "Crazy, Heretical, and Awesome: The Way I Write Rails Apps"

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.

like image 24
David J. Avatar answered Sep 17 '22 16:09

David J.


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.

like image 42
Jesse Wolgamott Avatar answered Sep 20 '22 16:09

Jesse Wolgamott