Fat models and large actions have always been a problem for Rails developers, service objects came to help us but sometimes it feels like we just move the bad and hard to follow the code from controllers to plain ruby objects. But Waterfall gem seems to solve the problem. Let’s check it out.

Waterfall

A waterfall is a new approach to service objects. The idea behind it is being functional: you can pass blocks and other waterfall objects one to another chaining them easily. If any of them fails (gets ‘dammed’ as it’s called here), the whole waterfall fails and can be rolled back by transactions or manually using the #reverse flow method.

The average waterfall service looks like this:

class MakeUserHappy < Struct.new(:user)
  include Waterfall

  def call
    with_transaction do
      chain { MakeUserHappy.new(user) }

      when_falsy { user.happy? }
        .dam { "The #{user.name} is not happy at all >_<" }

      chain { notify_happy_user }

      on_dam { |error_pool| puts error_pool + ' shame on you'}
    end
  end

  private

  def reverse_flow
    make_user_unhappy(user)
  end

  def notify_happy_user
    ...
  end 
end

The best thing about this approach is that it allows us to create some basic service objects and combine them the way we want to. As for the controller, it looks like this:

def make_user_happy
  authorize(@user)

  Wf.new
    .chain  { MakeUserHappy.new(@user) }
    .chain  { flash[:notice] = 'User is happy!' }
    .on_dam { |err| flash[:alert] = err }

  redirect_to user_path(@user)
end

You can add as many #chain methods as you want of course, but we try to keep it simple for controllers.

Case 1 — Nested attributes and around callbacks

Let’s take a look at the example. What we wanted to do is to get rid of the nested attributes and update passengers of a booking (enquiry) separately from the booking itself. We also had to do some tracking before any updates and right after them. Finally, we needed the tracking to be optional.

If we didn’t have to to get rid of the nested attributes, it would be easy just to make some around_update callback for a booking and use nested attributes. But that gives us a hard time trying to get an older state of the object because the around_update callback would assign attributes right away. Finally, we would have to think of the way to skip the callback in some cases and there’s currently no clean solution for this in Rails. And all this has to be done for the sake of a simple tracking, isn’t that overcomplicated already?

So we decided to make separate service objects for each step and chain them all together. The update action now looks like this:

def update
  authorize @enquiry

  Wf.new
    .chain { UpdateEnquiry.new(@enquiry, enquiry_params, partners_params) }
    .chain do
      redirect_to edit_enquiry_path(@enquiry), notice: 'Enquiry was successfully updated.'
    end
    .on_dam do |err|
      redirect_to edit_enquiry_path(@enquiry), alert: "Error updating enquiry: #{err}."
    end
end

and the UpdateEnquiry service:

class UpdateEnquiry
  include Waterfall

  attr_accessor :enquiry, :enquiry_params, :partners_params

  def initialize(enquiry, enquiry_params, partners_params)
    @enquiry = enquiry
    @enquiry_params = enquiry_params
    @partners_params = partners_params
  end

  def call
    with_transaction do
      TrackEnquiryUpdate.new(enquiry).call do
        chain { UpdatePartners.new(enquiry.partners, partners_params) }

        when_falsy { enquiry.update(enquiry_params) }
          .dam { enquiry.errors.full_messages.join('; ') }
      end
    end
  end 
end

As you can see, we can easily use the #chain method inside the blocks as well. Tracking became much easier – now we just pass an enquiry we want to track and some block to update it or its associations. It’s like an around_update callback but it’s playing by our rules and it’s up to us whether we are going to use it. Here’s the code for TrackEnquiryUpdate:

class TrackEnquiryUpdate < Struct.new(:enquiry)
  include Waterfall

  def call
    chain do
      enquiry.track(:remove_from_cart)
      yield
      enquiry.reload.track(:add_to_cart)
    end
  end

  private

  def reverse_flow
    ...
  end 
end

We’ve decided to use Struct for service objects which have no initialize logic, that makes them much slimmer. Notice the #reverse_flow method that allows us to implement a custom rollback in case the waterfall is dammed.

19 Ruby on Rails Gems which Can Amaze

I’ll cover it out in the next example.

Case 2 — Payments and rollbacks

Payments. We all been there: sometimes API returns an error after creating a payment in a database, other times we enroll a payment but fail to save it to a database. Waterfall helps to ditch these issues as well.

How to Choose a Payment Platform for Your Project: PayPal, Stripe, Braintree

In controller we have this:

def create
  payment_form = CardPaymentForm.new(card_payment_form_params)

  Wf.new
    .chain { EnrollPayment.new(payment_form) }
    .chain { head :ok }
    .on_dam do |errors|
      render json: { msg: errors.join(";\n ") }, status: 422
    end
end

and the Enroll payment waterfall:

class EnrollPayment < Struct.new(:payment_form)
  include Waterfall

  def call
    chain(charge: :charge) do
      ChargeStripe.new(charge_params)
    end

    chain(balance: :balance) do |flow|
      GetStripeBalanceTransaction.new(flow.charge.balance_transaction)
    end

    when_falsy do |flow|
      payment_form.attributes = charge_payment_params(flow.charge, flow.balance)
      payment_form.save
    end
      .dam { payment_form.errors.full_messages }

    chain { notify_after_payment }
  end

  private

  def charge_params
    payment_form.stripe_charge_params
  end

  def charge_payment_params(charge, balance)
    ...
  end

  def notify_after_payment
    ...
  end 
end

Notice how we separate API calls and database transactions. Having them in separate waterfalls allows us to apply custom rollbacks for them. with_transaction block will take care of database rollbacks, for API rollback we’ll have to take a look at the ChargeStripe waterfall:

class ChargeStripe < Struct.new(:stripe_charge_params)
  include Waterfall

  def call
    chain(:charge) { Stripe::Charge.create(stripe_charge_params) }
  rescue Stripe::StripeError => e
    dam([e.message])
  end

  private

  def reverse_flow
    Stripe::Refund.create(charge: self.outflow.charge.id)
  end
end

The #reverse_flow is our custom rollback and it will only be executed if the current waterfall finished its job and the parent waterfall got dammed later. So in case we get a database error or the GetStripeBalanceTransaction waterfall will be dammed, the ChargeStripe waterfall will refund the payment made earlier.

Conclusion

The waterfall is a great new way to implement service objects and clear out your controllers and models from the code that doesn’t belong there. Now services look like a clear sequence of actions, not just some kind of a rubbish dump for all your scary code 🙂

How useful was this post?

Click on a star to rate it!

Average rating 4.8 / 5. Vote count: 16

No votes so far! Be the first to rate this post.

We are sorry that this post was not useful for you!

Let us improve this post!

Tell us how we can improve this post?


Author

VP of Business Development at Rubyroid Labs

Write A Comment