Best Practices

Table of contents

  1. When to Use Operations
  2. Design Principles
    1. Naming Conventions
    2. Single Responsibility
    3. Parameter Design
  3. Testing
    1. Basic Operation Tests (RSpec)
    2. Testing with Dry::Monads
    3. Testing Composed Operations
    4. Mocking Dependencies
    5. Testing Partial Application
    6. Testing Authorization
  4. Error Handling
    1. Choosing Between Exceptions and Results
    2. Custom Exception Classes
    3. Result Type Patterns
    4. Mixing Exceptions and Results
    5. Retry Strategies
  5. Organizing Operations
    1. Directory Structure
    2. Shared Base Classes
    3. Common Mixins
  6. Common Patterns
    1. Builder Pattern
    2. Query Object
    3. Service Wrapper

Guidelines for testing, error handling, and organizing operations.

When to Use Operations

✅ Good candidates:

  • Complex business logic with multiple steps
  • Type-safe inputs with explicit contracts
  • Reusable logic across controllers, jobs, services
  • Coordination between multiple models/services
  • Authorization checks

❌ Not ideal:

  • Simple CRUD with no business logic (use ActiveRecord)
  • Single-line transformations
  • Framework-specific concerns (callbacks, validations)
  • View logic (use helpers/presenters)

Design Principles

Naming Conventions

Use verb-noun format:

# ✅ Good
CreateUserOperation
ProcessRefundOperation
GenerateReportOperation

# ❌ Bad
UserCreator
RefundProcessor

Single Responsibility

Split large operations:

# ✅ Good - Focused operations
class ProcessOrderOperation < ApplicationOperation
  include Dry::Monads::Do.for(:perform)

  def perform
    yield ChargePaymentOperation.call(order: order)
    yield UpdateInventoryOperation.call(order: order)
    yield SendConfirmationOperation.call(order: order)
    Success(order)
  end
end

Parameter Design

class SearchUsersOperation < TypedOperation::Base
  param :query, String
  param :limit, Integer, default: 25
  param :offset, Integer, default: 0
  param :sort_by, optional(String)
  param :filters, Hash, default: -> { {} }  # Use proc for mutable defaults
end

Testing

Basic Operation Tests (RSpec)

RSpec.describe Users::CreateOperation do
  it 'creates a user' do
    result = described_class.call(email: 'test@example.com', name: 'Test')
    expect(result.email).to eq('test@example.com')
  end

  it 'raises type error for invalid input' do
    expect {
      described_class.call(email: nil, name: 'Test')
    }.to raise_error(Literal::TypeError)
  end
end

Testing with Dry::Monads

RSpec.describe Orders::ProcessOperation do
  let(:order) { create(:order) }

  context 'when successful' do
    it 'returns Success' do
      result = described_class.call(order: order)
      expect(result).to be_success
      expect(result.value!).to eq(order)
    end
  end

  context 'when payment fails' do
    before { allow(PaymentGateway).to receive(:charge).and_return(false) }

    it 'returns Failure with error code' do
      result = described_class.call(order: order)
      expect(result).to be_failure
      code, _message = result.failure
      expect(code).to eq(:payment_failed)
    end
  end
end

Testing Composed Operations

RSpec.describe RegisterUserOperation do
  it 'stops on first failure' do
    allow(CreateUserOperation).to receive(:call).and_return(Failure(:error))
    allow(SendWelcomeEmailOperation).to receive(:call)

    result = described_class.call(email: 'test@example.com', password: 'pass')

    expect(result).to be_failure
    expect(SendWelcomeEmailOperation).not_to have_received(:call)
  end
end

Mocking Dependencies

# Inject dependencies as params for testability
class ProcessPaymentOperation < TypedOperation::Base
  param :order, Order
  param :gateway, Object, default: -> { PaymentGateway.new }

  def perform
    gateway.charge(order.total)
  end
end

RSpec.describe ProcessPaymentOperation do
  it 'uses gateway' do
    mock_gateway = instance_double(PaymentGateway, charge: true)
    described_class.call(order: create(:order), gateway: mock_gateway)
    expect(mock_gateway).to have_received(:charge)
  end
end

Testing Partial Application

RSpec.describe SendEmailOperation do
  it 'pre-fills parameters' do
    base_op = described_class.with(from: 'noreply@example.com')
    allow(Mailer).to receive(:send_email)

    base_op.call(to: 'user@example.com', subject: 'Test', body: 'Body')

    expect(Mailer).to have_received(:send_email)
      .with(hash_including(from: 'noreply@example.com'))
  end
end

Testing Authorization

RSpec.describe Posts::UpdateOperation do
  it 'raises error when unauthorized' do
    allow_any_instance_of(PostPolicy).to receive(:update?).and_return(false)

    expect {
      described_class.call(post: create(:post), initiator: create(:user), title: 'New')
    }.to raise_error(ActionPolicy::Unauthorized)
  end
end

Error Handling

Choosing Between Exceptions and Results

Use exceptions for:

  • Programming errors (type errors, nil references)
  • Invariant violations (data integrity)
  • Configuration issues
  • Truly exceptional cases that should halt execution

Use Result types for:

  • Expected failure cases (validation, authorization)
  • Business rule violations
  • External service failures
  • Flows where caller needs granular error handling

Custom Exception Classes

Define operation-specific exceptions for domain clarity:

class TransferFundsOperation < TypedOperation::Base
  class InsufficientFundsError < StandardError; end
  class AccountFrozenError < StandardError; end

  param :from_account, Account
  param :to_account, Account
  param :amount_cents, Integer

  def perform
    raise InsufficientFundsError if from_account.balance_cents < amount_cents
    raise AccountFrozenError if from_account.frozen?

    # ... perform transfer
  end
end

Result Type Patterns

Use symbolic error codes for pattern matching:

class AuthenticateUserOperation < TypedOperation::Base
  include Dry::Monads[:result]

  param :email, String
  param :password, String

  def perform
    user = User.find_by(email: email)
    return Failure(:user_not_found) unless user
    return Failure(:account_locked) if user.locked?
    return Failure(:invalid_password) unless user.authenticate(password)
    Success(user)
  end
end

Mixing Exceptions and Results

Use exceptions in prepare for invariants, Results in perform for business logic:

class CreateBookingOperation < TypedOperation::Base
  include Dry::Monads[:result]

  param :room, Room
  param :start_date, Date
  param :end_date, Date

  def prepare
    raise ArgumentError, "End date must be after start" if end_date <= start_date
  end

  def perform
    return Failure(:room_unavailable) unless room.available?(start_date, end_date)
    booking = Booking.create!(room: room, start_date: start_date, end_date: end_date)
    Success(booking)
  end
end

Retry Strategies

class SendNotificationOperation < TypedOperation::Base
  include Dry::Monads[:result]

  param :user, User
  param :max_retries, Integer, default: 3

  def perform
    retries = 0
    begin
      NotificationService.send(user)
      Success(true)
    rescue NotificationService::TemporaryError
      retries += 1
      retry if retries < max_retries
      Failure(:max_retries_exceeded)
    end
  end
end

Organizing Operations

Directory Structure

app/operations/
├── application_operation.rb
├── users/
│   ├── create_operation.rb
│   └── update_operation.rb
└── orders/
    ├── process_operation.rb
    └── refund_operation.rb

Shared Base Classes

class ApplicationOperation < TypedOperation::Base
  include Dry::Monads[:result]
  include Dry::Monads::Do.for(:perform)
end

module Users
  class BaseOperation < ApplicationOperation
    param :current_user, optional(User)
  end
end

Common Mixins

module Paginatable
  extend ActiveSupport::Concern

  included do
    param :page, Integer, default: 1
    param :per_page, Integer, default: 25
  end

  def pagination_offset
    (page - 1) * per_page
  end
end

class SearchUsersOperation < ApplicationOperation
  include Paginatable
  param :query, String

  def perform
    User.where("name LIKE ?", "%#{query}%")
        .limit(per_page)
        .offset(pagination_offset)
  end
end

Common Patterns

Builder Pattern

report_builder = BuildReportOperation.with(user: current_user, start_date: 1.month.ago)

pdf_report = report_builder.call(end_date: Date.today, format: "pdf")
csv_report = report_builder.call(end_date: Date.today, format: "csv")

Query Object

class FindActiveUsersOperation < TypedOperation::Base
  param :role, optional(String)
  param :limit, Integer, default: 100

  def perform
    scope = User.active
    scope = scope.where(role: role) if role
    scope.limit(limit)
  end
end

Service Wrapper

class FetchWeatherDataOperation < TypedOperation::Base
  include Dry::Monads[:result]
  param :city, String

  def perform
    response = WeatherAPI.fetch(city: city)
    response.success? ? Success(response.data) : Failure(:api_error)
  rescue => e
    Failure(:network_error)
  end
end