Moving transactional operations away from the cont

2019-08-04 18:14发布

问题:

  def cancel

    begin
      to_bank = @transfer.main_to_bank
      to_bank.with_lock do
        to_bank.locked_balance -= @transfer.amount
        to_bank.available_balance += @transfer.amount
        to_bank.save!
        @transfer.cancel
        @transfer.save!
      end
    rescue ActiveRecord::ActiveRecordError => e
      redirect_to admin_transfer_url(@transfer), alert: "Error while cancelling."
      return
    end

    redirect_to admin_transfer_url(@transfer), notice: 'Transfer was successfully cancelled.'
  end

I would want to refactor the above code to the Transfer model or some other place, because this same code is used elsewhere. However, ActiveRecord does some transaction magic within the model, so I'm worried I might introduce some unexpected side effects by simply moving the code under the model.

Are my worries unfounded and how would the above code typically be refactored to be outside of the controller for reusability?

Update: This seems like the perfect spot for a service object, as described here http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/.

回答1:

1) As you mention in your update, this is a perfect fit for service objects. Put them in a directory like app/services since anything in /app is autoloaded. There are two popular ways of implementing them:

As a static class:

AccountService.transfer(from_account, to_account, amount)

As an object:

service = AccountService.new(from_account, to_account)
service.transfer(amount)

I prefer option one coming from a Java enterprise development background where you would use Java beans similarly.

I would also recommend returning result objects from all services as a rule. This means you create a small class called "ServiceResult" which contains a boolean flag of whether or not the call was successful, a user friendly message and optionally a result object (which is the method return value if you didn't have service result objects). In other words checking the result from the controller or any other place would be:

response = AccountService.transfer(from, to, amount)

if response.success?
  flash[:notice] = response.message
else
  flash[:alert] = response.message
end

You can always refactor this into a method:

flash_service_result response

After adding some helper methods to your services, a service method could look like this:

def self.transfer(from_account, to_account, amount)

   ActiveRecord::Base.transaction do
     ..do stuff..
     from_account.save!
     to_account.save!

     service_success("Transfer succesfull...")
   end

 rescue SomeException => error
   service_error("Failed to transfer...")

 rescue ActiveRecord::RecordInvalid => invalid_record_error
   service_validation_error(invalid_record_error)
 end

When using result objects, never raise an exception from the service that you expect to be handled (checked exceptions in other languages).

2) Using the active record transactional methods will behave the same regardless of where it's called from. It will not add any side effects. So yes you can call it from a controller or service.