Make workflows for complicated tasks

I’ve been extracting a lot of controller code into simple POROs recently, but it’s become more and more difficult and repetitive to get things to work consistently. I end up doing a lot of if statements in the #call method to manage failure states. An example might be:

# in a controller
def create
  @amount = params[:amount].to_i
  unless @amount > 100
    render :error and return
  end

  begin
    @charge = Stripe::Charge.create({
        amount: @amount,
        card: params[:card_token]
      })
  rescue Stripe::CardError => e
    render :error and return
  end

  @charge_response = StripeChargeResponse.new(body: @charge.to_hash)
  @payment = Payment.new({
    stripe_charge_response: charge_response,
    stripe_charge_id: charge.id,
    amount: charge.amount,
    currency: charge.currency
  })

  begin
    @payment.save!
  rescue ActiveRecord::RecordInvalid => e
    render :error and return
  end

  ReceiptMailer.payment_receipt(@payment).deliver_later

  redirect_to receipt_path(@payment)
end

After some work, I decided I should extract each step of the operation into it’s own method:

# in a controller
def create
  if grab_amount &&
     charge &&
     save_payment &&
     send_email
    redirect_to receipt_path(@payment)
  else
    render :error
  end
end

def grab_amount
  @amount = params[:amount].to_i
  @amount > 100
end

def charge
  @charge = Stripe::Charge.create({
    amount: @amount,
    card: params[:card_token]
  })
  true
rescue Stripe::CardError => e
end

def save_payment
  @charge_response = StripeChargeResponse.new(body: @charge.to_hash)
  @payment = Payment.new({
    stripe_charge_response: charge_response,
    stripe_charge_id: charge.id,
    amount: charge.amount,
    currency: charge.currency
  })

  @payment.save!
rescue ActiveRecord::RecordInvalid => e
end

def send_email
  ReceiptMailer.payment_receipt(@payment).deliver_later
  true
end

After that, I realized if I had a method to wrap and capture failures I could cleanup things even more:

# in a controller
def create
  if grab_amount &&
     charge &&
     save_payment &&
     send_email
    redirect_to receipt_path(@payment)
  else
    render :error
  end
end

def define_and_capture
  yield
rescue StandardError => e
  false
end

define_and_capture :grab_amount do
  @amount = params[:amount].to_i
  @amount > 100
end

define_and_capture :charge do
  @charge = Stripe::Charge.create({
    amount: @amount,
    card: params[:card_token]
  })
  true
end

define_and_capture :save_payment do
  @charge_response = StripeChargeResponse.new(body: @charge.to_hash)
  @payment = Payment.new({
    stripe_charge_response: charge_response,
    stripe_charge_id: charge.id,
    amount: charge.amount,
    currency: charge.currency
  })

  @payment.save!
end

define_and_capture :send_email do
  ReceiptMailer.payment_receipt(@payment).deliver_later
  true
end

And this is way too much going on in the controller, IMHO. So making a service object for this is pretty simple:

# in a controller
def create
  @service = ChargeACard.new(params[:amount], params[:card_token])
  if @service.call
    redirect_to receipt_path(@service.payment)
  else
    render :error
  end
end

# in it's own file
class ChargeACard
  attr_reader :payment

  def initialize(amount, card_token)
    @amount = amount
    @card_token = card_token
  end

  def call
    if grab_amount &&
       charge &&
       save_payment &&
       send_email
      true
    else
      false
    end
  end

  def define_and_capture
    yield
  rescue StandardError => e
    false
  end

  define_and_capture :grab_amount do
    @amount = params[:amount].to_i
    @amount > 100
  end

  define_and_capture :charge do
    @charge = Stripe::Charge.create({
      amount: @amount,
      card: params[:card_token]
    })
    true
  end

  define_and_capture :save_payment do
    @charge_response = StripeChargeResponse.new(body: @charge.to_hash)
    @payment = Payment.new({
      stripe_charge_response: charge_response,
      stripe_charge_id: charge.id,
      amount: charge.amount,
      currency: charge.currency
    })

    @payment.save!
  end

  define_and_capture :send_email do
    ReceiptMailer.payment_receipt(@payment).deliver_later
    true
  end
end

I iterated on this more and then decided I should just package up the repeatable bits into a module which I am now publishing as a gem: Workout.

Workout can help declare the steps needed to work through something. If any step fails then execution halts. A workflow instance knows if it’s completed, valid, or successful. This means a lot of controller actions can return to the simple and amazing if success then render success, else render error.

Most service object type libraries I see online accept their arguments into the call method, but I don’t like this approach. I’ve made the mistake of setting instance vars in methods and those might get carried over. To me, a better approach is to always Thing.new(args).call each time instead.

I hope someone might also find this type of object useful.

 
2
Kudos
 
2
Kudos

Now read this

Object Oriented System Architecture

Building large systems to process web requests, work jobs, and do other things can be daunting without a plan of attack or a system-of-thought. How many components to build, how to separate responsibilities, and when to build small or... Continue →