DEV Community

Cover image for Service Objects: Level Up Your Rails Architecture
Sulman Baig
Sulman Baig

Posted on • Originally published at sulmanweb.com

Service Objects: Level Up Your Rails Architecture

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:

  1. Encapsulating complex business logic
  2. Improving testability
  3. Enhancing code reusability
  4. 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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Best Practices for Service Objects

  1. Single Responsibility: Each service should do one thing well
  2. Immutable Input: Use initialized parameters instead of instance variables
  3. Result Objects: Always return a consistent result object
  4. Transaction Safety: Wrap related operations in transactions
  5. Audit Trail: Log important business events
  6. Validation: Handle all edge cases and validation upfront
  7. 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)