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 🙂