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