Introduction
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.
Why Service Objects?
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
Basic Structure of a Service Object
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
Real-World Example: Transaction Management
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
Benefits in Practice
1. Clear Interface
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
2. Integrated Error Handling
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
3. Transaction Safety
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
4. Audit Trail Integration
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
Integration with GraphQL Mutations
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
Testing Service Objects
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
Best Practices for Service Objects
- 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
Conclusion
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!
Top comments (0)