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.
Lessons from Production
After running this pattern across three payment systems, a few things stand out:
- Name services after what they do, not what they are.
Payments::Captureis better thanPaymentCaptureService. The namespace gives you the domain, the class name gives you the action. TheServicesuffix is noise. - Don't reach for a gem. I've tried
interactor,trailblazer, anddry-transaction. They all add indirection without solving a problem that a 20-line base class doesn't already handle. Start simple. Add complexity only when you feel the pain. - Result objects beat exceptions for expected failures. A declined card isn't exceptional — it's a normal business outcome. Reserve exceptions for things that are actually unexpected: network timeouts, serialization bugs, nil where there shouldn't be nil.
- Keep services stateless. No instance variables that persist between calls. No class-level caching. Each
.callis a fresh execution. This makes them safe to use in background jobs, webhooks, and console sessions without worrying about stale state.
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
- Rails Guides: Active Record Basics — understanding what belongs in models vs. service layers
- Ruby Struct Documentation — the foundation for lightweight Result objects
- Stripe API: Capture a Charge — real-world gateway interface that service objects wrap
- RSpec Documentation — testing patterns for service objects with dependency injection
- Martin Fowler: Service Layer Pattern — the architectural pattern behind service objects
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.