When building Rails applications, I often encounter complex business logic that doesn’t fit neatly into models or controllers. Over years of refining architecture, I’ve identified seven service object patterns that maintain clarity without compromising capability. These approaches handle everything from payment flows to inventory synchronization while keeping code testable and adaptable.
Transactional Integrity Pattern
Database consistency is non-negotiable for operations like order processing. I wrap interrelated steps in atomic transactions to guarantee all-or-nothing execution. This prevents partial updates that corrupt data. Consider this implementation:
class FinancialReconciliation
def initialize(account)
@account = account
end
def execute
ActiveRecord::Base.transaction do
deduct_service_fees!
apply_interest!
clear_pending_transactions!
end
rescue ActiveRecord::RecordInvalid => e
ErrorTracker.log_reconciliation_failure(account: @account.id, error: e)
false
end
private
def deduct_service_fees!
@account.update!(balance: @account.balance - 30.00)
FeeLedger.create!(account: @account, amount: -30.00, description: "Monthly service fee")
end
def apply_interest!
interest = @account.balance * 0.015
@account.update!(balance: @account.balance + interest)
InterestLedger.create!(account: @account, amount: interest, description: "Interest applied")
end
# Additional private methods
end
The transaction block ensures fee deductions and interest calculations either succeed together or roll back completely. I always rescue specific exceptions like RecordInvalid
rather than generic errors to handle failures precisely. This pattern eliminates “in-between” states that cause accounting nightmares.
Validation Gateway Pattern
I enforce strict input validation before any business logic runs. This fail-fast approach prevents invalid operations from contaminating the system. Notice how this shipping service halts immediately for invalid addresses:
class PackageDispatcher
def initialize(package, destination)
@package = package
@destination = destination
end
def dispatch
validate_destination!
calculate_shipping_costs
generate_tracking
# Additional dispatch steps
end
private
def validate_destination!
unless ShippingZone.valid?(@destination.postal_code)
raise InvalidDestinationError, "Unserviceable postal code"
end
end
def calculate_shipping_costs
# Uses validated destination
@cost = ShippingCalculator.for_zone(@destination.zone).calculate(@package.weight)
end
# More private methods
end
By validating @destination
upfront, I avoid complex rollbacks later. The explicit InvalidDestinationError
clearly communicates failure reasons to callers. I’ve found this pattern reduces defensive coding in downstream methods since they can assume valid inputs.
Dependency Injection Pattern
Hardcoded external services become technical debt. I inject dependencies to isolate third-party integrations and enable testing. This subscription service demonstrates the technique:
class SubscriptionActivator
def initialize(user, plan, payment_gateway: StripeGateway, notifier: EmailNotifier)
@user = user
@plan = plan
@gateway = payment_gateway
@notifier = notifier
end
def activate
payment_result = @gateway.charge(@user, @plan.price)
return Result.failure("Payment failed") unless payment_result.success?
@user.subscriptions.create!(plan: @plan, expires_at: 1.year.from_now)
@notifier.deliver(:subscription_activated, @user)
Result.success
end
end
# Test example
RSpec.describe SubscriptionActivator do
let(:mock_gateway) { double('Gateway', charge: OpenStruct.new(success?: true)) }
let(:mock_notifier) { double('Notifier', deliver: true) }
it "activates on successful payment" do
activator = SubscriptionActivator.new(user, plan, payment_gateway: mock_gateway, notifier: mock_notifier)
expect(activator.activate).to be_success
end
end
Swapping payment providers or notification channels requires zero internal changes. During testing, I pass mocks that verify interactions without hitting real APIs. This pattern proves invaluable when integrating with flaky external systems.
Outcome Object Pattern
Boolean returns often obscure failure details. I return rich result objects that encapsulate status, data, and errors. This user registration example shows the pattern:
class UserRegistrar
def initialize(params)
@params = params
end
def register
user = User.new(@params)
if user.save
UserWelcomeKit.deliver(user)
RegistrationResult.new(success: true, user: user)
else
RegistrationResult.new(success: false, errors: user.errors)
end
end
end
class RegistrationResult
attr_reader :user, :errors
def initialize(success:, user: nil, errors: nil)
@success = success
@user = user
@errors = errors
end
def success?
@success
end
def failure?
!@success
end
end
# Controller usage
result = UserRegistrar.new(user_params).register
if result.success?
redirect_to dashboard_path
else
flash[:error] = result.errors.full_messages
render :new
end
The RegistrationResult
object provides controllers with everything needed for response handling without exposing service internals. I extend these objects with methods like #on_success
for callbacks when needed. This avoids cluttering services with presentation logic.
Sequential Workflow Pattern
Complex workflows become readable when broken into sequenced private methods. I structure services as self-documenting step-by-step processes. This content publishing flow illustrates:
class ArticlePublisher
def initialize(article)
@article = article
end
def publish
validate_article!
set_publication_timestamp
generate_seo_metadata
distribute_to_cdn
notify_subscribers
@article
end
private
def validate_article!
raise DraftError unless @article.completed?
end
def set_publication_timestamp
@article.update!(published_at: Time.current)
end
def generate_seo_metadata
@article.metadata = SeoGenerator.for_article(@article)
@article.save!
end
def distribute_to_cdn
CdnBackend.push(@article.to_html)
end
def notify_subscribers
SubscriptionNotifier.for(@article.category).notify_all
end
end
Each private method represents a business requirement. The #publish
method reads like documentation while encapsulating implementation details. I keep methods under 10 lines to maintain readability. This pattern handles regulatory workflows where steps must occur in specific order.
Contextual Logging Pattern
Debugging distributed failures requires rich context. I instrument services to log structured data without exposing internals. This inventory service demonstrates:
class InventoryReserver
def initialize(product, quantity)
@product = product
@quantity = quantity
@logger = AuditLogger.new(tag: "inventory")
end
def reserve
@logger.context(product_id: @product.id) do
@product.with_lock do
validate_availability!
create_reservation
@logger.log_event("reservation_created", quantity: @quantity)
end
end
rescue ReservationError => e
@logger.log_error(e, context: { available: @product.stock_count })
raise
end
private
def validate_availability!
if @product.stock_count < @quantity
raise ReservationError, "Insufficient stock"
end
end
def create_reservation
# Reservation logic
end
end
The logger automatically attaches product IDs to all entries within the block. When errors occur, I capture the current stock count without exposing other implementation details. This pattern creates audit trails that accelerate incident investigations while preserving encapsulation.
Stateless Composition Pattern
Large services become unmanageable. I decompose complex operations into smaller stateless services that compose together. This report generation example shows how:
class FinancialReportGenerator
def initialize(company, year)
@company = company
@year = year
end
def generate
raw_data = DataCollector.new(@company, @year).fetch
analyzed = DataAnalyzer.new(raw_data).process
formatted = ReportFormatter.new(analyzed).format
AuditRecorder.log_report_generated(@company, @year)
formatted
end
end
class DataCollector
def initialize(company, year)
@company = company
@year = year
end
def fetch
# Database queries
end
end
# Additional specialized services
Each sub-service handles one responsibility: collecting data, analyzing, formatting. The main service coordinates them into a workflow. I make sub-services stateless by passing all required data as parameters. This enables isolated testing and reuse across multiple workflows. When requirements change, I modify or swap individual components without touching unrelated code.
These patterns evolved through solving real-world problems. Transactional integrity prevents financial discrepancies during system crashes. Dependency injection allowed migrating payment providers with minimal disruption. Outcome objects made API error responses more actionable. Every pattern addresses specific pain points I’ve encountered in production systems.
Remember that patterns serve your needs, not vice versa. I start with simple service objects and introduce these techniques only when complexity demands them. The sweet spot emerges when services remain focused enough to fit in one screen view but robust enough to handle real business requirements. Properly implemented, these patterns create systems that handle today’s complexity while adapting to tomorrow’s requirements.