April 7, 2026 9 min read

Ruby Service Objects for Payment Processing — Patterns That Scale

After watching three Rails codebases buckle under the weight of 2,000-line payment models, I started extracting service objects. Here's the playbook that took our payment code from "nobody wants to touch this" to something the whole team could reason about.

The Problem with Fat Models in Payment Code

I'll be honest — the first payment system I built in Rails was a mess. The Payment model had methods for capturing charges, issuing refunds, handling webhooks, sending receipts, and reconciling with the ledger. It was 1,800 lines long and every PR that touched it felt like defusing a bomb.

The controller wasn't much better. One action method was doing gateway authentication, amount validation, currency conversion, fraud checks, and the actual charge — all in a single create method. Testing it required mocking half the universe.

The core issue: payment logic doesn't belong in models or controllers. Models should handle persistence and associations. Controllers should handle HTTP concerns. Everything in between — the actual business logic — needs its own home. That's where service objects come in.

Concern Fat Model Approach Service Object Approach
Payment capture payment.capture! — buried in 1,800-line model Payments::Capture.call(payment) — isolated, 60 lines
Refund logic Mixed with validations and callbacks Dedicated Payments::Refund with clear inputs/outputs
Error handling Exceptions bubble up unpredictably Result objects with explicit success/failure paths
Testing Requires full model setup, database hits Unit-testable with dependency injection
Composition Chained method calls, implicit ordering Explicit pipelines: authorize → capture → notify
Onboarding "Read the whole model to understand anything" "Read the one file that does the thing"

The Basic Pattern: One Class, One Job

Every service object in our codebase follows the same contract: initialize with dependencies, expose a single .call class method, return a Result. No exceptions for flow control. No side effects hidden in the constructor. Here's the base class we use:

class ApplicationService
  def self.call(...)
    new(...).call
  end

  private

  def success(data = {})
    Result.new(success: true, data: data)
  end

  def failure(error, code: nil)
    Result.new(success: false, error: error, code: code)
  end
end

Result = Struct.new(:success, :data, :error, :code, keyword_init: true) do
  def success? = success
  def failure? = !success
end

Why a class method wrapper? The self.call(...) pattern lets you write Payments::Capture.call(payment) instead of Payments::Capture.new(payment).call. It's a small thing, but it reads better in controllers and makes the service feel like a function. The constructor stays private for dependency injection in tests.

Real Code: Payment Capture

Here's what a production capture service actually looks like. Notice how it handles the gateway response, logs the outcome, and returns a typed result — no exceptions leaking out:

module Payments
  class Capture < ApplicationService
    def initialize(payment, gateway: StripeGateway.new)
      @payment = payment
      @gateway = gateway
    end

    def call
      return failure("Already captured", code: :already_captured) if @payment.captured?
      return failure("Invalid state: #{@payment.status}", code: :invalid_state) unless @payment.authorized?

      response = @gateway.capture(
        charge_id: @payment.gateway_charge_id,
        amount: @payment.amount_cents,
        currency: @payment.currency,
        idempotency_key: "capture_#{@payment.id}"
      )

      if response.success?
        @payment.update!(
          status: :captured,
          captured_at: Time.current,
          gateway_capture_id: response.transaction_id
        )
        success(payment: @payment)
      else
        @payment.update!(status: :capture_failed, failure_reason: response.error)
        failure(response.error, code: :gateway_error)
      end
    rescue Payments::GatewayTimeout => e
      Rails.logger.error("[Capture] Timeout for payment #{@payment.id}: #{e.message}")
      failure("Gateway timeout — safe to retry", code: :timeout)
    end
  end
end

The controller stays thin. It just calls the service and maps the result to an HTTP response:

def create
  result = Payments::Capture.call(payment)

  if result.success?
    render json: PaymentSerializer.new(result.data[:payment]), status: :ok
  else
    render json: { error: result.error, code: result.code }, status: :unprocessable_entity
  end
end

Refunds and Webhook Handling

The same pattern applies everywhere. Here's the refund service — notice how it validates the refund amount and handles partial refunds cleanly:

module Payments
  class Refund < ApplicationService
    def initialize(payment, amount_cents:, reason: nil, gateway: StripeGateway.new)
      @payment = payment
      @amount_cents = amount_cents
      @reason = reason
      @gateway = gateway
    end

    def call
      return failure("Not captured", code: :invalid_state) unless @payment.captured?
      return failure("Amount exceeds captured", code: :invalid_amount) if @amount_cents > @payment.refundable_amount_cents

      response = @gateway.refund(
        charge_id: @payment.gateway_charge_id,
        amount: @amount_cents,
        reason: @reason,
        idempotency_key: "refund_#{@payment.id}_#{@amount_cents}_#{Time.current.to_i}"
      )

      if response.success?
        @payment.refunds.create!(amount_cents: @amount_cents, gateway_refund_id: response.transaction_id)
        @payment.update!(status: fully_refunded? ? :refunded : :partially_refunded)
        success(payment: @payment.reload)
      else
        failure(response.error, code: :gateway_error)
      end
    end

    private

    def fully_refunded?
      @payment.total_refunded_cents + @amount_cents >= @payment.amount_cents
    end
  end
end

Gotcha: idempotency keys for refunds. I learned this the hard way — if you use just the payment ID as the idempotency key for refunds, you can't issue a second partial refund. The gateway thinks it's a duplicate. Always include the amount and a timestamp in the key. We lost two hours debugging this in production before the penny dropped.

Webhook handling follows the same service pattern. The webhook controller dispatches to the right service based on event type:

module Webhooks
  class PaymentCaptured < ApplicationService
    def initialize(event)
      @event = event
    end

    def call
      payment = Payment.find_by(gateway_charge_id: @event.dig("data", "charge_id"))
      return failure("Payment not found", code: :not_found) unless payment
      return success(payment: payment) if payment.captured?  # Idempotent

      payment.update!(
        status: :captured,
        captured_at: Time.parse(@event.dig("data", "captured_at")),
        gateway_capture_id: @event.dig("data", "capture_id")
      )
      success(payment: payment)
    end
  end
end

Composing Services for Complex Flows

This is where service objects really shine. A checkout flow isn't one operation — it's a pipeline: authorize the card, capture the funds, create the order record, and send the confirmation. Each step is its own service. The orchestrator composes them and short-circuits on failure:

module Checkout
  class Process < ApplicationService
    def initialize(cart, payment_method:)
      @cart = cart
      @payment_method = payment_method
    end

    def call
      authorize_result = Payments::Authorize.call(@payment_method, amount: @cart.total_cents)
      return authorize_result if authorize_result.failure?

      payment = authorize_result.data[:payment]

      capture_result = Payments::Capture.call(payment)
      return capture_result if capture_result.failure?

      order = Orders::Create.call(@cart, payment: payment)
      return order if order.failure?

      # Notification is non-critical — log but don't fail the checkout
      notify_result = Notifications::OrderConfirmation.call(order.data[:order])
      Rails.logger.warn("[Checkout] Notification failed: #{notify_result.error}") if notify_result.failure?

      success(order: order.data[:order], payment: payment)
    end
  end
end

Key design decision: Notice that the notification step doesn't short-circuit the checkout. If the email fails, the customer still gets their order. I've seen teams treat every step as equally critical and end up rolling back successful payments because Sendgrid was having a bad day. Classify your steps: critical (authorize, capture) vs. best-effort (notify, analytics).

Testing Service Objects

This is the real payoff. Because each service takes its dependencies through the constructor, testing is straightforward. No need to hit the database for unit tests. No need to stub global state. Just inject a fake gateway:

RSpec.describe Payments::Capture do
  let(:gateway) { instance_double(StripeGateway) }
  let(:payment) { build_stubbed(:payment, status: :authorized, amount_cents: 5000) }

  context "when capture succeeds" do
    before do
      allow(gateway).to receive(:capture).and_return(
        OpenStruct.new(success?: true, transaction_id: "cap_abc123")
      )
      allow(payment).to receive(:update!)
    end

    it "returns success with the payment" do
      result = described_class.call(payment, gateway: gateway)
      expect(result).to be_success
      expect(result.data[:payment]).to eq(payment)
    end
  end

  context "when payment is already captured" do
    let(:payment) { build_stubbed(:payment, status: :captured) }

    it "returns failure without hitting the gateway" do
      result = described_class.call(payment, gateway: gateway)
      expect(result).to be_failure
      expect(result.code).to eq(:already_captured)
      expect(gateway).not_to have_received(:capture)
    end
  end
end

The second test is the important one. It verifies that the guard clause prevents a gateway call entirely. No HTTP stubs, no VCR cassettes, no flaky network tests. The service boundary makes this trivial.

73%
Reduction in model size
4x
Faster test suite
< 80
Lines per service avg.

Lessons from Production

After running this pattern across three payment systems, a few things stand out:

On file organization: We keep services under app/services/ with one directory per domain: payments/, webhooks/, checkout/, notifications/. Rails autoloading picks them up automatically. No initializer hacks needed.

The pattern isn't revolutionary. It's just disciplined separation of concerns applied to the messiest part of most Rails apps. But the cumulative effect — smaller files, faster tests, clearer ownership, easier onboarding — compounds over time. After six months, the team stopped dreading payment PRs. That alone was worth the refactor.

References

Disclaimer: This article reflects the author's personal experience and opinions. Code examples are simplified for clarity and may not represent production-ready implementations. Product names, logos, and brands are property of their respective owners. Always verify patterns against official documentation for your specific Ruby and Rails versions.