smithery/rockcodelabs

rails-service-object

Service object architecture for kw-app using dry-monads Result monad. Covers when to use services, structure patterns, testing, and integration with controllers.

Installation

$ npx skills add smithery/rockcodelabs --skill rails-service-object

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery/rockcodelabs.

npx skills add smithery/rockcodelabs

Browse all from smithery/rockcodelabs

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Skill metadata

Parsed from SKILL.md frontmatter.

Allowed toolsRead, Write, Edit, Bash

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 15,419 B
  • docs SUMMARY.md 189 B

History

  1. First recorded snapshot · 0 installs

SKILL.md

Rails Service Object Pattern (kw-app)

Overview

Service objects encapsulate complex business logic that doesn't belong in models or controllers.

kw-app Standard: All services MUST use dry-monads (Success/Failure).

When to Use Service Objects

Scenario Use Service Object? Alternative
Multiple model interactions ✅ Yes -
Complex business logic ✅ Yes -
External API calls ✅ Yes -
Multi-step operations ✅ Yes -
Transaction required ✅ Yes -
Simple CRUD ❌ No Use model directly
Single validation ❌ No Use model validation
View formatting ❌ No Use presenter/helper

Decision Tree

Where should this logic go?

Is it business logic?
├─ No → Controller/View/Helper
└─ Yes → Continue...

Does it involve multiple models?
├─ Yes → Service Object
└─ No → Continue...

Is it complex (>10 lines)?
├─ Yes → Service Object
└─ No → Model method

Does it call external APIs?
├─ Yes → Service Object
└─ No → Continue...

Does it need transaction?
├─ Yes → Service Object
└─ No → Model method

Structure

Naming Convention

app/components/
├── users/
│   └── operation/
│       ├── create.rb         # Users::Operation::Create
│       ├── update.rb         # Users::Operation::Update
│       └── activate.rb       # Users::Operation::Activate
└── payments/
    └── operation/
        ├── charge.rb         # Payments::Operation::Charge
        └── refund.rb         # Payments::Operation::Refund

Pattern: Namespace::Operation::Action

Basic Template

# app/components/users/operation/create.rb
module Users
  module Operation
    class Create
      include Dry::Monads[:result, :do]

      def call(params:)
        validated = yield validate(params)
        user      = yield persist(validated)
        
        Success(user)
      end

      private

      def validate(params)
        contract = Users::Contract::Create.new.call(params)
        return Failure(contract.errors.to_h) unless contract.success?
        Success(contract.to_h)
      end

      def persist(attrs)
        user = User.create(attrs)
        user.persisted? ? Success(user) : Failure(user.errors)
      end
    end
  end
end

Patterns

Pattern 1: Simple CRUD Service

module Users
  module Operation
    class Create
      include Dry::Monads[:result, :do]

      def call(params:, current_user:)
        validated = yield validate(params)
        user      = yield persist(validated, current_user)
        yield send_welcome_email(user)
        
        Success(user)
      end

      private

      def validate(params)
        contract = Users::Contract::Create.new.call(params)
        return Failure([:validation_failed, contract.errors.to_h]) unless contract.success?
        Success(contract.to_h)
      end

      def persist(attrs, creator)
        user = User.new(attrs.merge(created_by: creator))
        user.save ? Success(user) : Failure([:save_failed, user.errors])
      end

      def send_welcome_email(user)
        UserMailer.welcome(user).deliver_later
        Success(:email_queued)
      rescue => e
        # Don't fail the whole operation if email fails
        Rails.logger.error("Failed to send welcome email: #{e.message}")
        Success(:email_skipped)
      end
    end
  end
end

Pattern 2: Service with Transaction

module Orders
  module Operation
    class Create
      include Dry::Monads[:result, :do]

      def call(user:, cart:)
        return Failure([:empty_cart, "Cart is empty"]) if cart.empty?

        order = nil
        ActiveRecord::Base.transaction do
          order = yield create_order(user, cart)
          yield create_order_items(order, cart)
          yield charge_payment(order, user)
          yield clear_cart(cart)
        end

        Success(order)
      rescue ActiveRecord::RecordInvalid => e
        Failure([:record_invalid, e.message])
      rescue PaymentError => e
        Failure([:payment_failed, e.message])
      end

      private

      def create_order(user, cart)
        order = user.orders.create!(
          total: cart.total,
          status: :pending
        )
        Success(order)
      end

      def create_order_items(order, cart)
        cart.items.each do |item|
          order.order_items.create!(
            product: item.product,
            quantity: item.quantity,
            price: item.price
          )
        end
        Success(order)
      end

      def charge_payment(order, user)
        PaymentGateway.charge(user: user, amount: order.total)
        order.update!(status: :paid)
        Success(order)
      end

      def clear_cart(cart)
        cart.clear!
        Success(:cleared)
      end
    end
  end
end

Pattern 3: Service with Dependencies

module Notifications
  module Operation
    class Send
      include Dry::Monads[:result, :do]

      def initialize(notifier: default_notifier, logger: Rails.logger)
        @notifier = notifier
        @logger = logger
      end

      def call(user:, message:)
        return Failure([:disabled, "User has notifications disabled"]) unless user.notifications_enabled?

        result = yield send_notification(user, message)
        yield log_notification(user, result)

        Success(result)
      end

      private

      attr_reader :notifier, :logger

      def default_notifier
        Rails.env.test? ? NullNotifier.new : PushNotifier.new
      end

      def send_notification(user, message)
        notifier.deliver(user: user, message: message)
        Success(:delivered)
      rescue NotificationError => e
        Failure([:delivery_failed, e.message])
      end

      def log_notification(user, result)
        logger.info("Notification sent to user #{user.id}: #{result}")
        Success(:logged)
      end
    end
  end
end

Pattern 4: Calculation Service

module Entities
  module Operation
    class CalculateRating
      include Dry::Monads[:result]

      def call(entity:)
        average = calculate_average(entity)
        
        if entity.update(average_rating: average, submissions_count: count_submissions(entity))
          Success(average)
        else
          Failure([:update_failed, entity.errors])
        end
      end

      private

      def calculate_average(entity)
        return 0.0 if entity.submissions.empty?
        entity.submissions.average(:rating).to_f.round(1)
      end

      def count_submissions(entity)
        entity.submissions.count
      end
    end
  end
end

Controller Integration

Standard Pattern

class UsersController < ApplicationController
  def create
    result = Users::Operation::Create.new.call(
      params: user_params,
      current_user: current_user
    )

    case result
    in Success(user)
      redirect_to user, notice: 'User created successfully'
    in Failure([:validation_failed, errors])
      @errors = errors
      render :new, status: :unprocessable_entity
    in Failure([:save_failed, errors])
      @errors = errors
      render :new, status: :unprocessable_entity
    in Failure(error)
      redirect_to users_path, alert: "Error: #{error}"
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :name)
  end
end

With Detailed Error Handling

class OrdersController < ApplicationController
  def create
    result = Orders::Operation::Create.new.call(
      user: current_user,
      cart: current_cart
    )

    case result
    in Success(order)
      redirect_to order, notice: 'Order placed successfully'
    in Failure([:empty_cart, message])
      redirect_to cart_path, alert: message
    in Failure([:payment_failed, message])
      flash[:error] = "Payment failed: #{message}"
      render :review, status: :unprocessable_entity
    in Failure([:record_invalid, message])
      flash[:error] = "Order error: #{message}"
      render :review, status: :unprocessable_entity
    in Failure(error)
      Bugsnag.notify(error)
      redirect_to cart_path, alert: 'An error occurred'
    end
  end
end

Testing

Basic Service Spec

# spec/components/users/operation/create_spec.rb
require 'rails_helper'

RSpec.describe Users::Operation::Create do
  subject(:operation) { described_class.new }

  describe '#call' do
    let(:params) { { email: '[email protected]', name: 'Test' } }
    let(:current_user) { create(:user) }

    context 'with valid params' do
      it 'returns Success with user' do
        result = operation.call(params: params, current_user: current_user)

        expect(result).to be_success
        expect(result.success).to be_a(User)
        expect(result.success.email).to eq('[email protected]')
      end

      it 'creates a user record' do
        expect {
          operation.call(params: params, current_user: current_user)
        }.to change(User, :count).by(1)
      end

      it 'sets created_by' do
        result = operation.call(params: params, current_user: current_user)
        expect(result.success.created_by).to eq(current_user)
      end
    end

    context 'with invalid params' do
      let(:params) { { email: '', name: '' } }

      it 'returns Failure with validation errors' do
        result = operation.call(params: params, current_user: current_user)

        expect(result).to be_failure
        
        case result
        in Failure([:validation_failed, errors])
          expect(errors).to include(:email, :name)
        else
          fail "Expected validation_failed, got #{result}"
        end
      end

      it 'does not create a user' do
        expect {
          operation.call(params: params, current_user: current_user)
        }.not_to change(User, :count)
      end
    end
  end
end

Testing with Mocked Dependencies

RSpec.describe Notifications::Operation::Send do
  subject(:operation) { described_class.new(notifier: notifier, logger: logger) }

  let(:notifier) { instance_double(PushNotifier) }
  let(:logger) { instance_double(Logger) }
  let(:user) { create(:user, notifications_enabled: true) }
  let(:message) { 'Test notification' }

  before do
    allow(notifier).to receive(:deliver).and_return(true)
    allow(logger).to receive(:info)
  end

  describe '#call' do
    it 'sends notification' do
      result = operation.call(user: user, message: message)

      expect(result).to be_success
      expect(notifier).to have_received(:deliver).with(user: user, message: message)
    end

    context 'when notification fails' do
      before do
        allow(notifier).to receive(:deliver).and_raise(NotificationError, 'Network error')
      end

      it 'returns Failure' do
        result = operation.call(user: user, message: message)

        expect(result).to be_failure
        
        case result
        in Failure([:delivery_failed, message])
          expect(message).to eq('Network error')
        else
          fail "Expected delivery_failed, got #{result}"
        end
      end
    end
  end
end

Testing Transactions

RSpec.describe Orders::Operation::Create do
  subject(:operation) { described_class.new }

  let(:user) { create(:user) }
  let(:cart) { create(:cart, :with_items, user: user) }

  describe '#call' do
    context 'when payment fails' do
      before do
        allow(PaymentGateway).to receive(:charge).and_raise(PaymentError, 'Card declined')
      end

      it 'does not create order (rollback)' do
        expect {
          operation.call(user: user, cart: cart)
        }.not_to change(Order, :count)
      end

      it 'does not clear cart (rollback)' do
        expect {
          operation.call(user: user, cart: cart)
        }.not_to change { cart.reload.items.count }
      end

      it 'returns Failure' do
        result = operation.call(user: user, cart: cart)

        expect(result).to be_failure
        
        case result
        in Failure([:payment_failed, message])
          expect(message).to include('Card declined')
        else
          fail "Expected payment_failed, got #{result}"
        end
      end
    end
  end
end

Best Practices

✅ Always Do

  • Use dry-monads Success/Failure
  • Use do-notation for chaining
  • Write comprehensive tests
  • Follow single responsibility
  • Use dependency injection
  • Handle all error cases
  • Use descriptive failure codes

⚠️ Ask First

  • Modifying existing services used by multiple controllers
  • Adding external API dependencies
  • Changing service interfaces
  • Adding database transactions

🚫 Never Do

  • Mix exceptions with monads
  • Create services without tests
  • Put presentation logic in services
  • Use custom Result classes (deprecated)
  • Skip error handling
  • Create "god" services with too many responsibilities

Common Mistakes

❌ Mistake 1: Not using :do notation

# ❌ Wrong - verbose
def call(params:)
  validation_result = validate(params)
  return validation_result if validation_result.failure?
  
  persist_result = persist(validation_result.success)
  return persist_result if persist_result.failure?
  
  persist_result
end

Fix:

# ✅ Correct - clean with :do
def call(params:)
  validated = yield validate(params)
  user      = yield persist(validated)
  Success(user)
end

❌ Mistake 2: Returning different types

# ❌ Wrong - inconsistent returns
def call(params:)
  return false unless valid?(params)  # Returns boolean
  Success(create_user(params))         # Returns monad
end

Fix:

# ✅ Correct - always return monad
def call(params:)
  return Failure(:invalid) unless valid?(params)
  Success(create_user(params))
end

❌ Mistake 3: Too many responsibilities

# ❌ Wrong - god service
class Users::Process
  def call
    # Validates
    # Creates user
    # Sends email
    # Updates stats
    # Logs analytics
    # Notifies admin
    # ... 500 more lines
  end
end

Fix:

# ✅ Correct - split responsibilities
class Users::Operation::Create
  def call
    user = yield create_user
    yield Users::Operation::SendWelcomeEmail.new.call(user: user)
    yield Users::Operation::UpdateStats.new.call(user: user)
    Success(user)
  end
end

Quick Reference

Pattern Use Case Example
Simple do-notation Multi-step success path User creation
With transaction Multi-model changes Order processing
With dependencies External services Notifications
Calculation Data transformation Rating calculation

Additional Resources

  • dry-monads patterns: See [dry-monads-patterns skill](../dry-monads-patterns/SKILL.md)
  • Testing standards: See [testing-standards skill](../testing-standards/SKILL.md)
  • kw-app policy: See CLAUDE.md section on services
  • Official dry-rb: https://dry-rb.org/gems/dry-monads/

Version: 2.0 Last Updated: 2024-01 Maintained By: kw-app team