Service Objects: Level Up Your Rails Architecture

Senior Software Engineer with 12 years of expertise in Ruby on Rails and Vue.js, specializing in health, e-commerce, staffing, and transport. Experienced in software development and version analysis.
Search for a command to run...

Senior Software Engineer with 12 years of expertise in Ruby on Rails and Vue.js, specializing in health, e-commerce, staffing, and transport. Experienced in software development and version analysis.
No comments yet. Be the first to comment.
Motivation After building several Shopify embedded apps, I've learned valuable lessons about what works (and what definitely doesn't) in the embedded app environment. Today, I'm sharing these insights to help you avoid common pitfalls and build bette...

As a developer working with Shopify's ecosystem, I recently built a multi-tenant SaaS application that synchronizes customer data between Shopify stores and external services. In this article, I'll share my experience and technical insights into crea...

As developers, we're always looking for ways to streamline our deployment process while maintaining security and reliability. Today, I'm excited to share my experience setting up an automated deployment pipeline for a Rails application using Dokku an...

The Challenge: Modernizing Rails Deployment When I recently needed to deploy my Rails 8 application with multiple databases, I faced a common dilemma: choosing between expensive managed solutions and complex self-managed servers. My requirements were...

The Cost-Benefit Realization As a Rails developer who recently migrated from Heroku to Dokku, I want to share my journey and the surprising benefits I discovered. This transition wasn't just about cost savings—it opened up new possibilities for my de...

In modern Rails applications, organizing business logic effectively is crucial for maintainability and scalability. One powerful pattern that has emerged is the use of Service Objects - Plain Old Ruby Objects (POROs) that encapsulate specific business operations. In this article, we'll explore how Service Objects can be implemented for handling GraphQL mutations and background jobs, using real-world examples from a financial transaction management system.
Traditional Rails applications often struggle with "fat" models and controllers that violate the Single Responsibility Principle. Service Objects help solve this by:
Encapsulating complex business logic
Improving testability
Enhancing code reusability
Providing clearer application architecture
Let's look at the base Service Object pattern implemented in our application:
class ApplicationService
attr_reader :params
def self.call(params = {})
new(params).call
end
def initialize(params = {})
@params = params.is_a?(String) ? JSON.parse(params, symbolize_names: true) : params
end
def call
raise NotImplementedError, "#{self.class} must implement #call"
end
private
def transaction(&)
ActiveRecord::Base.transaction(&)
end
def log_event(user:, data: {})
event_data = { user:, data:, class_name: self.class.to_s }.compact
AuditLog.create(event_data)
end
Result = Struct.new(:success, :data, :errors, keyword_init: true) do
alias_method :success?, :success
end
def success(data = {})
Result.new(success: true, data:, errors: nil)
end
def failure(errors)
Result.new(success: false, data: nil, errors:)
end
end
Let's examine how Service Objects handle complex business logic in a financial transaction system:
module Transactions
class CreateService < ApplicationService
def call
validate_dependencies ||
create_transaction
end
private
def validate_dependencies
return failure([USER_NOT_FOUND_MESSAGE]) unless user
return failure([ACCOUNT_NOT_FOUND_MESSAGE]) unless account
return failure([CATEGORY_NOT_FOUND_MESSAGE]) unless category || permitted_params[:transfer] == true
false
end
def create_transaction
ActiveRecord::Base.transaction do
transaction = Transaction.new(permitted_params)
transaction.transaction_time = Time.current if transaction.transaction_time.blank?
transaction.save!
multiplier = transaction.transaction_type == 'expense' ? -1 : 1
account.update!(balance: account.balance + (transaction.amount * multiplier))
log_event(user:, data: { transaction: })
success(transaction)
rescue ActiveRecord::RecordInvalid => e
failure(e.record.errors.full_messages)
end
end
end
end
Service Objects provide a consistent interface through the call method:
result = Transactions::CreateService.call(
user_id: current_user.id,
account_id: params[:account_id],
amount: 100.00,
transaction_type: 'expense'
)
if result.success?
# Handle success
else
# Handle failure
end
The Result object pattern provides a clean way to handle success and failure states:
def failure(errors)
Result.new(success: false, data: nil, errors:)
end
def success(data = {})
Result.new(success: true, data:, errors: nil)
end
Services can easily wrap operations in database transactions:
def transfer_money
ActiveRecord::Base.transaction do
CreateService.call(user_id: user.id, account_id: account_to.id,
transaction_type: 'income', transfer: true, **permitted_params)
CreateService.call(user_id: user.id, account_id: account_from.id,
transaction_type: 'expense', transfer: true, **permitted_params)
success(TRANSACTION_CREATED_MESSAGE)
end
end
Built-in logging capabilities for tracking business operations:
def log_event(user:, data: {})
event_data = { user:, data:, class_name: self.class.to_s }.compact
AuditLog.create(event_data)
end
Service Objects integrate seamlessly with GraphQL mutations:
module Mutations
class TransactionCreate < BaseMutationWithErrors
def resolve(**args)
auth_result = authenticate_user!
return auth_result unless auth_result[:success]
result = Transactions::CreateService.call(user_id: current_user.id, **args)
{
errors: result.errors,
success: result.success?,
transaction: result.success? ? result.data : nil
}
end
end
end
Service Objects are highly testable, as demonstrated by our RSpec examples:
RSpec.describe Transactions::CreateService do
subject(:service) { described_class.new(params) }
let(:user) { create(:user) }
let(:account) { create(:account, balance: 100, user:) }
let(:params) do
{
user_id: user.id,
account_id: account.id,
amount: 100,
transaction_type: 'expense'
}
end
describe '#call' do
context 'when valid' do
it 'creates the transaction and updates balance' do
result = service.call
expect(result).to be_success
expect(account.reload.balance).to eq(0)
end
end
end
end
Single Responsibility: Each service should do one thing well
Immutable Input: Use initialized parameters instead of instance variables
Result Objects: Always return a consistent result object
Transaction Safety: Wrap related operations in transactions
Audit Trail: Log important business events
Validation: Handle all edge cases and validation upfront
Testing: Write comprehensive tests for each service
Service Objects provide a powerful way to organize business logic in Rails applications. They offer clear benefits in terms of code organization, maintainability, and testability. When implemented consistently, they create a predictable pattern for handling complex business operations, whether in GraphQL mutations, background jobs, or other application components.
Remember that Service Objects are not a silver bullet - they should be used judiciously where they add value to your application architecture. For simple CRUD operations, traditional Rails patterns might still be the better choice.
The key is to identify complex business operations that benefit from encapsulation and isolation, and implement Service Objects for those specific cases. This approach leads to more maintainable and testable code while keeping the rest of your application simple and Rails-like.
Happy Coding!