Cowie.me

Using Railway-Oriented Programming to Model Business Transactions

in Development

The more complex a process is, the more ways it can fail. The more forks in the road you have, the more dead ends you’ll come across. In a complex web application on you might validate input, locate records, communicate with external services, and more, and that’s on top of performing any number of operations the business logic calls for. This can lead to a messy web of interdependent code and lots of additional overhead to continually ensure we are in a good state.

Railway oriented programming offers a solution. It’s a technique for dividing a complex set of logic into a series of discrete steps. Each step can either pass or fail. If it passes, the transaction continues. If it fails, the transaction splits off from the railway at that given stage and ends. This naturally fits the shape most processes have. Most of the time things are successful and stays on that main path, but there’s many things that could go wrong. It’s easy to fixate on that happy path, that’s where the stakeholders are going to be focused, and deal with the rest later. But, as developers we need to cover every possible state, and that’s what the Railway way helps us do. It both greatly reduces what we’re dealing with at at one time, as well as providing a natural place for any error handling required. Organizing our logic this way gives us a few additional intertwined advantages:

Implementation

A few years ago, I came across dry-rb, an evolving collection of useful abstractions for Ruby. It includes the simple yet powerful Dry::Monads library. Monads are famously a bit difficult to grok, but they have lots of uses, some of which are laid out in their documentation. Encouraged additional reading!

Dry::Monads provides what we need to build a railway-oriented process: conventions to report success or failure (:result), and an eject button when a failure is hit (:do). For this example, let’s walk through a someone registering to attend a paid online workshop.

To complete a registration, we need the workshop being registered for, basic information for the registrant (name, email), and optionally a discount code, and payment source to be charged if the workshop is not free (before or after discount!). This is a simplified example but already there’s a few ways it can go sideways. The workshop may not be open to registration, registrant’s email may be missing, the discount code may be expired, payment may or may not be due, and if so that charge has to process.

We’ll start with a class, including the modules we want from Dry::Monads, and initialized with the input we’ll need to process the registration.

require 'dry/monads'

class WorkshopRegistrar
  include Dry::Monads[:result, :do]

  def initialize(workshop:, discount_code: nil, payment_source: nil, registrant_info: {})
    @workshop = workshop
    @registrant_info = registrant_info
    @discount_code = discount_code
    @payment_source = payment_source
  end
end

We’re ready for our first step. Each one must return Success or Failure. These can wrap any return value. I’ve not come across a case where there was really a need to return some resulting value. So, instead I like to pass a descriptive error code inside any Failures. This can provide immediate context when debugging and scanning logs. It could also be used later to provide specific feedback to the user.

  def call
    yield check_availability(@workshop)
  end
  
  def check_availability(workshop)
    if workshop.blank?
      Failure(:workshop_not_found)
    elsif workshop.online_registerable?
      Success(true)
    else
      Failure(:workshops_not_online_registerable)
    end
  end
  

Next, we’re ready to generate a registration record. We will need this registration down the line, so we’ll return with our Success.

  def call
    yield check_availability(@workshop)
    registration = yield create_registration(workshop: @workshop, registrant_info: @registrant_info)
  end
  
  def check_availability(workshop)
    # ...
  end
  
  def create_registration(workshop:, registrant_info: {})
    reg = Registration.new(registrant_info)
    reg.workshop = workshop
    reg.confirmation_code = reg.generate_unique_confirmation_code
    if reg.save
      Success(reg)
    else
      Failure(:registration_could_not_create)
    end
  end

With the do syntax from Dry::Monads, to run through our steps, we yield to them in our desired order. Remember, if a Failure is returned from a yield the method exits early with that result. From here forward, we can know we have a valid, available workshop, and a valid (pending) registration.

Following this pattern, we can add each step of what needs to happen one by one. Here’s a more complete view at what that looks like in this simplified example.

require 'dry/monads'

class WorkshopRegistrar
  include Dry::Monads[:result, :do]

  def initialize(workshop:, discount_code: "", payment_source: "", registrant_info: {}, expected_price_cents:)
    @workshop = workshop
    @registrant_info = registrant_info
    @discount_code = discount_code
    @payment_source = payment_source
    @expected_price = Money.new(expected_price_cents)
  end

  def call
    ApplicationRecord.transaction do
      yield check_availability(@workshop)
      registration = yield create_registration(workshop: @workshop, registrant_info: @registrant_info)
      yield check_discount_code(@discount_code)
      payment = yield build_payment(registration: registration, discount_code: @discount_code, workshop: @workshop)
      yield verify_price(payment, @expected_price)
      yield charge_payment(payment, source: @payment_source)
      yield mark_completed(registration)
      yield mail_registrant(registration)

      Success(registration)
    end
  end

  def check_availability(workshop)
    if workshop.blank?
      Failure(:workshop_not_found)
    elsif workshop.online_registerable?
      Success(true)
    else
      Failure(:workshop_not_online_registerable)
    end
  end

  def create_registration(workshop:, registrant_info: {})
    reg = Registration.new(registrant_info)
    reg.workshop = workshop
    reg.confirmation_code = reg.generate_unique_confirmation_code
    if reg.save
      Success(reg)
    else
      Failure(:registration_could_not_create)
    end
  end
  
  def check_discount_code(code)
    if code.blank?
      Success()
    elsif DiscountCode.active.lookup(code)
      Success(code)
    else
      Failure(:discount_code_invalid)
    end
  end

  def build_payment(registration:, discount_code:, workshop:)
    if registration&.email.blank? || registration&.confirmation_code.blank?
      return Failure(:payment_missing_info)
    end
    
    original_amount = if discount_code.present?
      workshop.price
    else
      workshop.current_price
    end
    
    Success(Payment.new(
      item: registration,
      code: discount_code,
      original_amount: original_amount,
      email: registration.email,
      statement_descriptor: "Smart Workshops Inc",
      description: workshop.name,
      additional_metadata: { confirmation_code: registration.confirmation_code }
    ))
  end

  def verify_price(payment, expected_price)
    if payment.discounted_amount == expected_price
      Success(true)
    else
      Failure(:expected_price_differs)
    end
  end

  def charge_payment(payment, source:)
    if payment.charge!(source: source)
      Success(payment)
    else
      Failure(:payment_charge_failed)
    end
  end
  
  def mark_completed(registration)
    if registration.mark_completed!
      Success(true)
    else
      Failure(:regisration_could_not_mark)
    end
  end

  def mail_registrant(registration)
    RegistrationMailer.with(registration: registration).registered.deliver_later
    Success(registration)
  rescue => error
    # This step is non-critical, a human can send this information if it fails.
    # But do track the error.
    ExceptionTracker.track(error, registration: registration, action: "mail_registrant")
    Success(registration)
  end
end

You can see we don’t really do much heavy lifting directly here. The transaction should be a level above what it’s working with, focused on orchestrating the pieces rather than doing the work. And because transactions themselves return Success or Failure, they can easily be split up or strung together as needed.

This also makes testing easier. Each step can be called independently, and any tests of the transaction itself only need to be concerned with the control flow (those methods already have unit tests, right? 😉). Like any method, keeping steps small and single purpose helps testing, maintenance, and refactoring.

Leveraging a bit of syntactical sugar from RSpec and FactoryBot, we can quickly iterate through cases like this:

RSpec.describe WorkshopRegistrar do
  let(:registrar) { WorkshopRegistrar.new(workshop: workshop, expected_price_cents: workshop.current_price.fractional) }

  describe "#check_availability" do
    let(:subject) { registrar.check_availability(workshop) }

    context "workshop not provided" do
      let(:workshop) { nil }
      it { is_expected.to be_a_failure }
    end

    context "workshop requires external registration" do
      let(:workshop) { build(:workshop, :external_registration) }
      it { is_expected.to be_a_failure }
    end
    
    context "workshop is online registerable" do
      let(:workshop) { build(:workshop, :registerable) }
      it { is_expected.to be_a_success }
    end

    # ...
  end

Now, it’s ready to be used. Our controller only has to do controller-y things, like assembling the parameters and rendering the result.

class RegistrationsController < ApplicationController
  def create
    workshop = Workshop.published.find_by(id: registration_params[:workshop_id])

    registrar = WorkshopRegistrar.new(
      workshop: workshop,
      discount_code: registration_params[:discount_code],
      payment_source: registration_params[:payment_source],
      registrant_info: registration_params[:registrant_info],
      expected_price_cents: registration_params[:expected_price_cents]
    )
    result = registrar.call

    if result.success?
      @registration = result.value!
      render json: @registration.as_api_json
    else
      failure_code = result.failure
      Rails.logger.info "Registration unsuccessful. Code: #{failure_code}"
      render_error code: failure_code, message: I18n.t("registrations.errors.#{failure_code}")
    end
  end
end

Conclusion

The fastest way of fighting complexity is to reduce the possibilities that have to be dealt with. Transactions should represent a specific set of operations, but not necessarily the operations themselves. By setting the bounds and order of those operations, we can clearly express our expectations at each step along the way. Taking a process with many inputs and many possible outcomes and compressing them down to a series of discrete pass/fail steps, we only have to deal with one thing at a time.