返回 Skill 列表
extension
分类: 开发与工程无需 API Key

rails-business-logic

专门用于Rails业务逻辑的技能,包括ActiveInteraction、AASM状态机和ActiveDecorator。在实现复杂操作、状态转换或表示逻辑时使用。强制执行交互模式而不是服务对象。

person作者: jakexiaohubgithub

Rails Business Logic

Implement business logic with ActiveInteraction, state machines, and decorators.

When to Use This Skill

  • Creating business operations with ActiveInteraction
  • Implementing state machines with AASM
  • Adding presentation logic with ActiveDecorator
  • Refactoring service objects to interactions
  • Managing complex workflows
  • Handling state transitions
  • Composing business operations

Core Principle: Use Interactions, NOT Service Objects

Why ActiveInteraction over service objects?

  • ✓ Built-in type checking
  • ✓ Automatic validation
  • ✓ Explicit contracts (inputs/outputs)
  • ✓ Composable
  • ✓ Testable
  • ✓ Self-documenting
# Gemfile
gem "active_interaction", "~> 5.3"
gem "aasm", "~> 5.5"
gem "active_decorator", "~> 1.4"

ActiveInteraction Basics

Simple Interaction

# app/interactions/users/create.rb
module Users
  class Create < ActiveInteraction::Base
    # Define inputs with types
    string :email
    string :name
    string :password, default: nil

    # Validations
    validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
    validates :name, presence: true

    # Main logic
    def execute
      user = User.create!(
        email: email,
        name: name,
        password: password || generate_password
      )

      UserMailer.welcome(user).deliver_later

      user  # Return value becomes outcome.result
    end

    private

    def generate_password
      SecureRandom.alphanumeric(32)
    end
  end
end

Running Interactions

# In controller
def create
  outcome = Users::Create.run(
    email: params[:email],
    name: params[:name],
    password: params[:password]
  )

  if outcome.valid?
    @user = outcome.result
    redirect_to @user, notice: "User created"
  else
    @errors = outcome.errors
    render :new, status: :unprocessable_entity
  end
end

# With bang method (raises on error)
user = Users::Create.run!(email: "user@example.com", name: "John")

Input Types

class MyInteraction < ActiveInteraction::Base
  # Primitives
  string :name
  integer :age
  float :price
  boolean :active
  symbol :status

  # Objects
  date :birthday
  time :created_at
  date_time :scheduled_at

  # Complex types
  array :tags
  hash :metadata

  # Model instances
  object :user, class: User

  # Arrays of specific types
  array :emails, default: [] do
    string
  end

  # Optional (nilable)
  string :optional_field, default: nil

  # Custom default
  integer :count, default: 0
end

Composing Interactions

module Users
  class Register < ActiveInteraction::Base
    string :email
    string :name
    string :password

    def execute
      # Compose other interactions
      user = compose(Users::Create,
        email: email,
        name: name,
        password: password
      )

      # compose raises if nested interaction fails
      # errors are merged automatically

      compose(Users::SendWelcomeEmail, user: user)

      user
    end
  end
end

Testing Interactions

RSpec.describe Users::Create do
  let(:valid_params) do
    { email: "user@example.com", name: "John", password: "SecurePass123" }
  end

  context "with valid parameters" do
    it "creates user" do
      expect { described_class.run(valid_params) }
        .to change(User, :count).by(1)
    end

    it "returns valid outcome" do
      outcome = described_class.run(valid_params)
      expect(outcome).to be_valid
    end

    it "returns created user" do
      outcome = described_class.run(valid_params)
      expect(outcome.result).to be_a(User)
    end
  end

  context "with invalid parameters" do
    it "returns invalid outcome" do
      outcome = described_class.run(valid_params.merge(email: nil))
      expect(outcome).not_to be_valid
    end
  end
end

State Machines (AASM)

Basic State Machine

# app/models/order.rb
class Order < ApplicationRecord
  include AASM

  aasm column: :status do
    state :pending, initial: true
    state :paid
    state :processing
    state :shipped
    state :delivered
    state :cancelled

    event :pay do
      transitions from: :pending, to: :paid

      after do
        OrderMailer.payment_received(self).deliver_later
      end
    end

    event :process do
      transitions from: :paid, to: :processing
    end

    event :ship do
      transitions from: :processing, to: :shipped

      after do
        TrackingService.create_shipment(self)
      end
    end

    event :deliver do
      transitions from: :shipped, to: :delivered
    end

    event :cancel do
      transitions from: [:pending, :paid], to: :cancelled

      before do
        refund_payment if paid?
      end
    end
  end

  private

  def refund_payment
    PaymentService.refund(self)
  end
end

Usage

order = Order.create!
order.pending?  # => true
order.status    # => "pending"

# Trigger transitions
order.pay!
order.paid?  # => true

# Check if transition allowed
order.may_ship?  # => false (must process first)
order.may_process?  # => true

order.process!
order.ship!
order.shipped?  # => true

# Get available events
order.aasm.events  # => [:deliver, :cancel]

Guards

class Order < ApplicationRecord
  include AASM

  aasm do
    state :pending, initial: true
    state :paid

    event :pay do
      transitions from: :pending, to: :paid, guard: :payment_valid?
    end
  end

  def payment_valid?
    payment_method.present? && total > 0
  end
end

# Usage
order.pay!  # Raises AASM::InvalidTransition if guard fails
order.pay   # Returns false if guard fails (no exception)

Scopes

AASM automatically creates scopes:

Order.pending    # All pending orders
Order.paid       # All paid orders
Order.shipped    # All shipped orders

# Combine with other scopes
Order.paid.where(user: current_user)

Testing State Machines

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

  it "starts in pending state" do
    expect(order).to be_pending
  end

  describe "pay event" do
    it "transitions from pending to paid" do
      expect { order.pay! }
        .to change(order, :status).from("pending").to("paid")
    end

    it "sends payment receipt" do
      expect { order.pay! }
        .to have_enqueued_mail(OrderMailer, :payment_received)
    end
  end

  describe "ship event" do
    context "when order is paid" do
      before { order.pay!; order.process! }

      it "allows shipping" do
        expect(order.may_ship?).to be true
      end
    end

    context "when order is pending" do
      it "raises error" do
        expect { order.ship! }.to raise_error(AASM::InvalidTransition)
      end
    end
  end
end

Decorators (ActiveDecorator)

Basic Decorator

# app/decorators/user_decorator.rb
module UserDecorator
  def full_name
    "#{first_name} #{last_name}"
  end

  def avatar_url(size: :medium)
    if avatar.attached?
      helpers.url_for(avatar.variant(resize_to_limit: avatar_size(size)))
    else
      helpers.image_url("default-avatar.png")
    end
  end

  def formatted_created_at
    created_at.strftime("%B %d, %Y")
  end

  def status_badge
    case status
    when "active"
      helpers.content_tag(:span, "Active", class: "badge badge-success")
    when "inactive"
      helpers.content_tag(:span, "Inactive", class: "badge badge-secondary")
    end
  end

  private

  def avatar_size(size)
    { small: [50, 50], medium: [100, 100], large: [200, 200] }[size]
  end

  def helpers
    ActionController::Base.helpers
  end
end

Usage in Views

<%# Automatically decorated in views %>
<%= @user.full_name %>
<%= image_tag @user.avatar_url(size: :large) %>
<%= @user.formatted_created_at %>
<%= @user.status_badge %>

<%# Collections decorated automatically %>
<% @users.each do |user| %>
  <p><%= user.full_name %></p>
<% end %>

Testing Decorators

RSpec.describe UserDecorator do
  let(:user) { create(:user, first_name: "John", last_name: "Doe") }

  describe "#full_name" do
    it "combines first and last name" do
      expect(user.full_name).to eq("John Doe")
    end
  end

  describe "#formatted_created_at" do
    it "formats date" do
      user.update(created_at: Date.new(2025, 1, 15))
      expect(user.formatted_created_at).to eq("January 15, 2025")
    end
  end
end

Service Objects vs Interactions

DON'T Use Service Objects

# AVOID - Verbose service object
class UserCreationService
  def initialize(params)
    @params = params
    @errors = []
  end

  def call
    validate_params
    return false if errors.any?
    create_user
  end

  # ... lots of boilerplate
end

DO Use Interactions

# PREFER - Clean interaction
class Users::Create < ActiveInteraction::Base
  string :email
  string :name

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }

  def execute
    User.create!(email: email, name: name)
  end
end

Controller Pattern

class ArticlesController < ApplicationController
  def create
    outcome = Articles::Create.run(
      title: params[:article][:title],
      body: params[:article][:body],
      author: current_user
    )

    if outcome.valid?
      redirect_to article_path(outcome.result), notice: "Article created"
    else
      @article = Article.new(params[:article])
      @article.errors.merge!(outcome.errors)
      render :new, status: :unprocessable_entity
    end
  end
end

Reference Documentation

For comprehensive business logic patterns:

  • Business logic guide: business-logic.md (detailed examples and advanced patterns)