Service object architecture for kw-app using dry-monads Result monad. Covers when to use services, structure patterns, testing, and integration with controllers.
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).
| 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 |
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
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
# 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
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
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
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
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
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
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
# 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
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
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
Success/Failure# ❌ 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
# ❌ 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
# ❌ 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
| 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 |
Version: 2.0
Last Updated: 2024-01
Maintained By: kw-app team
npx skills add rockcodelabs/rails-service-object下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer