AgentSkillsCN

rails-service-object

kw-app 服务对象架构,基于 dry-monads Result monad。涵盖何时使用服务、结构化模式、测试方法,以及与控制器的集成方式。

SKILL.md
--- frontmatter
name: rails-service-object
description: Service object architecture for kw-app using dry-monads Result monad. Covers when to use services, structure patterns, testing, and integration with controllers.
allowed-tools: Read, Write, Edit, Bash

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

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

Decision Tree

code
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

code
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

ruby
# 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

ruby
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

ruby
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

ruby
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

ruby
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

ruby
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

ruby
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

ruby
# 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: 'test@example.com', 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('test@example.com')
      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

ruby
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

ruby
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

ruby
# ❌ 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:

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

❌ Mistake 2: Returning different types

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

Fix:

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

❌ Mistake 3: Too many responsibilities

ruby
# ❌ 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:

ruby
# ✅ 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

PatternUse CaseExample
Simple do-notationMulti-step success pathUser creation
With transactionMulti-model changesOrder processing
With dependenciesExternal servicesNotifications
CalculationData transformationRating calculation

Additional Resources


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