Mind the transaction

26 Dec, 2020
0 views
rails

Decorators are a great alternative to ActiveRecord callbacks, since they make it harder to shoot oneself in the foot.

Instead of using a callback to send an email:

class Payment < ApplicationRecord
 after_commit :send_email, on: :create
 private
 def send_email
 UserMailer.payment_created(self).deliver_later
 end
end
Payment.create # sends an email

I prefer to use a decorator for that:

class Payment < ApplicationRecord
end
class PaymentWithEmail < SimpleDelegator
 def save
 super && send_email
 end
 def save!
 super && send_email
 end
 private
 def send_email
 UserMailer.payment_created(__getobj__).deliver_later
 end
end
Payment.create # does not send an email
PaymentWithEmail.new(Payment.new).save # sends an email

This makes saving a payment less surprising since there are no unintended side effects. Saving a payment will only save it and not send any emails, or worse yet communicate with the third party API (with the payment gateway, for example, to process it).

However, the decorator only temporarily saves the foot from being shot. It gets hurt if there are transactions involved:

class PaymentForm
 include ActiveModel::Model
 def save
 ActiveRecord::Base.transaction do
 user = User.create!
 invoice = user.invoices.create!
 PaymentWithEmail.new(invoice.payments.build).save!
 rescue ActiveRecord::RecordInvalid => e
 errors.add(:base, e.message)
 false
 end
 end
end
PaymentForm.new.save # no email will be delivered

This behavior is somewhat surprising and could’ve been avoided had we used the standard Rails’ callback approach. As with many other things, we are punished for not doing things the Rails way.

Here, our decorator wrongly assumes that save returning true means that the model is saved in a database. This is not the case if save is wrapped in a transaction block. This is why the original example with callbacks had used after_commit instead of after_save.

Thankfully, using transactions with decorators like this is still possible, with help from after_commit everywhere gem:

class PaymentWithEmail < SimpleDelegator
 include AfterCommitEverywhere
 def save
 super && send_email
 end
 def save!
 super && send_email
 end
 private
 def send_email
 after_commit do
 UserMailer.payment_created(__getobj__).deliver_later
 end
 end
end
PaymentForm.new.save # emails will now be delivered, as expected

AltStyle によって変換されたページ (->オリジナル) /