home / skills / thibautbaissac / rails_ai_agents / rails-service-object

rails-service-object skill

/skills/rails-service-object

This skill helps you implement Rails service objects with a clear contract, testability, and dependency injection for complex business logic.

npx playbooks add skill thibautbaissac/rails_ai_agents --skill rails-service-object

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
8.4 KB
---
name: rails-service-object
description: Creates service objects following single-responsibility principle with comprehensive specs. Use when extracting business logic from controllers, creating complex operations, implementing interactors, or when user mentions service objects or POROs.
allowed-tools: Read, Write, Edit, Bash
---

# Rails Service Object Pattern

## Overview

Service objects encapsulate business logic:
- Single responsibility (one public method: `#call`)
- Easy to test in isolation
- Reusable across controllers, jobs, rake tasks
- Clear input/output contract
- Dependency injection for testability

## When to Use Service Objects

| Scenario | Use Service Object? |
|----------|---------------------|
| Complex business logic | Yes |
| Multiple model interactions | Yes |
| External API calls | Yes |
| Logic shared across controllers | Yes |
| Simple CRUD operations | No (use model) |
| Single model validation | No (use model) |

## Workflow Checklist

```
Service Object Progress:
- [ ] Step 1: Define input/output contract
- [ ] Step 2: Create service spec (RED)
- [ ] Step 3: Run spec (fails - no service)
- [ ] Step 4: Create service file with empty #call
- [ ] Step 5: Run spec (fails - wrong return)
- [ ] Step 6: Implement #call method
- [ ] Step 7: Run spec (GREEN)
- [ ] Step 8: Add error case specs
- [ ] Step 9: Implement error handling
- [ ] Step 10: Final spec run
```

## Step 1: Define Contract

```markdown
## Service: Orders::CreateService

### Purpose
Creates a new order with inventory validation and payment processing.

### Input
- user: User (required) - The user placing the order
- items: Array<Hash> (required) - Items to order [{product_id:, quantity:}]
- payment_method_id: Integer (optional) - Saved payment method

### Output (Result object)
Success:
- success?: true
- data: Order instance

Failure:
- success?: false
- error: String (error message)
- code: Symbol (error code for programmatic handling)

### Dependencies
- inventory_service: Checks product availability
- payment_gateway: Processes payment

### Side Effects
- Creates Order and OrderItem records
- Decrements inventory
- Charges payment method
- Sends confirmation email (async)
```

## Step 2: Service Spec

Location: `spec/services/orders/create_service_spec.rb`

```ruby
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Orders::CreateService do
  subject(:service) { described_class.new(dependencies) }

  let(:dependencies) { {} }
  let(:user) { create(:user) }
  let(:product) { create(:product, inventory_count: 10) }
  let(:items) { [{ product_id: product.id, quantity: 2 }] }

  describe '#call' do
    subject(:result) { service.call(user: user, items: items) }

    context 'with valid inputs' do
      it 'returns success' do
        expect(result).to be_success
      end

      it 'creates an order' do
        expect { result }.to change(Order, :count).by(1)
      end

      it 'returns the order' do
        expect(result.data).to be_a(Order)
        expect(result.data.user).to eq(user)
      end
    end

    context 'with empty items' do
      let(:items) { [] }

      it 'returns failure' do
        expect(result).to be_failure
      end

      it 'returns error message' do
        expect(result.error).to eq('No items provided')
      end
    end

    context 'with insufficient inventory' do
      let(:items) { [{ product_id: product.id, quantity: 100 }] }

      it 'returns failure' do
        expect(result).to be_failure
      end

      it 'does not create order' do
        expect { result }.not_to change(Order, :count)
      end
    end
  end
end
```

See [templates/service_spec.erb](templates/service_spec.erb) for full template.

## Step 3-6: Implement Service

Location: `app/services/orders/create_service.rb`

```ruby
# frozen_string_literal: true

module Orders
  class CreateService
    def initialize(inventory_service: InventoryService.new,
                   payment_gateway: PaymentGateway.new)
      @inventory_service = inventory_service
      @payment_gateway = payment_gateway
    end

    def call(user:, items:, payment_method_id: nil)
      return failure('No items provided', :empty_items) if items.empty?
      return failure('Insufficient inventory', :insufficient_inventory) unless inventory_available?(items)

      order = create_order(user, items)
      process_payment(order, payment_method_id) if payment_method_id

      success(order)
    rescue ActiveRecord::RecordInvalid => e
      failure(e.message, :validation_failed)
    rescue PaymentError => e
      failure(e.message, :payment_failed)
    end

    private

    attr_reader :inventory_service, :payment_gateway

    def inventory_available?(items)
      items.all? do |item|
        inventory_service.available?(item[:product_id], item[:quantity])
      end
    end

    def create_order(user, items)
      ActiveRecord::Base.transaction do
        order = Order.create!(user: user, status: :pending)

        items.each do |item|
          order.order_items.create!(
            product_id: item[:product_id],
            quantity: item[:quantity]
          )
          inventory_service.decrement(item[:product_id], item[:quantity])
        end

        order
      end
    end

    def process_payment(order, payment_method_id)
      payment_gateway.charge(
        amount: order.total,
        payment_method_id: payment_method_id
      )
      order.update!(status: :paid)
    end

    def success(data)
      Result.new(success: true, data: data)
    end

    def failure(error, code = :unknown)
      Result.new(success: false, error: error, code: code)
    end
  end
end
```

## Result Object

Create a reusable Result class:

```ruby
# app/services/result.rb
# frozen_string_literal: true

class Result
  attr_reader :data, :error, :code

  def initialize(success:, data: nil, error: nil, code: nil)
    @success = success
    @data = data
    @error = error
    @code = code
  end

  def success?
    @success
  end

  def failure?
    !@success
  end

  # Allow pattern matching (Ruby 3+)
  def deconstruct_keys(keys)
    { success: @success, data: @data, error: @error, code: @code }
  end
end
```

## Calling Services

### From Controllers

```ruby
class OrdersController < ApplicationController
  def create
    result = Orders::CreateService.new.call(
      user: current_user,
      items: order_params[:items],
      payment_method_id: order_params[:payment_method_id]
    )

    if result.success?
      render json: result.data, status: :created
    else
      render json: { error: result.error }, status: :unprocessable_entity
    end
  end
end
```

### From Jobs

```ruby
class ProcessOrderJob < ApplicationJob
  def perform(user_id, items)
    user = User.find(user_id)
    result = Orders::CreateService.new.call(user: user, items: items)

    unless result.success?
      Rails.logger.error("Order failed: #{result.error}")
      # Handle failure (retry, notify, etc.)
    end
  end
end
```

## Testing with Mocked Dependencies

```ruby
RSpec.describe Orders::CreateService do
  let(:inventory_service) { instance_double(InventoryService) }
  let(:payment_gateway) { instance_double(PaymentGateway) }
  let(:service) { described_class.new(inventory_service: inventory_service, payment_gateway: payment_gateway) }

  before do
    allow(inventory_service).to receive(:available?).and_return(true)
    allow(inventory_service).to receive(:decrement)
    allow(payment_gateway).to receive(:charge)
  end

  # Tests...
end
```

## Directory Structure

```
app/services/
├── result.rb                    # Shared Result class
├── application_service.rb       # Optional base class
├── orders/
│   ├── create_service.rb
│   ├── cancel_service.rb
│   └── refund_service.rb
├── users/
│   ├── register_service.rb
│   └── update_profile_service.rb
└── payments/
    ├── charge_service.rb
    └── refund_service.rb
```

## Conventions

1. **Naming**: `VerbNounService` (e.g., `CreateOrderService`)
2. **Location**: `app/services/[namespace]/[name]_service.rb`
3. **Interface**: Single public method `#call`
4. **Return**: Always return Result object
5. **Dependencies**: Inject via constructor
6. **Errors**: Catch and wrap, don't raise

## Anti-Patterns to Avoid

1. **God service**: Too many responsibilities
2. **Hidden dependencies**: Using globals instead of injection
3. **No return contract**: Returning different types
4. **Raising exceptions**: Use Result objects instead
5. **Business logic in controller**: Extract to service

Overview

This skill generates Rails service objects that follow the single-responsibility principle and include comprehensive RSpec specs. It scaffolds a consistent service interface (single #call method) and a reusable Result object so services return a predictable success/failure contract. Use it to extract business logic from controllers, jobs, or background workers while keeping code testable and maintainable.

How this skill works

The skill creates a namespaced service class with an initializer that accepts injected dependencies and a single public #call method that returns a Result object. It also generates focused RSpec examples (red/green workflow) that drive implementation, plus helper files like a shared Result class and optional base ApplicationService. Error handling, transactions, and dependency mocking are illustrated in the generated code and tests.

When to use it

  • Extract complex business logic from controllers or models
  • Implement operations that coordinate multiple models or external APIs
  • Create interactors or POROs with clear input/output contracts
  • Encapsulate side effects (email, payment, inventory) for testability
  • Prefer models for simple CRUD; skip service for trivial single-model validation

Best practices

  • Name services VerbNounService and place under app/services/[namespace]
  • Expose a single public #call and always return a Result object
  • Inject dependencies via the constructor to allow easy mocking
  • Write the service spec first (RED), implement, then iterate on error cases
  • Keep services focused; avoid accumulating unrelated responsibilities

Example use cases

  • Orders::CreateService: validate inventory, create order, charge payment, send confirmation
  • Users::RegisterService: create user, send welcome email, schedule background tasks
  • Payments::ChargeService: call external gateway, update records, handle retries
  • Jobs invoking services to run idempotent business flows and log failures
  • Shared operations used by multiple controllers or rake tasks

FAQ

Should I always use a service for any non-trivial logic?

Use a service when logic spans models, external systems, or controllers; keep simple CRUD and single-model validation in the model.

How should errors be surfaced from a service?

Return a Result object with success? false, an error message, and a programmatic code symbol rather than raising uncontrolled exceptions.