Architecture

Rails Service Objects vs dry-transaction

BLUF (Bottom Line Up Front): Standard Ruby Service Objects often devolve into messy, nested if/else statements when handling multiple failure points. Integrating dry-transaction (part of the dry-rb ecosystem) introduces Railway Oriented Programming to functional ruby legacy codebases. It structures business transactions into a clear pipeline of step and map operations, cleanly separating the “happy path” from error handling.

Phase 1: The Limits of Standard Services

When a business process has multiple points of failure (e.g., validation, API call, database write), a standard PORO (Plain Old Ruby Object) becomes difficult to read.

Synthetic Engineering Context: Nested Conditionals

# Standard Service Object
class ProcessPaymentService
  def call(params)
    user = validate_user(params[:user_id])
    return { success: false, error: "Invalid User" } unless user

    payment = charge_card(user, params[:amount])
    return { success: false, error: "Card Declined" } unless payment

    receipt = generate_receipt(payment)
    { success: true, data: receipt }
  end
  # ... private methods omitted
end

This imperative style mixes business logic with control flow. The controller parsing the result must use vague hash checks (result[:success]).

Phase 2: Functional Pipelines with dry-transaction

dry-transaction uses Monads (specifically Success and Failure) to route data through a pipeline. If any step fails, the pipeline halts and returns the failure.

Execution: Building the Transaction

# app/transactions/process_payment.rb
require "dry/transaction"

class ProcessPayment
  include Dry::Transaction

  step :validate_user
  step :charge_card
  map  :generate_receipt # map is used when a step cannot fail

  private

  def validate_user(input)
    user = User.find_by(id: input[:user_id])
    user ? Success(input.merge(user: user)) : Failure(:invalid_user)
  end

  def charge_card(input)
    charge = PaymentGateway.charge(input[:user], input[:amount])
    charge.success? ? Success(input.merge(charge: charge)) : Failure(:card_declined)
  end

  def generate_receipt(input)
    ReceiptGenerator.call(input[:charge])
  end
end

Execution: The Controller Implementation

The controller uses pattern matching to handle the specific Monad returned by the transaction.

# app/controllers/payments_controller.rb
class PaymentsController < ApplicationController
  def create
    ProcessPayment.new.call(payment_params) do |m|
      m.success do |receipt|
        render json: { receipt: receipt }, status: :ok
      end

      m.failure :invalid_user do
        render json: { error: 'User not found' }, status: :not_found
      end

      m.failure :card_declined do
        render json: { error: 'Card was declined' }, status: :unprocessable_entity
      end
    end
  end
end

Phase 3: Next Steps & Risk Mitigation

Adopting dry-rb introduces functional programming concepts that may be alien to junior object-oriented developers. You must establish internal documentation and training before injecting Monads into a legacy Rails monolith, otherwise, it will create friction.

Need Help Stabilizing Your Legacy App? We use advanced architectural patterns like dry-transaction to tame complex, multi-step business logic. Contact USEO to modernize your Rails architecture.

Contact us for a Technical Debt Audit