home / skills / kaakati / rails-enterprise-dev / service-object-patterns

This skill helps you implement robust service objects in Rails to organize business logic, manage transactions, and return clear results.

npx playbooks add skill kaakati/rails-enterprise-dev --skill service-object-patterns

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

Files (6)
SKILL.md
7.1 KB
---
name: "Service Object Patterns"
description: "Complete guide to implementing Service Objects in Ruby on Rails applications. Use when: (1) Creating business logic services, (2) Refactoring fat models/controllers, (3) Organizing service namespaces, (4) Handling service results, (5) Designing service interfaces. Trigger keywords: service objects, business logic, use cases, operations, command pattern, interactors, PORO, application services"
version: 1.1.0
---

# Service Object Patterns

Comprehensive guidance for implementing Service Objects in Rails applications.

## Service Decision Tree

```
Where should this logic go?
│
├─ Business logic spanning multiple models?
│   └─ Service Object (app/services/)
│
├─ Operation has multiple steps/side effects?
│   └─ Service Object (with transaction)
│
├─ Need to orchestrate external services?
│   └─ Service Object (with error handling)
│
├─ Complex validation or business rules?
│   └─ Service Object (or Form Object for forms)
│
├─ Simple single-model CRUD?
│   └─ Keep in model/controller
│
└─ Single-line delegation?
    └─ Keep in controller
```

---

## NEVER Do This

**NEVER** put business logic in controllers:
```ruby
# WRONG - Fat controller
def create
  @order = Order.new(order_params)
  @order.calculate_tax
  @order.apply_discount(params[:coupon])
  @order.reserve_inventory
  PaymentGateway.charge(@order.total)
  @order.save
end

# RIGHT - Delegate to service
def create
  @order = OrdersManager::CreateOrder.call(user: current_user, params: order_params)
  redirect_to @order
end
```

**NEVER** return raw ActiveRecord errors from services:
```ruby
# WRONG - Leaking implementation details
def call
  task.save!
rescue ActiveRecord::RecordInvalid => e
  raise e  # Caller must understand AR errors
end

# RIGHT - Wrap in ServiceResult
def call
  task.save!
  ServiceResult.success(task)
rescue ActiveRecord::RecordInvalid => e
  ServiceResult.failure(e.message, errors: task.errors.full_messages)
end
```

**NEVER** make service methods public except `call`:
```ruby
# WRONG - Exposing internals
class MyService
  def call; end
  def validate_params; end  # Public - shouldn't be
  def process; end          # Public - shouldn't be
end

# RIGHT - Only .call is public
class MyService
  def self.call(...) = new(...).call
  def call; end

  private

  def validate_params; end
  def process; end
end
```

**NEVER** skip transactions for multi-step operations:
```ruby
# WRONG - Partial updates on failure
def call
  task.update!(status: 'completed')
  create_invoice(task)  # If this fails, task is still completed
  notify_customer(task)
end

# RIGHT - All or nothing
def call
  ActiveRecord::Base.transaction do
    task.update!(status: 'completed')
    create_invoice(task)
    notify_customer(task)
  end
end
```

---

## Directory Structure

```
app/services/
├── application_service.rb          # Base class
├── tasks_manager/
│   ├── create_task.rb
│   ├── assign_carrier.rb
│   └── complete_task.rb
├── billing_manager/
│   ├── generate_invoice.rb
│   └── process_payment.rb
├── notifications_manager/
│   └── send_sms.rb
└── integrations/
    └── shipping/
        └── create_label.rb
```

**Naming Convention**: `{Domain}Manager::{Action}`

---

## Base Service Class

```ruby
# app/services/application_service.rb
class ApplicationService
  def self.call(...)
    new(...).call
  end

  private

  attr_reader :params

  def initialize(**params)
    @params = params
  end
end
```

---

## Basic Service Pattern

```ruby
module TasksManager
  class CreateTask < ApplicationService
    def initialize(account:, merchant:, params:)
      @account = account
      @merchant = merchant
      @params = params
    end

    def call
      validate_params!

      ActiveRecord::Base.transaction do
        task = build_task
        task.save!
        schedule_notifications(task)
        task
      end
    end

    private

    attr_reader :account, :merchant, :params

    def validate_params!
      raise ArgumentError, "Recipient required" unless params[:recipient_id]
    end

    def build_task
      account.tasks.build(
        merchant: merchant,
        recipient_id: params[:recipient_id],
        status: 'pending'
      )
    end

    def schedule_notifications(task)
      TaskNotificationJob.perform_later(task.id)
    end
  end
end
```

---

## ServiceResult Pattern

```ruby
class ServiceResult
  attr_reader :data, :error, :errors

  def initialize(success:, data: nil, error: nil, errors: [])
    @success = success
    @data = data
    @error = error
    @errors = errors
  end

  def success? = @success
  def failure? = !@success

  def self.success(data = nil)
    new(success: true, data: data)
  end

  def self.failure(error = nil, errors: [])
    new(success: false, error: error, errors: errors)
  end
end
```

### Usage in Controller

```ruby
result = TasksManager::AssignCarrier.call(task: @task, carrier: @carrier)

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

---

## Service Interface Guidelines

| Aspect | Rule |
|--------|------|
| Entry point | Only `.call` is public |
| Dependencies | Pass via constructor |
| Return | ServiceResult or domain object |
| Side effects | Wrap in transactions |
| Validation | Fail fast with clear errors |
| External APIs | Handle timeouts, retries |

---

## Error Handling Strategy

| Error Type | Handling |
|------------|----------|
| Validation errors | Return ServiceResult.failure |
| Authorization | Raise custom AuthorizationError |
| External API | Retry with backoff, then failure |
| Database | Transaction rollback |
| Unexpected | Log, track, return generic failure |

---

## Service Checklist

Before creating a service:

```bash
# Check existing service structure
ls app/services/
ls app/services/*/ 2>/dev/null

# Review existing patterns
head -50 $(find app/services -name '*.rb' | head -1)

# Check naming conventions
grep -r 'class.*Manager' app/services/ --include='*.rb' | head -5

# Verify namespace exists
ls app/services/{namespace}/ 2>/dev/null
```

Before shipping:

- [ ] Only `.call` is public
- [ ] All side effects in transaction
- [ ] Returns ServiceResult (not raw errors)
- [ ] Dependencies injected via constructor
- [ ] Input validated at start
- [ ] Specs cover success and failure cases

---

## Quick Reference

| Pattern | Use Case |
|---------|----------|
| Basic Service | Single business operation |
| ServiceResult | Operations that can fail gracefully |
| Dry::Monads | Complex multi-step with pattern matching |
| Service Composition | Orchestrating multiple services |
| Retriable | External API calls |
| Circuit Breaker | Protect against cascading failures |

---

## References

Detailed patterns and examples in `references/`:
- `result-patterns.md` - ServiceResult, Dry::Monads, fluent API
- `error-handling.md` - Custom errors, retry, circuit breaker
- `instrumentation.md` - Logging, metrics, error tracking
- `testing.md` - RSpec patterns, shared examples, mocking
- `examples.md` - Complete service implementations

Overview

This skill is a complete guide to implementing Service Objects in Ruby on Rails applications. It provides patterns, directory layout, a base service class, ServiceResult handling, and checklist items to build predictable, testable business logic. Use it to refactor fat controllers/models and to design clear service interfaces for multi-step or external-facing operations.

How this skill works

The skill explains where to place business logic using a decision tree and prescribes a canonical app/services/ directory layout with namespaced managers. It supplies a minimal ApplicationService base, a recommended .call-only public interface, transaction handling for multi-step operations, and a ServiceResult wrapper to avoid leaking ActiveRecord or external errors. It also covers error strategies, dependency injection, and controller integration.

When to use it

  • When business logic spans multiple models or responsibilities
  • When an operation involves multiple steps or side effects that need transactions
  • When orchestrating external APIs with retries and error handling
  • When refactoring fat controllers or models to improve testability
  • When designing a clear, single-entry public interface for application operations

Best practices

  • Expose only .call as the public API; keep internals private
  • Inject dependencies via constructor parameters rather than globals
  • Wrap multi-step side effects in ActiveRecord transactions for all-or-nothing behavior
  • Return a ServiceResult (success/failure) instead of raw framework exceptions
  • Validate inputs early and fail fast with clear error messages
  • Handle external API failures with retries/backoff and convert to ServiceResult

Example use cases

  • OrdersManager::CreateOrder — build order, charge payment, reserve inventory in a transaction
  • TasksManager::AssignCarrier — validate assignment, call shipping API, update task status
  • BillingManager::ProcessPayment — call payment gateway with retries and wrap outcome
  • NotificationsManager::SendSms — enqueue job and return result without leaking errors
  • Integrations::Shipping::CreateLabel — isolate external API logic and circuit-breaker handling

FAQ

Should services return domain objects or ServiceResult?

Prefer ServiceResult for operations that can fail so callers get a consistent success/failure interface. For simple commands that always succeed, returning a domain object is acceptable.

Where do I put simple CRUD logic?

Keep straightforward single-model CRUD in the model or controller. Use a Service Object when logic spans models, has multiple steps, or needs orchestration.