← til

Mind the transaction

December 26, 2020
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