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:
- We can more easily communicate what the system is doing to non-technical stakeholders.
- We can explicitly define any requirements we have for any step of the process
- When things fail, we can know where, when, and the exact conditions that caused them.
- We get the additional benefit of having this kind of domain knowledge outside of application concerns like Models or Controllers. This lets us test our implementation directly and independently.
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.