In Ruby, everything is an object – and that includes classes themselves. A class like Payment is actually an instance of Class, meaning it can have its own methods, attributes, and behavior just like any other object. Because every object in Ruby has a special hidden class called a singleton class (or eigenclass), Ruby uses this mechanism to store methods that belong specifically to the class object, rather than to its instances.
When developers open a class’s eigenclass using class << self, they are directly modifying this singleton class, gaining access to unique meta-programming abilities not available through normal def self.method definitions. This approach lets you define private class methods, include modules into a class’s singleton behavior, override internal methods like new or allocate, group multiple class methods cleanly, and create flexible DSLs. Ultimately, opening the eigenclass enables fine-grained control over a Ruby class’s meta-level behavior, a powerful tool when writing expressive, maintainable frameworks and advanced Ruby code.
? Why Ruby Needs a Singleton Class for the Class Object
Ruby separates instance behavior from class behavior:
Instance methods live in the class (Payment)
Class methods live in the classβs singleton class (Payment.singleton_class)
This means:
def self.process
end
and:
class << self
def process
end
end
are doing the same thing – defining a method on the class’s eigenclass.
But class << self gives you more control.
What You Can Do With class << self That You Can’t Do With def self.method
1. Group multiple class methods without repeating self.
class << self
def load_data; end
def generate_stats; end
def export; end
end
Cleaner and more readable when many class methods exist.
2. Make class methods private
This is a BIG reason to open the eigenclass.
class << self
private
def secret_config
"hidden!"
end
end
With def self.secret_config, you cannot make it private.
3. Add modules to the class’s singleton behavior
This modifies the class itself, not its instances.
class << self
include SomeClassMethods
end
Equivalent to:
extend SomeClassMethods
But allows mixing visibility (public/private/protected).
class << self
def allocate
puts "custom allocation"
super
end
end
This cannot be done correctly with def self.allocate.
5. Implement DSLs and class-level configuration
Rails, RSpec, Sidekiq, and ActiveRecord all use this.
class << self
attr_accessor :config
end
Now the class has its own state:
Payment.config = { mode: :test }
Understanding the Bigger Picture β Ruby’s Meta-Object Model
Ruby treats classes as objects, and every object has:
A class where instance methods live
A singleton class where methods specific to that object live
So:
Instance methods β stored in the class (Payment)
Class methods β stored in the singleton class (Payment.singleton_class)
Opening the eigenclass means directly modifying that second structure.
When Should You Use class << self?
Use class << self when:
β You have several class methods to define β You need private/protected class methods β You want to include or extend modules into the class’s behavior β You need to override class-level built-ins (new, allocate) β You’re implementing DSLs or framework-level code
Use def self.method when:
β You’re defining one or two simple class methods β You want the simplest, most readable syntax
π― Final Takeaway
Opening the singleton class at the class level isn’t just stylistic β it unlocks capabilities that normal class method definitions cannot provide. It’s a powerful tool for clean organization, encapsulation, and meta-programming. Frameworks like Rails rely heavily on this pattern because it allows precise control over how classes behave at a meta-level.
Understanding this distinction helps you write cleaner, more flexible Ruby code β and it deepens your appreciation of Ruby’s elegant object model.
In the next article, we can check more examples in detail.
How we transformed fragmented payment tracking into a comprehensive admin interface that gives business teams complete visibility into every payment attempt.
Payment systems are mission-critical components that directly impact revenue and customer trust, making comprehensive testing absolutely essential. A robust testing strategy must cover three distinct layers: isolated unit tests that verify individual payment service behaviours, integration tests that ensure proper webhook handling and external API interactions, and feature tests that validate the complete user experience from payment initiation to admin dashboard visibility. This multi-layered approach ensures that payment failures are caught early in development, edge cases are properly handled, and business stakeholders can rely on accurate payment data for decision-making.
Unit Testing Payment Service
Unit tests form the foundation of payment system reliability by isolating and verifying the core payment processing logic without external dependencies, ensuring that different payment scenarios (success, card declined, network errors) are handled correctly and consistently.
# spec/services/payment_service_spec.rb
RSpec.describe PaymentService do
let(:customer) { create(:customer, :with_payment_method) }
describe '.charge' do
context 'successful payment' do
before do
allow(Stripe::PaymentIntent).to receive(:create)
.and_return(double(status: 'succeeded', id: 'pi_success_123', to_hash: {}))
end
it 'creates successful transaction' do
transaction = PaymentService.charge(2999, 'Test charge', customer)
expect(transaction).to be_persisted
expect(transaction.success?).to be true
expect(transaction.amount_cents).to eq(2999)
end
it 'creates payment record association' do
expect {
transaction = PaymentService.charge(2999, 'Test charge', customer)
customer.payment_records.create!(transaction: transaction)
}.to change { customer.payment_records.count }.by(1)
end
end
context 'card declined' do
let(:declined_error) do
Stripe::CardError.new('Card declined', 'card_declined',
json_body: { 'error' => { 'code' => 'card_declined',
'message' => 'Your card was declined.' } })
end
before do
allow(Stripe::PaymentIntent).to receive(:create).and_raise(declined_error)
end
it 'creates failed transaction with error details' do
transaction = PaymentService.charge(2999, 'Test charge', customer)
expect(transaction).to be_persisted
expect(transaction.success?).to be false
expect(transaction.error_code).to eq('card_declined')
expect(transaction.error_message).to eq('Your card was declined.')
end
end
context 'network error' do
before do
allow(Stripe::PaymentIntent).to receive(:create)
.and_raise(Stripe::APIConnectionError.new('Network error'))
end
it 'creates failed transaction with network error' do
transaction = PaymentService.charge(2999, 'Test charge', customer)
expect(transaction).to be_persisted
expect(transaction.success?).to be false
expect(transaction.error_message).to eq('Network error')
end
end
context 'zero amount' do
it 'creates successful zero-amount transaction' do
transaction = PaymentService.charge(0, 'Free item', customer)
expect(transaction).to be_persisted
expect(transaction.success?).to be true
expect(transaction.amount_cents).to eq(0)
end
end
end
end
Integration Testing with Webhooks
Integration tests validate the critical communication pathways between your application and Stripe’s web-hook system, ensuring that payment status updates are properly received, parsed, and stored even when network conditions or timing issues occur.
# spec/controllers/webhooks/stripe_controller_spec.rb
RSpec.describe Webhooks::StripeController do
let(:customer) { create(:customer) }
describe 'payment_intent.payment_failed webhook' do
let(:webhook_payload) do
{
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'pi_failed_123',
amount: 2999,
currency: 'usd',
customer: customer.stripe_customer_id,
last_payment_error: {
code: 'card_declined',
message: 'Your card was declined.'
}
}
}
}
end
it 'creates failed transaction record' do
expect {
post :handle_webhook, params: webhook_payload
}.to change { Transaction.count }.by(1)
transaction = Transaction.last
expect(transaction.success?).to be false
expect(transaction.error_code).to eq('card_declined')
end
it 'associates transaction with customer' do
expect {
post :handle_webhook, params: webhook_payload
}.to change { customer.payment_records.count }.by(1)
end
end
end
Feature Testing Admin Interface
Feature tests provide end-to-end validation of the admin dashboard experience, verifying that business users can access complete payment information, understand transaction statuses at a glance, and take appropriate actions based on payment data.
# spec/features/admin/customer_payments_spec.rb
RSpec.describe 'Customer Payment Admin', type: :feature do
let(:admin_user) { create(:admin_user) }
let(:customer) { create(:customer) }
before { login_as(admin_user) }
scenario 'viewing customer payment history' do
# Create test transactions
successful_transaction = create(:transaction, :successful, amount_cents: 2999)
failed_transaction = create(:transaction, :failed, amount_cents: 4999)
customer.payment_records.create!(transaction: successful_transaction)
customer.payment_records.create!(transaction: failed_transaction)
visit admin_customer_path(customer)
within('#payment-history') do
expect(page).to have_content('$29.99')
expect(page).to have_content('SUCCESS')
expect(page).to have_content('$49.99')
expect(page).to have_content('FAILED')
expect(page).to have_link('View Stripe Dashboard')
expect(page).to have_link('Retry Payment')
end
end
end
Advanced Implementation Patterns
Beyond basic payment processing, production payment systems require sophisticated patterns to handle complex business scenarios like multi-payment methods per customer, subscription lifecycle events, and intelligent error recovery. These advanced patterns separate robust enterprise systems from simple payment integrations by providing the flexibility and resilience needed for real-world business operations. Implementing these patterns proactively prevents technical debt and ensures your payment system can evolve with changing business requirements.
1. Payment Method Management System
A comprehensive payment method management system allows customers to store multiple payment methods securely while giving businesses the flexibility to handle payment method updates, expirations, and customer preferences without disrupting service continuity.
# app/services/payment_method_manager.rb
class PaymentMethodManager
def initialize(customer)
@customer = customer
end
def add_payment_method(payment_method_id)
begin
# Attach to customer
Stripe::PaymentMethod.attach(payment_method_id, {
customer: @customer.stripe_customer_id
})
# Store locally
@customer.customer_payment_methods.create!(
stripe_payment_method_id: payment_method_id,
is_default: @customer.customer_payment_methods.empty?
)
{ success: true }
rescue Stripe::InvalidRequestError => e
{ success: false, error: e.message }
end
end
def set_default_payment_method(payment_method_id)
# Update Stripe customer
Stripe::Customer.update(@customer.stripe_customer_id, {
invoice_settings: { default_payment_method: payment_method_id }
})
# Update local records
@customer.customer_payment_methods.update_all(is_default: false)
@customer.customer_payment_methods
.find_by(stripe_payment_method_id: payment_method_id)
&.update!(is_default: true)
end
def remove_payment_method(payment_method_id)
# Detach from Stripe
Stripe::PaymentMethod.detach(payment_method_id)
# Remove local record
@customer.customer_payment_methods
.find_by(stripe_payment_method_id: payment_method_id)
&.destroy!
end
end
2. Subscription Lifecycle Management
Subscription lifecycle management encompasses the complete journey from trial creation through renewal, pause, and cancellation, ensuring that billing events are properly tracked and business logic is consistently applied across all subscription state changes.
# app/services/subscription_manager.rb
class SubscriptionManager
def initialize(customer)
@customer = customer
end
def create_subscription(price_id, trial_days = nil)
subscription_params = {
customer: @customer.stripe_customer_id,
items: [{ price: price_id }],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
}
subscription_params[:trial_period_days] = trial_days if trial_days
stripe_subscription = Stripe::Subscription.create(subscription_params)
# Create local subscription record
subscription = @customer.subscriptions.create!(
stripe_subscription_id: stripe_subscription.id,
status: stripe_subscription.status,
current_period_start: Time.at(stripe_subscription.current_period_start),
current_period_end: Time.at(stripe_subscription.current_period_end),
trial_end: stripe_subscription.trial_end ? Time.at(stripe_subscription.trial_end) : nil
)
# Track the creation attempt
if stripe_subscription.latest_invoice.payment_intent
track_subscription_payment(stripe_subscription, subscription)
end
subscription
end
private
def track_subscription_payment(stripe_subscription, local_subscription)
payment_intent = stripe_subscription.latest_invoice.payment_intent
transaction = Transaction.create!(
amount_cents: payment_intent.amount,
success: payment_intent.status == 'succeeded',
stripe_data: payment_intent.to_hash,
stripe_payment_id: payment_intent.id,
transaction_type: 'subscription_payment'
)
local_subscription.payment_records.create!(transaction: transaction)
end
end
3. Comprehensive Error Handling and Notifications
Advanced error handling goes beyond simple retry logic to include intelligent categorization of payment failures, automated customer communication workflows, and escalation procedures that help recover revenue while maintaining positive customer relationships.
# app/jobs/payment_failure_handler_job.rb
class PaymentFailureHandlerJob < ApplicationJob
def perform(transaction_id)
transaction = Transaction.find(transaction_id)
return if transaction.success?
# Find associated customer
customer = find_customer_for_transaction(transaction)
return unless customer
case transaction.error_code
when 'card_declined', 'insufficient_funds'
handle_declined_card(customer, transaction)
when 'expired_card'
handle_expired_card(customer, transaction)
when 'authentication_required'
handle_3ds_required(customer, transaction)
else
handle_generic_failure(customer, transaction)
end
end
private
def handle_declined_card(customer, transaction)
# Send customer notification
PaymentFailureMailer.card_declined(customer, transaction).deliver_now
# Update customer status
customer.update!(payment_status: 'payment_failed', last_payment_failure_at: Time.current)
# Schedule retry in 3 days
PaymentRetryJob.set(wait: 3.days).perform_later(customer.id, transaction.id)
end
def handle_expired_card(customer, transaction)
PaymentFailureMailer.card_expired(customer, transaction).deliver_now
customer.update!(payment_status: 'card_expired')
end
def find_customer_for_transaction(transaction)
payment_record = PaymentRecord.find_by(transaction: transaction)
return nil unless payment_record&.payable_type == 'Customer'
payment_record.payable
end
end
Business Intelligence and Reporting
Raw payment data becomes truly valuable when transformed into actionable business insights that drive strategic decisions and operational improvements. Business intelligence for payment systems encompasses both real-time monitoring capabilities that help identify and resolve issues quickly, and analytical reporting that reveals patterns in customer behaviour, payment success rates, and revenue optimization opportunities. These capabilities transform payment systems from cost centers into strategic business assets that actively contribute to growth and customer satisfaction.
1. Payment Analytics Dashboard
A comprehensive analytics dashboard transforms scattered payment data into clear, actionable insights that help business teams identify trends, optimize conversion rates, and proactively address payment issues before they impact revenue or customer experience.
Automated payment recovery systems intelligently retry failed payments based on error type and customer history, implementing business rules that maximize revenue recovery while respecting customer preferences and avoiding negative experiences that could damage relationships.
# app/services/payment_recovery_service.rb
class PaymentRecoveryService
def self.process_failed_payments
# Find customers with recent payment failures
failed_payment_records = PaymentRecord.joins(:transaction)
.where(transactions: { success: false })
.where(created_at: 1.day.ago..Time.current)
.includes(:payable, :transaction)
failed_payment_records.each do |payment_record|
next unless payment_record.payable_type == 'Customer'
customer = payment_record.payable
retry_payment_for_customer(customer, payment_record.transaction)
end
end
private
def self.retry_payment_for_customer(customer, original_transaction)
# Only retry certain error types
return unless retryable_error?(original_transaction.error_code)
# Don't retry if customer has been marked as do-not-retry
return if customer.payment_retry_disabled?
# Attempt payment with same amount
new_transaction = PaymentService.charge(
original_transaction.amount_cents,
"Retry: #{original_transaction.stripe_data['description']}",
customer
)
customer.payment_records.create!(
transaction: new_transaction,
retry_of_transaction_id: original_transaction.id
)
if new_transaction.success?
PaymentRecoveryMailer.payment_recovered(customer, new_transaction).deliver_later
else
# Mark for manual review after multiple failures
customer.update!(requires_manual_payment_review: true)
end
end
def self.retryable_error?(error_code)
%w[api_connection_error rate_limit_error temporary_failure].include?(error_code)
end
end
Conclusion
The key principles to remember:
Track Everything: Every payment attempt, successful or failed, tells part of your business story
Design for Non-Technical Users: Transform complex payment data into actionable business intelligence
Plan for Scale: Use caching, efficient queries, and smart data structures
Test Thoroughly: Payment systems require comprehensive testing of both happy and sad paths
Monitor Continuously: Build dashboards and alerts that help you catch issues before they impact customers
Ready to implement robust payment tracking in your Rails application? Start with the foundational data models, then build up your service layer and admin interfaces systematically. Remember: comprehensive payment visibility is not just a technical requirementβit’s a business advantage.
How we transformed fragmented payment tracking into a comprehensive admin interface that gives business teams complete visibility into every payment attempt.
Introduction: The Payment Visibility Challenge
When building SaaS applications with complex payment flows, one of the most critical yet overlooked aspects is payment visibility for non-technical teams. While Stripe provides excellent APIs and webhooks, the challenge lies in making this data accessible and actionable for marketing teams, customer success, and business operations.
In this post, we’ll walk through a comprehensive implementation for building robust Stripe payment tracking in a Ruby on Rails application, transforming scattered payment data into a unified admin dashboard that provides complete visibility into every payment attemptβsuccessful or failed.
The Problem: Incomplete Payment Tracking
Common Issues in Production Systems
Many Rails applications suffer from similar payment tracking gaps:
Selective Tracking: Only successful payments are recorded
Fragmented Data: Payment attempts scattered across different models
Poor Error Visibility: Failed payments disappear into the void
Limited Business Intelligence: No way to analyze payment patterns
Typical Implementation Problems
# Anti-pattern 1: Only tracking successes
def process_subscription_payment(user, amount)
payment = stripe_service.charge(user.stripe_customer_id, amount)
if payment.succeeded?
user.payment_records.create!(
amount: amount,
status: 'success',
stripe_payment_id: payment.id
)
# β Failed payments are lost forever
end
payment
end
# Anti-pattern 2: Missing associations
def charge_customer_wallet(customer, amount, description)
charge = create_stripe_charge(customer, amount, description)
# β Charge created but not linked to customer
Transaction.create!(
amount: amount,
success: charge.succeeded?,
stripe_data: charge.to_hash
)
charge
end
Architecture Deep Dive: Rails + Stripe Integration Patterns
Successful Stripe integration in Rails applications requires more than just API callsβit demands a well-architected system that handles the complexity of payment processing while maintaining clean, maintainable code. The foundation of this architecture lies in polymorphic associations that allow payments to be linked to various business entities (customers, orders, subscriptions), combined with service objects that abstract Stripe’s API complexity and provide consistent error handling. This architectural approach ensures that payment logic remains decoupled from business models while providing the flexibility to support diverse payment scenarios across your application.
The Foundation: Polymorphic Payment Records
Our solution uses a polymorphic association pattern that allows payments to be tracked across different business entities:
# app/models/payment_record.rb
class PaymentRecord < ApplicationRecord
belongs_to :transaction
belongs_to :payable, polymorphic: true # Customer, Order, Subscription, etc.
end
# app/models/customer.rb
class Customer < ApplicationRecord
has_many :payment_records, as: :payable
has_many :transactions, through: :payment_records
def charge(amount_cents, description = 'Payment')
payment_service.charge(amount_cents, description, self)
end
end
# app/models/transaction.rb
class Transaction < ApplicationRecord
# amount_cents: integer
# success: boolean
# stripe_data: jsonb (full Stripe response)
# stripe_payment_id: string
# error_code: string
# error_message: text
scope :successful, -> { where(success: true) }
scope :failed, -> { where(success: false) }
def declined?
!success && stripe_data.dig('error', 'code') == 'card_declined'
end
def error_type
stripe_data.dig('error', 'type')
end
end
The Payment Service: Stripe API Abstraction
A centralized payment service acts as the bridge between your Rails application and Stripe’s API, encapsulating all the complexity of error handling, response processing, and data transformation while providing a clean, consistent interface for the rest of your application to interact with.
# app/services/payment_service.rb
class PaymentService
class << self
def charge(amount_cents, description, customer)
return create_zero_transaction if amount_cents <= 0
success = false
stripe_response = nil
begin
stripe_response = Stripe::PaymentIntent.create({
amount: amount_cents,
currency: 'usd',
description: description,
customer: customer.stripe_customer_id,
payment_method: customer.default_payment_method_id,
off_session: true,
confirm: true
})
success = (stripe_response.status == 'succeeded')
rescue Stripe::CardError => e
# Declined cards, insufficient funds
stripe_response = { error: e.json_body['error'] }
rescue Stripe::RateLimitError => e
# Too many requests
stripe_response = { error: { message: 'Rate limit exceeded', type: 'rate_limit' } }
rescue Stripe::InvalidRequestError => e
# Bad parameters
stripe_response = { error: e.json_body['error'] }
rescue Stripe::AuthenticationError => e
# Bad API key
stripe_response = { error: { message: 'Authentication failed', type: 'authentication' } }
rescue Stripe::APIConnectionError => e
# Network issues
stripe_response = { error: { message: 'Network error', type: 'api_connection' } }
rescue StandardError => e
# Catch-all for unexpected errors
stripe_response = {
error: {
message: e.message,
type: 'unexpected_error',
backtrace: Rails.env.development? ? e.backtrace : nil
}
}
end
# Always create transaction record
transaction = Transaction.create!(
amount_cents: amount_cents,
success: success,
stripe_data: stripe_response.try(:to_hash) || stripe_response,
stripe_payment_id: stripe_response.try(:id),
error_code: success ? nil : extract_error_code(stripe_response),
error_message: success ? nil : extract_error_message(stripe_response)
)
transaction
end
private
def create_zero_transaction
Transaction.create!(
amount_cents: 0,
success: true,
stripe_data: { type: 'zero_amount' }
)
end
def extract_error_code(response)
response.dig('error', 'code') || response.dig('error', 'type')
end
def extract_error_message(response)
response.dig('error', 'message') || 'Unknown error occurred'
end
end
end
Payment Flow Implementation Patterns
Different business scenarios require distinct payment processing patterns, each with specific requirements for timing, error handling, and customer communication, making it essential to implement proven patterns that can be reused and maintained across various payment contexts.
Pattern 1: Subscription Billing
Subscription billing patterns handle the complexities of recurring payments, including trial periods, billing cycles, proration calculations, and the critical requirement to track both successful renewals and failed payment attempts that could lead to service disruption.
# app/services/subscription_billing_service.rb
class SubscriptionBillingService
def initialize(customer, subscription_plan)
@customer = customer
@subscription_plan = subscription_plan
end
def process_monthly_billing
transaction = PaymentService.charge(
@subscription_plan.price_cents,
"Monthly subscription - #{@subscription_plan.name}",
@customer
)
# Always create payment record (success OR failure)
@customer.payment_records.create!(transaction: transaction)
if transaction.success?
extend_subscription
send_receipt_email
else
handle_payment_failure(transaction)
end
transaction
end
private
def handle_payment_failure(transaction)
# Retry logic, notifications, etc.
PaymentFailureNotificationJob.perform_later(@customer.id, transaction.id)
case transaction.error_code
when 'card_declined'
@customer.update!(payment_status: 'declined')
when 'insufficient_funds'
@customer.update!(payment_status: 'insufficient_funds')
else
@customer.update!(payment_status: 'payment_failed')
end
end
end
Pattern 2: E-commerce Order Processing
E-commerce payment patterns focus on immediate transaction processing with tight integration to inventory management, order fulfillment workflows, and the need for real-time payment confirmation before product delivery or service activation.
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :customer
has_many :payment_records, as: :payable
has_many :transactions, through: :payment_records
def process_payment!
transaction = PaymentService.charge(
total_amount_cents,
"Order ##{order_number}",
customer
)
payment_records.create!(transaction: transaction)
if transaction.success?
update!(status: 'paid', paid_at: Time.current)
fulfill_order
else
update!(status: 'payment_failed')
cancel_inventory_hold
end
transaction
end
end
Pattern 3: Digital Product Purchases
Digital product purchase patterns emphasize instant delivery capabilities, handling payment failures gracefully without impacting customer experience, and managing scenarios where payment processing occurs outside the main application flow through webhooks and payment intents.
# app/services/digital_product_purchase_service.rb
class DigitalProductPurchaseService
def self.process_failed_purchase(purchase_params)
stripe_payment = Stripe::PaymentIntent.retrieve(purchase_params[:payment_intent_id])
transaction = Transaction.create!(
amount_cents: purchase_params[:amount_cents],
success: false,
stripe_data: stripe_payment.to_hash,
stripe_payment_id: stripe_payment.id,
error_code: stripe_payment.last_payment_error&.code,
error_message: stripe_payment.last_payment_error&.message
)
# Link to customer if email matches existing user
if purchase_params[:customer_email].present?
customer = Customer.find_by(email: purchase_params[:customer_email])
customer.payment_records.create!(transaction: transaction) if customer
end
transaction
end
end
Building the Admin Dashboard: From Data to Insights
Transforming raw payment data into actionable business intelligence requires careful consideration of both data presentation and system performance. Admin dashboards must balance comprehensive information display with fast load times, while making complex payment details understandable to non-technical business users. The key is creating presenter objects that encapsulate formatting logic, implementing smart caching strategies to handle large datasets efficiently, and designing interfaces that highlight critical information while providing deep-dive capabilities for detailed analysis.
The Challenge: Making Complex Data Accessible
Raw payment data needs transformation for business users. We need to convert this:
A dedicated presenter class encapsulates the complex logic needed to transform raw Stripe API responses into business-friendly display formats, centralizing formatting decisions and providing a clean separation between data processing and view rendering concerns.
# app/presenters/payment_details_presenter.rb
class PaymentDetailsPresenter
def initialize(transaction, view_context)
@transaction = transaction
@view = view_context
end
def status_badge
if @transaction.success?
@view.content_tag(:span, 'SUCCESS',
class: 'badge badge-success')
else
@view.content_tag(:span, 'FAILED',
class: 'badge badge-danger')
end
end
def stripe_dashboard_link
return 'N/A' unless @transaction.stripe_payment_id
@view.link_to(
@view.truncate(@transaction.stripe_payment_id, length: 18),
"https://dashboard.stripe.com/payments/#{@transaction.stripe_payment_id}",
target: '_blank',
class: 'btn btn-sm btn-outline-primary'
)
end
def formatted_amount
@view.number_to_currency(@transaction.amount_cents / 100.0)
end
def error_summary
return 'N/A' if @transaction.success?
error_type = @transaction.error_code&.humanize || 'Unknown'
"#{error_type}: #{@transaction.error_message}"
end
def retry_action_link
return '' if @transaction.success?
@view.link_to('Retry Payment',
@view.retry_payment_path(@transaction),
method: :post,
class: 'btn btn-sm btn-warning',
confirm: 'Are you sure you want to retry this payment?')
end
def documentation_link
return '' if @transaction.success? || @transaction.stripe_data.dig('error', 'doc_url').blank?
@view.link_to('View Docs',
@transaction.stripe_data.dig('error', 'doc_url'),
target: '_blank',
class: 'btn btn-sm btn-info')
end
def receipt_link
return '' unless @transaction.success? &&
@transaction.stripe_data.dig('charges', 'data', 0, 'receipt_url')
@view.link_to('Receipt',
@transaction.stripe_data.dig('charges', 'data', 0, 'receipt_url'),
target: '_blank',
class: 'btn btn-sm btn-secondary')
end
end
Performance Optimization: Caching Strategy
Payment dashboards can quickly become performance bottlenecks as transaction volumes grow, making intelligent caching strategies essential for maintaining responsive user experiences while minimizing expensive API calls and database queries that format payment details.
# app/helpers/admin/payments_helper.rb
module Admin::PaymentsHelper
def cached_payment_details(transaction)
Rails.cache.fetch("payment_details_#{transaction.id}_#{transaction.updated_at.to_i}",
expires_in: 1.hour) do
presenter = PaymentDetailsPresenter.new(transaction, self)
{
status: presenter.status_badge,
amount: presenter.formatted_amount,
stripe_link: presenter.stripe_dashboard_link,
error_summary: presenter.error_summary,
retry_link: presenter.retry_action_link,
docs_link: presenter.documentation_link,
receipt_link: presenter.receipt_link,
created_at: transaction.created_at.strftime('%m/%d/%Y %I:%M %p')
}
end
end
def payment_details_for_display(transaction)
@payment_cache ||= {}
@payment_cache[transaction.id] ||= cached_payment_details(transaction)
end
end
Admin Interface Implementation
Effective admin interface implementation balances information density with usability, providing business users with immediate access to critical payment insights while offering detailed drill-down capabilities that support both operational decision-making and customer support scenarios.
# app/admin/customers.rb (using ActiveAdmin)
ActiveAdmin.register Customer do
show do |customer|
panel "Payment History" do
if customer.payment_records.any?
table_for customer.payment_records.includes(:transaction)
.order(created_at: :desc).limit(50) do
column('Amount') { |pr| payment_details_for_display(pr.transaction)[:amount] }
column('Status') { |pr| payment_details_for_display(pr.transaction)[:status].html_safe }
column('Date') { |pr| payment_details_for_display(pr.transaction)[:created_at] }
column('Stripe ID') { |pr| payment_details_for_display(pr.transaction)[:stripe_link].html_safe }
column('Error Details') { |pr| payment_details_for_display(pr.transaction)[:error_summary] }
column('Actions') do |pr|
details = payment_details_for_display(pr.transaction)
[details[:retry_link], details[:docs_link], details[:receipt_link]]
.select(&:present?).join(' ').html_safe
end
end
else
div "No payment history found.", class: 'text-muted'
end
end
end
end
Stripe API Integration Best Practices
Production Stripe integrations must handle the realities of distributed systems: network failures, rate limits, duplicate requests, and security concerns. Best practices go far beyond basic API usage to include comprehensive webhook verification, idempotency handling that prevents duplicate charges, intelligent retry mechanisms that respect Stripe’s rate limits, and support for complex multi-tenant scenarios through Stripe Connect. These practices ensure your integration remains reliable and secure as your business scales from startup to enterprise levels.
1. Webhook Security and Verification
Webhook security forms the foundation of reliable Stripe integration, ensuring that payment status updates genuinely originate from Stripe and haven’t been tampered with during transmission, protecting your application from malicious actors attempting to manipulate payment states.
# app/controllers/webhooks/stripe_controller.rb
class Webhooks::StripeController < ApplicationController
protect_from_forgery with: :null_session
before_action :verify_webhook_signature
def handle_webhook
event_type = params[:type]
event_data = params[:data][:object]
case event_type
when 'payment_intent.succeeded'
handle_successful_payment(event_data)
when 'payment_intent.payment_failed'
handle_failed_payment(event_data)
when 'customer.subscription.created'
handle_subscription_created(event_data)
when 'invoice.payment_failed'
handle_invoice_payment_failed(event_data)
end
head :ok
end
private
def verify_webhook_signature
payload = request.body.read
signature_header = request.env['HTTP_STRIPE_SIGNATURE']
endpoint_secret = Rails.application.credentials.stripe[:webhook_secret]
begin
Stripe::Webhook.construct_event(payload, signature_header, endpoint_secret)
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
Rails.logger.error "Webhook signature verification failed: #{e.message}"
head :bad_request and return
end
end
def handle_failed_payment(payment_intent)
transaction = Transaction.find_or_create_by(stripe_payment_id: payment_intent['id']) do |t|
t.amount_cents = payment_intent['amount']
t.success = false
t.stripe_data = payment_intent
t.error_code = payment_intent.dig('last_payment_error', 'code')
t.error_message = payment_intent.dig('last_payment_error', 'message')
end
# Find and associate with customer
if payment_intent['customer']
customer = Customer.find_by(stripe_customer_id: payment_intent['customer'])
customer.payment_records.find_or_create_by(transaction: transaction) if customer
end
# Trigger business logic
PaymentFailureHandlerJob.perform_later(transaction.id)
end
end
2. Idempotency for Critical Operations
Idempotency mechanisms prevent the nightmare scenario of duplicate charges caused by network timeouts, user double-clicks, or retry logic, ensuring that each payment intent is processed exactly once regardless of how many times the operation is attempted.
# app/services/idempotent_payment_service.rb
class IdempotentPaymentService
def self.charge_with_idempotency(customer, amount_cents, description, idempotency_key)
# Check if we already processed this request
existing_transaction = Transaction.find_by(idempotency_key: idempotency_key)
return existing_transaction if existing_transaction
begin
payment_intent = Stripe::PaymentIntent.create({
amount: amount_cents,
currency: 'usd',
description: description,
customer: customer.stripe_customer_id,
idempotency_key: idempotency_key # Stripe-level idempotency
})
Transaction.create!(
amount_cents: amount_cents,
success: payment_intent.status == 'succeeded',
stripe_data: payment_intent.to_hash,
stripe_payment_id: payment_intent.id,
idempotency_key: idempotency_key # Application-level idempotency
)
rescue Stripe::IdempotencyError => e
# Stripe detected duplicate request
Rails.logger.warn "Stripe idempotency conflict: #{e.message}"
Transaction.find_by(idempotency_key: idempotency_key)
end
end
end
3. Rate Limiting and Retry Logic
Intelligent rate limiting and retry strategies ensure your application gracefully handles Stripe’s API limits while maintaining service availability, implementing exponential backoff and circuit breaker patterns that prevent cascading failures during high-traffic periods.
# app/services/resilient_payment_service.rb
class ResilientPaymentService
MAX_RETRIES = 3
RETRY_DELAYS = [1.second, 2.seconds, 5.seconds].freeze
def self.charge_with_retries(customer, amount_cents, description)
attempt = 0
begin
attempt += 1
PaymentService.charge(amount_cents, description, customer)
rescue Stripe::RateLimitError => e
if attempt <= MAX_RETRIES
delay = RETRY_DELAYS[attempt - 1] || 5.seconds
Rails.logger.warn "Rate limited, retrying in #{delay}s (attempt #{attempt})"
sleep(delay)
retry
else
# Create failed transaction for rate limit exceeded
Transaction.create!(
amount_cents: amount_cents,
success: false,
stripe_data: { error: { type: 'rate_limit', message: 'Rate limit exceeded after retries' } },
error_code: 'rate_limit_exceeded',
error_message: 'Payment failed due to rate limiting'
)
end
rescue Stripe::APIConnectionError => e
if attempt <= MAX_RETRIES
delay = RETRY_DELAYS[attempt - 1] || 5.seconds
Rails.logger.warn "Network error, retrying in #{delay}s (attempt #{attempt})"
sleep(delay)
retry
else
raise e
end
end
end
end
4. Multi-tenant Stripe Connect Integration
Stripe Connect integration enables platform businesses to process payments on behalf of multiple vendors or service providers, requiring careful handling of split payments, fee calculations, and account permissions while maintaining compliance with financial regulations and platform policies.
# app/services/connect_payment_service.rb
class ConnectPaymentService
def self.charge_connected_account(platform_customer, connected_account_id, amount_cents, description)
begin
payment_intent = Stripe::PaymentIntent.create({
amount: amount_cents,
currency: 'usd',
description: description,
customer: platform_customer.stripe_customer_id,
application_fee_amount: calculate_platform_fee(amount_cents),
transfer_data: {
destination: connected_account_id
}
}, {
stripe_account: connected_account_id # Execute on connected account
})
# Create transaction records for both platform and connected account
Transaction.create!(
amount_cents: amount_cents,
success: payment_intent.status == 'succeeded',
stripe_data: payment_intent.to_hash,
stripe_payment_id: payment_intent.id,
connected_account_id: connected_account_id,
platform_fee_cents: calculate_platform_fee(amount_cents)
)
rescue Stripe::PermissionError => e
# Connected account permissions issue
Transaction.create!(
amount_cents: amount_cents,
success: false,
stripe_data: { error: e.json_body },
error_code: 'permission_error',
error_message: 'Connected account permission denied'
)
end
end
private
def self.calculate_platform_fee(amount_cents)
# 2.9% + $0.30 platform fee
(amount_cents * 0.029 + 30).round
end
end
Key Takeaways and Best Practices
1. Always Track All Payment Attempts
# β Good: Comprehensive tracking
def process_payment(customer, amount, description)
transaction = PaymentService.charge(amount, description, customer)
customer.payment_records.create!(transaction: transaction) # Always create
if transaction.success?
handle_successful_payment(transaction)
else
handle_failed_payment(transaction)
end
transaction
end
# β Bad: Selective tracking
def process_payment(customer, amount, description)
transaction = PaymentService.charge(amount, description, customer)
if transaction.success? # Only tracking successes
customer.payment_records.create!(transaction: transaction)
handle_successful_payment(transaction)
end
transaction
end
2. Use Polymorphic Associations for Flexibility
# Allows payments to be associated with any business entity
class PaymentRecord < ApplicationRecord
belongs_to :payable, polymorphic: true # Customer, Order, Subscription, etc.
end
3. Store Complete Stripe Response Data
# Preserve full context for debugging and analytics
Transaction.create!(
amount_cents: amount,
success: payment_successful?,
stripe_data: stripe_response.to_hash, # Complete response
stripe_payment_id: stripe_response.id,
error_code: extract_error_code(stripe_response),
error_message: extract_error_message(stripe_response)
)
4. Build Business-Friendly Interfaces
def payment_status_badge
if transaction.success?
content_tag(:span, 'SUCCESS', class: 'badge badge-success')
else
content_tag(:span, 'FAILED', class: 'badge badge-danger')
end
end
5. Implement Robust Testing
# Test both success and failure scenarios
describe 'payment processing' do
it 'tracks successful payments' do
expect { process_payment }.to change { customer.payment_records.count }.by(1)
expect(customer.transactions.last.success?).to be true
end
it 'tracks failed payments' do
stub_payment_failure
expect { process_payment }.to change { customer.payment_records.count }.by(1)
expect(customer.transactions.last.success?).to be false
end
end
6. Use Caching for Performance
# Cache expensive payment details calculations
def payment_details_for_display(transaction)
Rails.cache.fetch("payment_#{transaction.id}_#{transaction.updated_at.to_i}") do
PaymentDetailsPresenter.new(transaction, self).to_hash
end
end
Conclusion
Building comprehensive payment tracking in Rails applications requires careful attention to data architecture, error handling, and user experience. The patterns demonstrated here provide a foundation for creating payment systems that not only process transactions reliably but also give business teams the visibility they need to understand and optimize their payment flows.
By implementing these patterns, you’ll create payment systems that not only meet immediate business needs but also provide the foundation for future growth and optimization.
A deep dive into race conditions, testing modes, and the mysterious world of background job testing
The Mystery: “But It Works On My Machine!” π€
Picture this: You’ve just refactored some code to improve performance by moving slow operations to background workers. Your tests pass locally with flying colors. You push to CI, feeling confidentβ¦ and then:
X expected: 3, got: 2
X expected: 4, got: 0
Welcome to the wonderful world of Sidekiq testing race conditions β one of the most frustrating debugging experiences in Rails development.
The Setup: A Real-World Example
Let’s examine a real scenario that recently bit us. We had a OrdersWorker that creates orders for new customers:
# app/workers/signup_create_upcoming_orders_worker.rb
class OrdersWorker
include Sidekiq::Worker
def perform(client_id, reason)
client = Client.find(client_id)
# Create orders - this is slow!
client.orders.create
# ... more setup logic
end
end
The worker gets triggered during customer activation:
# lib/settings/update_status.rb
def setup(prev)
# NEW: Move slow operation to background
OrdersWorker.perform_async(@user.client.id, @reason)
# ... other logic
end
And our test helper innocently calls this during setup:
# spec/helper.rb
def init_client(tags = [], sub_menus = nil)
client = FactoryBot.create(:client, ...)
# This triggers the worker!
Settings::Status.new(client, { status: 'active', reason: 'test'}).save
client
end
Understanding Sidekiq Testing Modes
Sidekiq provides three testing modes that behave very differently:
1. Default Mode (Production-like)
# Workers run asynchronously in separate processes
OrdersWorker.perform_async(client.id, 'signup')
# Test continues immediately - worker runs "sometime later"
2. Fake Mode
Sidekiq::Testing.fake!
# Jobs are queued but NOT executed
expect(OrdersWorker.jobs.size).to eq(1)
3. Inline Mode
Sidekiq::Testing.inline!
# Jobs execute immediately and synchronously
OrdersWorker.perform_async(client.id, 'signup')
# ^ This blocks until the job completes
The Environment Plot Twist
Here’s where it gets interesting. The rspec-sidekiq gem can completely override these modes:
Local Development
# Your test output
[rspec-sidekiq] WARNING! Sidekiq will *NOT* process jobs in this environment.
Translation: “I don’t care what Sidekiq::Testing mode you set – workers aren’t running, period.”
CI/Staging
# No warning - workers run normally
Sidekiq 7.3.5 connecting to Redis with options {:url=>"redis://redis:6379/0"}
Translation: “Sidekiq testing modes work as expected.”
The Race Condition Emerges
Now we can see the perfect storm:
RSpec.describe 'OrderBuilder' do
it "calculates order quantities correctly" do
client = init_client([],[]) # * Triggers worker async in CI
client.update!(order_count: 5) # * Sets expected value
order = OrderBuilder.new(client).create(week) # * Reads client state
expect(order.products.first.quantity).to eq(3) # >> Fails in CI
end
end
What happens in CI:
init_client triggers OrdersWorker.perform_async
Test sets order_count = 5
Worker runs asynchronously, potentially resetting client state
# Local: Workers disabled
[rspec-sidekiq] WARNING! Sidekiq will *NOT* process jobs in this environment.
# CI: Workers enabled (no warning)
2. Trace Worker Triggers
Look for these patterns in your test setup:
# Direct calls
SomeWorker.perform_async(...)
# Indirect calls through model callbacks, service objects
client.setup! # May trigger workers internally
Settings::Status.new(...).save # May trigger workers
3. Check for State Mutations
Workers that modify the same data your tests depend on:
# Test expects this value
client.update!(important_field: 'expected_value')
# But worker might reset it
class ProblematicWorker
def perform(client_id)
client = Client.find(client_id)
client.update!(important_field: 'default_value') # π₯ Race condition
end
end
Solutions & Best Practices
Solution 1: File-Level Inline Mode
For specs heavily dependent on worker behavior:
RSpec.describe 'OrderBuilder' do
before(:each) do
# Force all workers to run synchronously
Sidekiq::Testing.inline!
# ... other setup
end
# All tests now have consistent worker behavior
end
Solution 2: Context-Specific Inline Mode
For isolated problematic tests:
context "with background jobs" do
before { Sidekiq::Testing.inline! }
it "works with synchronous workers" do
# Test that needs worker execution
end
end
Solution 3: Stub the Workers
When you don’t need the worker logic:
before do
allow(ProblematicWorker).to receive(:perform_async)
end
Solution 4: Test the Worker Separately
Isolate worker testing from business logic testing:
# Test the worker in isolation
RSpec.describe OrdersWorker do
it "creates orders correctly" do
Sidekiq::Testing.inline!
worker.perform(client.id, 'signup')
expect(client.orders.count).to eq(4)
end
end
# Test business logic without worker interference
RSpec.describe OrderBuilder do
before { allow(OrdersWorker).to receive(:perform_async) }
it "calculates quantities correctly" do
# Pure business logic test
end
end
The Golden Rules
1. Be Explicit About Worker Behavior
Don’t rely on global configuration – be explicit in your tests:
# β Good: Clear intent
context "with synchronous jobs" do
before { Sidekiq::Testing.inline! }
# ...
end
# β Bad: Relies on global config
context "testing orders" do
# Assumes some global Sidekiq setting
end
2. Understand Your Test Environment
Know how rspec-sidekiq is configured in each environment:
# config/environments/test.rb
if ENV['CI']
# Allow workers in CI for realistic testing
Sidekiq::Testing.fake!
else
# Disable workers locally for speed
require 'rspec-sidekiq'
end
3. Separate Concerns
Test business logic without worker dependencies
Test worker behavior in isolation
Test integration with controlled worker execution
Real-World Fix
Here’s how we actually solved our issue:
RSpec.describe 'OrderBuilder' do
before(:each) do |example|
# CRITICAL: Ensure Sidekiq workers run synchronously to prevent race conditions
# The init_client helper triggers OrdersWorker via Settings::Status,
# which can modify client state (rte_meal_count) asynchronously in CI, causing test failures.
Sidekiq::Testing.inline!
unless example.metadata[:skip_before]
create_diet_restrictions
create_recipes
assign_recipe_tags
end
end
# All tests now pass consistently in both local and CI! β
end
Takeaways
Environment Parity Matters: Your local and CI environments may handle Sidekiq differently
Workers Create Race Conditions: Background jobs can interfere with test state
Be Explicit: Don’t rely on global Sidekiq test configuration
Debug Systematically: Look for worker triggers in your test setup
Choose the Right Solution: Inline, fake, or stubbing – pick what fits your test needs
The next time you see tests passing locally but failing in CI, ask yourself: “Are there any background jobs involved?” You might just save yourself hours of debugging! π―
Have you encountered similar Sidekiq testing issues? Share your war stories and solutions in the comments below!
Part 1: The Problem – When Legacy Meets Modern Frontend
Our Architecture: A Common Evolution Story
Many web applications today follow a similar evolutionary path. What started as a traditional Rails monolith gradually transforms into a modern hybrid architecture. Our application, let’s call it “MealCorp,” followed this exact journey:
Phase 1: Traditional Rails Monolith
# Traditional Rails serving HTML + embedded JavaScript
class HomeController < ApplicationController
def index
# Rails renders ERB templates with inline scripts
render 'home/index'
end
end
# Modern hybrid: Rails API + Vue frontend
class AppController < ApplicationController
INDEX_PATH = Rails.root.join('public', 'app.html')
INDEX_CONTENT = File.exist?(INDEX_PATH) && File.open(INDEX_PATH, &:read).html_safe
def index
if Rails.env.development?
redirect_to request.url.gsub(':3000', ':5173') # Vite dev server
else
render html: INDEX_CONTENT # Serve built Vue app
end
end
end
The routes configuration looked like this:
# config/routes.rb
Rails.application.routes.draw do
root 'home#index'
get '/dashboard' => 'app#index'
get '/settings' => 'app#index'
get '/profile' => 'app#index'
# Most routes serve the Vue SPA
end
The Hidden Performance Killer
While our frontend was modern and fast, we discovered a critical performance issue that’s common in hybrid architectures. Our Google PageSpeed scores were suffering, showing this alarming breakdown:
// Fix: Add proper type declarations
(window as any)._rollbarConfig = { ... }; // Type assertion approach
// OR declare global types for better type safety
This hybrid architecture optimization demonstrates how modern frontend practices can be retroactively applied to existing applications, achieving significant performance improvements while maintaining full functionality. The key is identifying where legacy server-side patterns conflict with modern client-side performance optimization and implementing targeted solutions.
In Part 1, we learned how to spot bottlenecks using Rails logs. Now let’s go deeper – using profiling tools, custom logging, and real-world fixes (including our Flipper case).
π§° Profiling Tools for Rails Developers
1. Rack Mini Profiler π
Rack Mini Profiler is the go-to gem for spotting slow DB queries and views.
Add it to your Gemfile (development & staging only):
group :development do
gem 'rack-mini-profiler'
end
Then run:
bundle install
When you load a page, youβll see a little timing panel in the top-left corner:
Total time per request.
SQL queries count & time.
Which queries are repeated.
View rendering breakdown.
This makes N+1 queries immediately visible.
2. Bullet (for N+1 Queries)
Add to Gemfile:
group :development do
gem 'bullet'
end
Config in config/environments/development.rb:
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.rails_logger = true
end
Now, if you forget to eager load (includes), Bullet will warn:
N+1 query detected: Review => Product
Add to your query: .includes(:product)
3. Custom Logging for SQL Queries
Sometimes you need to trace where in your code a slow query is triggered. Rails lets you hook into ActiveRecord logging:
# config/initializers/query_tracer.rb
ActiveSupport::Notifications.subscribe("sql.active_record") do |_, start, finish, _, payload|
duration = (finish - start) * 1000
if duration > 100 # log queries > 100ms
Rails.logger.warn "SLOW QUERY (#{duration.round(1)}ms): #{payload[:sql]}"
Rails.logger.warn caller.select { |line| line.include?(Rails.root.to_s) }.first(5).join("\n")
end
end
This logs:
The query.
Execution time.
Stack trace (where in Rails code it was triggered).
Real Example: Fixing Flipper Performance
In Part 1, we saw Flipper queries running 38 times per page:
SELECT "flipper_features"."key" AS feature_key,
"flipper_gates"."key",
"flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"
Problem
Each Flipper.enabled?(:feature_name, user) call hit the DB. With dozens of flags per request β repeated queries β 6s page loads.
Solution: Redis Caching
Flipper supports caching with Redis.
# config/initializers/flipper.rb
require 'flipper/adapters/active_record'
require 'flipper/adapters/redis'
require 'flipper/adapters/operation_logger'
flipper_db_adapter = Flipper::Adapters::ActiveRecord.new
flipper_redis_adapter = Flipper::Adapters::Redis.new(Redis.new)
# wrap with memory cache to avoid repeat queries
flipper_caching_adapter = Flipper::Adapters::Cache.new(
flipper_db_adapter,
cache: flipper_redis_adapter,
expires_in: 5.minutes
)
Flipper.configure do |config|
config.default = Flipper.new(flipper_caching_adapter)
end
Now:
First request β fetches from DB, writes to Redis.
Rails makes building apps fast and joyful – but sooner or later, every team runs into the same dreaded complaint:
“Why is this page so slow?”
Performance debugging is tricky because Rails abstracts so much for us. Underneath every User.where(...).first or current_user.orders.includes(:products), there’s real SQL, database indexes, network calls, caching layers, and Ruby code running.
This post (Part 1) focuses on how to find the bottlenecks in a Rails app using logs and manual inspection. In Part 2, we’ll explore tools like Rack Mini Profiler and real-world fixes.
Symptoms of a Slow Rails Page
Before diving into logs, it’s important to recognize what “slow” might mean:
Page loads take several seconds.
CPU usage spikes during requests.
The database log shows queries running longer than expected.
Repeated queries (e.g. the same SELECT firing 30 times).
Memory bloat or high GC (garbage collection) activity.
Example symptom we hit:
SELECT "flipper_features"."key" AS feature_key,
"flipper_gates"."key",
"flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"
This query was executed 38 times when loading a product page (/product/adidas-shoe). That’s a red flag .
Understanding Rails Logs
Every Rails request is logged in log/development.log (or production.log). A typical request looks like:
Started GET "/products/123" for 127.0.0.1 at 2025-09-25 12:45:01 +0530
Processing by ProductsController#show as HTML
Parameters: {"id"=>"123"}
Product Load (1.2ms) SELECT "products".* FROM "products" WHERE "products"."id" = $1 LIMIT $2 [["id", 123], ["LIMIT", 1]]
Review Load (10.4ms) SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = $1 [["product_id", 123]]
Completed 200 OK in 120ms (Views: 80.0ms | ActiveRecord: 20.0ms | Allocations: 3456)
Key things to notice:
Controller action β ProductsController#show.
Individual SQL timings β each query shows how long it took.
If the DB time is small but Views are big β it’s a rendering problem. If ActiveRecord dominates β the DB queries are the bottleneck.
π΅οΈ Debugging a Slow Page Step by Step
1. Watch your logs in real time
tail -f log/development.log | grep -i "SELECT"
This shows you every SQL query as it executes.
2. Look for repeated queries (N+1)
If you see the same SELECT firing dozens of times:
SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 123
SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 124
SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 125
That’s the classic N+1 query problem.
3. Look for expensive joins
Queries with multiple JOINs can be slow without proper indexing. Example:
SELECT "orders"."id", "users"."email"
FROM "orders"
INNER JOIN "users" ON "users"."id" = "orders"."user_id"
WHERE "users"."status" = 'active'
If there’s no index on users.status, this can cause sequential scans.
4. Look for long-running queries
Rails logs include timings:
User Load (105.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = 123
If a query consistently takes >100ms on small tables, it probably needs an index or query rewrite.
β‘ Real Example: Debugging the Flipper Feature Flag Queries
In our case, the Rails logs showed:
SELECT "flipper_features"."key" AS feature_key,
"flipper_gates"."key",
"flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"
It executed 38 times on one page.
Each execution took between 60β200ms.
Together, that added ~6 seconds to page load time.
The query itself wasn’t huge (tables had <150 rows). The problem was repetition – every feature flag check was hitting the DB fresh.
This pointed us toward caching (covered in Part 2).
Workflow for Performance Debugging in Rails
Reproduce the slow page locally or in staging.
Tail the logs and isolate the slow request.
Categorize: rendering slow? DB queries slow? external API calls?
Identify repeated or long queries.
Ask “why“:
Missing index?
Bad join?
N+1 query?
Repeated lookups that could be cached?
Confirm with SQL tools (EXPLAIN ANALYZE in Postgres).
Summary of Part 1
In this first part, we covered:
Recognizing symptoms of slow pages.
Reading Rails logs effectively.
Debugging step by step with queries and timings.
A real-world case of repeated Flipper queries slowing down a page.
In Part 2, we’ll go deeper into tools and solutions:
Setting up Rack Mini Profiler.
Capturing queries + stack traces in custom logs.
Applying fixes: indexes, eager loading, and caching (with Flipper as a worked example).
10) Optional: Redis caching in a Rails API app (why and how)
Even in API-only apps, application-level caching is useful to reduce DB load for expensive queries or aggregated endpoints.
Common patterns
Low-level caching:Rails.cache.fetch
Fragment caching: less relevant for API-only (used for views), but you can cache JSON blobs
Keyed caching with expiration for computed results
Example β caching an expensive query
class Api::V1::ReportsController < ApplicationController
def sales_summary
key = "sales_summary:#{Time.current.utc.strftime('%Y-%m-%d-%H')}"
data = Rails.cache.fetch(key, expires_in: 5.minutes) do
# expensive computation
compute_sales_summary
end
render json: data
end
end
Why Redis?
Redis is fast, supports expirations, and can be used as Rails.cache store (config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }).
Works well for ephemeral caches that you want to expire automatically.
Invalidation strategies
Time-based (TTL) β simplest.
Key-based β when related data changes, evict related keys.
Example: after a product update, call Rails.cache.delete("top_offers").
Versioned keys β embed a version or timestamp in the key (e.g., products:v3:top) and bump the version on deploy/major change.
Tagging / Key sets β maintain a set of keys per resource to delete them on change (more manual).
Caveat: Don’t rely solely on Redis caching for user-specific sensitive data. Use private caches when needed.
11) Purging caches and CDN invalidation
When hashed assets are used you rarely need to purge because new filenames mean new URLs. For non-hashed assets or CDN caches you may need purge:
CDN invalidation: Cloudflare / Fastly / CloudFront offer purge by URL or cache key. Use CDN APIs or surrogate-key headers to do group purges.
Surrogate-Control / Surrogate-Key (Fastly): set headers that help map objects to tags for efficient purging.
Nginx cache purge: if you configure proxy_cache, you must implement purge endpoints or TTLs.
Recommended approach:
Prefer hashed filenames for assets so you rarely need invalidation.
For dynamic content, prefer short TTLs or implement programmatic CDN purges as part of deploy/administration flows.
12) Testing and verifying caching behavior (practical commands)
def show
resource = Resource.find(params[:id])
if stale?(etag: resource, last_modified: resource.updated_at)
render json: resource
end
end
Redis caching (Rails.cache)
data = Rails.cache.fetch("top_products_page_#{params[:page]}", expires_in: 5.minutes) do
Product.top.limit(20).to_a
end
render json: data
Conclusion (Part 4)
This part explained where caching belongs in an API-only Rails + Vue application, how Passenger fits into the stack, how to set cache headers for safe API caching, optional Redis caching strategies, and practical testing/operational steps.
In Part 3 we explain how Passenger sits behind Nginx in an API-only Rails app, where caching belongs in the stack, how to safely cache API responses (or avoid caching sensitive ones), and how to verify behavior. This part covers Passenger role, request lifecycle, and API response caching strategy.
1) Passenger’s role in a Rails API app β what it is and how it plugs into Nginx
What is Passenger? Passenger (Phusion Passenger) is an application server that runs Ruby apps (Rails) and integrates tightly with web servers such as Nginx. It manages application processes, handles spawning, lifecycle, zero-downtime restarts, and serves Rack apps directly without a separate reverse-proxy layer.
Why using Passenger in your stack matters:
Nginx serves static files directly (fast).
If a request cannot be served as a static file, Nginx hands it to Passenger, which invokes your Rails app (API).
Passenger takes care of Ruby processes, workers, memory limits, restarts, etc., reducing operational complexity compared to orchestrating your own Puma cluster + systemd.
Passenger is enabled per-server or per-location. Static files under root are resolved by nginx first β Passenger only gets requests that do not map to files (or that you explicitly route to Passenger).
Browser requests https://www.mydomain.com/some/path (or /vite/index-ABC.js, or /api/v1/products).
Nginx checks if the request maps to a static file under root /apps/mydomain/current/public.
If file exists β serve it directly and attach headers (Cache-Control, etc.).
If not β pass the request to Passenger.
Passenger receives the request and dispatches it to a Rails process.
Rails API processes the request (controllers -> models -> DB) and produces a response JSON or status.
Rails returns the response to Passenger β Passenger returns it to Nginx β Nginx returns it to the browser.
Key layers where caching can occur:
Client-side (browser) β controlled by Cache-Control returned from server.
Reverse-proxy or CDN β e.g., Cloudflare, Fastly, CloudFront; caching behavior influenced by s-maxage and surrogate headers.
Application caching (Rails/Redis) β memoization or precomputed JSON payloads to reduce DB cost.
Nginx (edge) caching β possible for static assets; less common for dynamic Rails responses when using Passenger (but possible with proxying setups).
3) Where API caching should sit (principles)
Because your Rails app is API-only, you should carefully control caching:
Static assets (JS/CSS/fonts/images) = Nginx (1-year for hashed assets).
API responses (JSON) = usually short-lived or uncached unless content is highly cacheable and non-sensitive. If cached:
Prefer caching at CDN layer (s-maxage) or using application-level caching (Rails + Redis).
Use cache validation (ETag, Last-Modified) to enable conditional requests and 304 responses.
Sensitive endpoints (auth, user-specific data) = never cached publicly. Use Cache-Control: no-store, private.
4) Cache-Control and related headers for APIs β recommended practices
Important response headers and their recommended usage
Cache-Control:
no-store β do not store response anywhere (safest for sensitive data).
no-cache β caches may store but must revalidate with origin before use (useful if you want caching but require revalidation).
private β response intended for a single user; shared caches (CDNs) must not store it.
public β response may be stored by browsers and CDNs.
max-age=SECONDS β TTL in seconds.
s-maxage=SECONDS β TTL for shared caches (CDNs); supersedes max-age for shared caches.
must-revalidate / proxy-revalidate β force revalidation after expiration.
stale-while-revalidate / stale-if-error β allow stale responses while revalidation or in case of errors (good for resilience).
ETag:
Strong validator; server generates a value representing resource state. Client includes If-None-Match on subsequent requests. Server returns 304 Not Modified if ETag matches.
Last-Modified and If-Modified-Since:
Based on timestamp; less precise than ETag but simple.
Vary:
Tells caches that responses vary by certain request headers (e.g., Vary: Accept-Encoding or Vary: Authorization).
Example header patterns
Public, CDN cacheable API (e.g., public product listings): Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30 ETag: "abc123" Vary: Accept-Encoding
Browser caches for 60s. CDN caches for 300s. Meanwhile allow stale while revalidate.
5) How to add caching headers in a Rails API controller (practical examples)
Because you run an API-only app, prefer setting headers in controllers selectively for GET endpoints you consider safe to cache.
Basic manual header (safe and explicit):
class Api::V1::ProductsController < ApplicationController
def index
@products = Product.popular.limit(20)
# set short-lived cache for 60 seconds for browsers
response.set_header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=30')
render json: @products
end
end
Using conditional GET with ETag / Last-Modified:
class Api::V1::ProductsController < ApplicationController
def show
product = Product.find(params[:id])
# This helps return 304 Not Modified if product hasn't changed
if stale?(etag: product, last_modified: product.updated_at)
render json: product
end
end
end
Notes: stale? and fresh_when are provided by ActionController::ConditionalGet. In an API-only app these helper methods are normally available, but confirm by checking your ApplicationController inheritance; if not, you can use response.set_header('ETag', ...) directly.
Setting ETag manually:
etag_value = Digest::SHA1.hexdigest(product.updated_at.to_i.to_s + product.id.to_s)
response.set_header('ETag', "\"#{etag_value}\"")
# then Rails will respond with 304 if If-None-Match matches
6) Important rules for API caching
Only cache GET responses. Never cache responses to POST, PUT, PATCH, DELETE.
Do not cache user-specific or sensitive info in shared caches. Use private or no-store.
Prefer CDN caching (s-maxage) for public endpoints. Use s-maxage to instruct CDNs to keep content longer than browsers.
Use ETags or Last-Modified for validation to reduce bandwidth and get 304 responses.
Consider short TTLs and stale-while-revalidate to reduce origin load while keeping content fresh.
Version your API (e.g., /api/v1/) so you can change caching behavior on new releases without conflicting with old clients.
7) Nginx + Passenger and caching for API endpoints β what to do (and what to avoid)
Avoid using Nginx proxy cache with Passenger by default. Passenger is not a reverse proxy; it’s an app server. Nginx can use proxy_cache for caching upstream responses, but that pattern is more common when you proxy to a separate Puma/Unicorn backend via proxy_pass. With Passenger, it’s simpler and safer to set cache headers in Rails and let CDNs or clients respect them.
If you want edge caching in Nginx, it is technically possible to enable fastcgi_cache/proxy_cache patterns if you have an upstream; use caution β caching dynamic JSON responses at the web server is tricky and must be carefully invalidated.
Recommended: set caching headers in Rails (as shown), then let a CDN (Cloudflare/Fastly/CloudFront) apply caching and invalidation; Passenger remains the process manager for Rails.
8) Example: making a public, cacheable endpoint safe and CDN-friendly
class Api::V1::PublicController < ApplicationController
def top_offers
data = Offer.top(10) # expensive query
response.set_header('Cache-Control', 'public, max-age=120, s-maxage=600, stale-while-revalidate=30')
# Optionally set ETag
fresh_when(etag: Digest::SHA1.hexdigest(data.map(&:updated_at).join(',')))
render json: data
end
end
max-age=120 β browsers cache for 2 minutes
s-maxage=600 β CDN caches for 10 minutes
stale-while-revalidate=30 β CDN/browsers may serve stale for 30s while origin revalidates
9) Passenger vs Puma β quick comparison (for API deployments)
Passenger
Pros:
Tight nginx integration (simpler config).
Auto-manages application processes; zero-downtime restarts are straightforward (passenger-config restart-app).
Good defaults for concurrency and memory management.
Cons:
Less flexible for custom proxy patterns (compared to running Puma behind nginx).
Some advanced caching/proxy setups are easier with a dedicated reverse-proxy architecture.
Puma (common alternative)
Pros:
Lightweight, highly configurable; often used behind nginx as reverse proxy.
Works well in containerized environments (Docker/Kubernetes).
Easy to pair with systemd or process managers and to horizontally scale workers.
Cons:
Requires extra process management & reverse proxying (nginx proxy_pass) configuration.
Slightly more operational overhead vs Passenger.
For an API-only Rails app with static assets served by nginx, Passenger is a great choice when you want fewer moving pieces. Puma + nginx gives more flexibility if you need advanced proxy caching or plan to run in a container orchestration platform.
I’ll continue with Part 4 covering Redis caching (optional), invalidation strategies, testing, debugging, commands, examples of common pitfalls and a final checklist.
In Part 1, we explored the request flow between Nginx, Vue (frontend), and Rails (API backend). We also covered how Nginx routes traffic and why caching matters in such a setup.
Now in Part 2, we’ll go deeper into asset caching strategies β specifically tailored for a Rails API-only backend + Vue frontend deployed with Nginx.
π The Core Idea
HTML files (like vite.html) should never be cached. They are the entry point of the SPA and change frequently.
Hashed assets (like /vite/index-G34XebCm.js) can be cached for 1 year safely, because the hash ensures cache-busting.
Non-hashed assets (images, fonts, legacy JS/CSS) should get short-term caching (e.g., 1 hour).
This split ensures fast repeat visits while avoiding stale deploys.
π Example: Files in public/vite/
Your build pipeline (via Vite) outputs hashed assets like:
Notice the random-looking suffixes (G34XebCm, D48ns5vN) β these are hashes. They change whenever the file content changes.
β‘οΈ That’s why they’re safe to cache for 1 year: a new deploy creates new filenames, so the browser will fetch fresh assets.
By contrast, files like:
assets/
15_minutes.png
Sky_background.png
do not have hashes. If you update them, the filename doesn’t change, so the browser might keep showing stale content if cached too long. These need shorter cache lifetimes.
π οΈ Final Nginx Caching Configuration
Here’s the Nginx cache snippet tuned for your setup:
These projects rely heavily on content hashing + Nginx headers β exactly what we’re setting up here.
β Best Practices Recap
Always fingerprint (hash) assets in production builds.
Cache HTML for 0 seconds, JS/CSS hashed files for 1 year.
Use immutable for hashed assets.
Keep non-hashed assets on short lifetimes or rename them when updated.
This ensures smooth deploys, lightning-fast repeat visits, and no stale content issues.
π In Part 3, we’ll go deeper into Rails + Passenger integration, showing how Rails API responses fit into this caching strategy (and what not to cache at the API layer).