本文へ移動
cccskills
無料GitHub で公開日本語紹介

rails-patterns

Ruby on Railsアプリの実装・レビューで、業務処理や検索、フォームの責務を整理するスキル。データ取得の無駄を防ぎ、バックグラウンド処理や画面部品の設計も支援します。

原文原文の説明を見る

Ruby on Rails framework patterns for Rails 7.1+ and 8.x apps. Covers the directory contract, skinny controllers with service objects, form objects, query objects, idiomatic ActiveRecord, background jobs, ViewComponent, Hotwire, and the Rails 8 Solid stack. Use when building or reviewing Rails apps, controllers, models, services, jobs, or views.

インストール方法を見る

こんなときに便利

  • 大きなcontrollerの業務処理の分離
  • 複数モデルを更新するフォームの設計
  • 重複する検索やデータ取得の見直し
  • バックグラウンド処理と画面部品の設計

日本語での紹介

できること

Ruby on Railsアプリの実装・レビューで、処理の置き場所と役割分担を整理します。業務処理をservice objects、複数モデルにまたがる入力をform objects、再利用する検索をquery objectsに分ける例を示します。ActiveRecordのデータ取得、バックグラウンドジョブ、ViewComponentによる画面部品、Hotwireによる部分更新も扱います。

こんなときに便利

controllerやmodelが大きくなったとき、画面表示で同じようなデータ取得が繰り返されるとき、複雑なフォームを整理したいときに向いています。ジョブの再実行で通知や外部処理が重複しない設計や、Rails 8のSolid Queue、Solid Cache、Solid Cableの構成を検討する際にも参照できます。

使い方の例

  • 「このcontrollerの業務処理をservice objectsに分けてください」
  • 「複数モデルを更新する登録画面をform objectsで整理してください」
  • 「関連データの取得とジョブの再実行時の挙動をレビューしてください」

注意点

Rails 7.1以降と8.xを対象としています。ジョブの例では、再実行しても結果が重複しない仕組みとして外部API側の重複防止も扱います。既存データのあるテーブルに件数を保持するcounter cacheを追加する場合、過去データの補完は例の対象外です。

この紹介文は、公開されている SKILL.md をもとに AI(Claude Haiku)が作成しました。正確な仕様は下の原文を確認してください。

含まれるファイル(1)

  • SKILL.md16.1 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Rails Patterns

Framework patterns for modern Ruby on Rails applications (Rails 7.1+ and 8.x). Rails is opinionated by design; these are the patterns the community has converged on for apps that stay maintainable past the 50-model mark. This skill is the "how." For the "what" and "when" (the decisions about which pattern to reach for), see the Ruby patterns rules — rules/ruby/patterns.md in this repository, installed as rules/ecc/ruby/patterns.md.

When to Activate

  • Building a Rails application (full-stack, API-only, or hybrid)
  • Reviewing a PR that touches app/ or config/
  • Generating models, controllers, services, or jobs
  • A controller action grows past ~10 lines
  • A model file grows past ~200 lines
  • ActiveRecord queries start appearing in controllers or views

Core Concepts

The directory contract

Rails apps follow a predictable structure. Add directories deliberately, not casually.

app/
  models/         ActiveRecord models. Persistence and domain logic close to the data.
  controllers/    HTTP request handling. Thin orchestration only.
  views/          ERB templates. No business logic.
  components/     ViewComponent classes. View logic that needs tests.
  services/       Service objects. Multi-step business operations.
  forms/          Form objects. Complex form handling across multiple models.
  queries/        Query objects. Reusable, composable ActiveRecord queries.
  jobs/           Background jobs. Async work via Solid Queue, Sidekiq, or GoodJob.
  mailers/        ActionMailer classes.
  helpers/        View helpers. Tiny presentational logic only.
  policies/       Authorization policies (if using Pundit). Optional.
  channels/       ActionCable channels for WebSocket work.

Avoid app/lib/, app/utils/, app/managers/. If something does not fit the directories above, the design usually needs rethinking, not a new directory. Truly generic code goes in lib/.

Skinny controllers

Controllers receive a request, delegate to the right object, and render a response. Business logic lives elsewhere. (Per the Ruby patterns rules, extract to a service object when the controller starts carrying multiple responsibilities.)

Service objects

The default for business operations that touch more than a single model save. Conventions that keep them consistent:

  • Namespace by domain (Invoices::Create), not by suffix (InvoiceCreator).
  • A class method .call delegates to an instance #call.
  • Return a Result object, not a boolean or a bare record, so the caller can branch on success, errors, and the affected record.
  • Wrap multi-record writes in a transaction.
  • Keep each service single-purpose (Invoices::Create, Invoices::MarkPaid), never Invoices::Manager.

Form objects

When a form spans multiple models or has fields that do not map to columns, use a form object rather than nested attributes or virtual attributes on the wrong model. It quacks like a model to the view (form_with model: @form) while composing records cleanly.

Query objects

For ActiveRecord queries reused across controllers or services, or too complex for a scope, extract a query object that accepts a scope as input so it composes. Rule of thumb: a scope that grows past three chained conditions or starts taking parameters wants to be a query object.

Background jobs

Offload anything slow. (Per the Ruby patterns rules, Solid Queue for greenfield Rails 8 with modest throughput; Sidekiq when you need mature observability, high throughput, or existing Redis.) Regardless of adapter: pass IDs not records, make perform idempotent, and set retry_on/discard_on explicitly.

ViewComponent over partials

For view logic with conditional rendering, more than two arguments, or reuse across more than three places, prefer a ViewComponent. Components are testable in isolation and surface their interface explicitly; partials with deep conditional logic become debt.

Hotwire: Turbo and Stimulus

The default Rails frontend stack. (Per the Ruby patterns rules, prefer Hotwire for server-rendered apps; reach for React/Vue only when interaction complexity justifies the client surface.) Turbo Frames for partial page updates, Turbo Streams for server-driven updates, Stimulus for small client-side behaviors next to the markup.

The Rails 8 Solid stack

Rails 8 ships database-backed defaults that previously needed Redis: Solid Queue (jobs), Solid Cache (cache), Solid Cable (ActionCable). The tradeoff is more database load for one fewer infrastructure component; a good fit for modest throughput, with Redis still winning at high scale. Kamal is the default Docker-based deploy tool.

Code Examples

Skinny controller with a service object

# Bad: business logic in the controller
class InvoicesController < ApplicationController
  def create
    @invoice = Invoice.new(invoice_params)
    @invoice.user = current_user
    @invoice.line_items.build(invoice_params[:line_items])
    @invoice.tax_total = TaxCalculator.new(@invoice).calculate
    @invoice.total = @invoice.line_items.sum(&:amount) + @invoice.tax_total

    if @invoice.save
      InvoiceMailer.created(@invoice).deliver_later
      AccountingExportJob.perform_later(@invoice.id)
      redirect_to @invoice, notice: "Invoice created"
    else
      render :new
    end
  end
end

# Good: controller orchestrates, service does the work
class InvoicesController < ApplicationController
  def create
    result = Invoices::Create.call(params: invoice_params, user: current_user)

    if result.success?
      redirect_to result.invoice, notice: "Invoice created"
    else
      @invoice = result.invoice
      render :new, status: :unprocessable_entity
    end
  end
end

The service object

# app/services/invoices/create.rb
module Invoices
  class Create
    # Struct keeps this runnable on every Ruby that Rails 7.1 supports.
    # On Ruby 3.2+, `Data.define(:success?, :invoice, :errors)` is a more
    # concise immutable alternative.
    Result = Struct.new(:success, :invoice, :errors, keyword_init: true) do
      def success?
        success
      end
    end

    def self.call(params:, user:)
      new(params: params, user: user).call
    end

    def initialize(params:, user:)
      @params = params
      @user = user
    end

    def call
      invoice = build_invoice
      ApplicationRecord.transaction do
        invoice.save!
      end
      begin
        send_notifications(invoice)
      rescue StandardError => e
        Rails.logger.error("Notification dispatch failed for invoice #{invoice.id}: #{e.message}")
      end
      Result.new(success: true, invoice: invoice, errors: nil)
    rescue ActiveRecord::RecordInvalid => e
      Result.new(success: false, invoice: e.record, errors: e.record.errors)
    end

    private

    attr_reader :params, :user

    def build_invoice
      invoice = user.invoices.new(params.except(:line_items))
      invoice.line_items.build(params[:line_items])
      invoice.tax_total = TaxCalculator.call(invoice)
      invoice.total = invoice.line_items.sum(&:amount) + invoice.tax_total
      invoice
    end

    def send_notifications(invoice)
      InvoiceMailer.created(invoice).deliver_later
      AccountingExportJob.perform_later(invoice.id)
    end
  end
end

Form object

# app/forms/signup_form.rb
class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :password, :string
  attribute :company_name, :string
  attribute :terms_accepted, :boolean

  validates :email, presence: true, format: URI::MailTo::EMAIL_REGEXP
  validates :password, presence: true, length: { minimum: 12 }
  validates :company_name, presence: true
  validates :terms_accepted, acceptance: true

  attr_reader :user, :company

  def save
    return false unless valid?

    ApplicationRecord.transaction do
      @company = Company.create!(name: company_name)
      @user = @company.users.create!(email: email, password: password, role: :owner)
    end
    true
  rescue ActiveRecord::RecordInvalid => e
    errors.merge!(e.record.errors)
    false
  end
end

Query object

# app/queries/invoices/overdue.rb
module Invoices
  class Overdue
    def self.call(scope: Invoice.all, as_of: Time.current)
      new(scope: scope, as_of: as_of).call
    end

    def initialize(scope:, as_of:)
      @scope = scope
      @as_of = as_of
    end

    def call
      scope
        .where(status: :sent)
        .where(due_date: ..as_of)
        .where.not(id: paid_invoice_ids)
        .includes(:customer, :line_items)
    end

    private

    attr_reader :scope, :as_of

    def paid_invoice_ids
      Payment.where(created_at: ..as_of).pluck(:invoice_id)
    end
  end
end

Query objects accept a scope, so they compose: Invoices::Overdue.call(scope: current_user.invoices).

N+1 prevention

# Bad: N+1 in the view when it calls post.author.name
@posts = Post.published

# Good: eager load
@posts = Post.published.includes(:author)

includes lets Rails choose preload vs eager_load. Force preload for separate queries, eager_load for a JOIN when filtering on the association. Since Rails 6.1, strict_loading raises on accidental lazy loads.

Counter cache

class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true
end
add_column :posts, :comments_count, :integer, default: 0, null: false

post.comments_count becomes a column read instead of a COUNT(*). This example assumes a new table; adding a counter cache to a table that already has rows requires a backfill, which is out of scope here.

Background job shape

Pass record IDs, not records. Retries make delivery at-least-once, so any job that calls an external service must be idempotent — otherwise a transient failure after the remote call succeeds will duplicate the effect on the next attempt.

class AccountingExportJob < ApplicationJob
  queue_as :exports

  retry_on AccountingApi::TransientError, wait: :polynomially_longer, attempts: 5
  discard_on AccountingApi::PermanentError

  def perform(invoice_id)
    invoice = Invoice.find(invoice_id)
    export = AccountingExport.create_or_find_by!(
      invoice: invoice,
      idempotency_key: "invoice-export-#{invoice.id}-#{invoice.updated_at.to_i}"
    )
    return if export.completed_at?

    receipt = AccountingApi.export(invoice, idempotency_key: export.idempotency_key)
    export.update!(completed_at: Time.current, external_id: receipt.id)
  end
end
add_index :accounting_exports, :idempotency_key, unique: true

The unique index is what makes this safe: when two attempts race, the database rejects the second insert and Active Record resolves the conflict inside the call, returning the existing row. That happens without any job-level retry — retry_on above covers only AccountingApi::TransientError. The guard covers the window before the remote call; passing idempotency_key through to the API covers the window after it, so a crash between the API call and update! still resolves to a single export.

ViewComponent

# app/components/invoice_status_badge_component.rb
class InvoiceStatusBadgeComponent < ViewComponent::Base
  STATUS_CLASSES = {
    draft: "bg-gray-100 text-gray-800",
    sent: "bg-blue-100 text-blue-800",
    paid: "bg-green-100 text-green-800",
    overdue: "bg-red-100 text-red-800"
  }.freeze

  def initialize(invoice:)
    @invoice = invoice
  end

  def call
    tag.span(@invoice.status.humanize, class: "rounded-full px-2 py-1 text-sm #{status_class}")
  end

  private

  def status_class
    STATUS_CLASSES.fetch(@invoice.status.to_sym, "bg-gray-100")
  end
end
<%= render InvoiceStatusBadgeComponent.new(invoice: @invoice) %>

Hotwire

<%# Turbo Frame: clicking Edit replaces only this frame %>
<%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
  <div class="invoice">
    <%= link_to "Edit", edit_invoice_path(@invoice) %>
  </div>
<% end %>
<%# Turbo Stream: app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "form", locals: { comment: Comment.new } %>
// app/javascript/controllers/copy_to_clipboard_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["source"]

  copy() {
    navigator.clipboard.writeText(this.sourceTarget.value)
  }
}

Acceptable vs unacceptable callbacks

# Acceptable: pure data normalization
class User < ApplicationRecord
  before_validation :normalize_email

  private

  def normalize_email
    self.email = email.to_s.downcase.strip
  end
end

# Move to a service instead: side effects hidden in a callback
# class User < ApplicationRecord
#   after_create :send_welcome_email  # hard to opt out of, hard to test
# end

Good concern vs bad concern

# Good: genuinely cross-cutting, reusable across unrelated models
# app/models/concerns/soft_deletable.rb
module SoftDeletable
  extend ActiveSupport::Concern

  included do
    scope :active, -> { where(deleted_at: nil) }
    scope :deleted, -> { where.not(deleted_at: nil) }
  end

  def soft_delete! = update!(deleted_at: Time.current)
  def restore! = update!(deleted_at: nil)
end

# Bad: a "concern" used by exactly one model, holding logic that belongs on it
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
  extend ActiveSupport::Concern

  def calculate_total
    line_items.sum(&:amount) + tax_total
  end
end
# Only Invoice includes this. It isn't cross-cutting; it's Invoice's own logic
# hidden in a module for the appearance of a "skinny" model. Put it back on Invoice.

A concern used by only one class is just moving code; it belongs in that class. A concern should be reusable across at least two unrelated models.

Anti-Patterns

God controllers

Any controller past ~80 lines is doing too much. Split actions across controllers or extract to services.

Fat models with 30+ methods

Models should know about their own data. Methods that orchestrate other models, send notifications, or coordinate workflows belong in services.

Callback chains

after_save :update_cache, :send_notifications, :enqueue_export is the start of a debugging nightmare. Move them into a service that runs them explicitly.

Nested attributes for complex forms

accepts_nested_attributes_for is fine for simple cases. For conditional validation or cross-model logic, use a form object.

Default scopes on critical models

default_scope { where(deleted: false) } silently excludes records from every query in the app, including the ones you need for support and debugging. Prefer an explicit named scope.

Models named after database concepts

UserRole, OrderStatus, InvoiceState are usually enum candidates, not models.

Reaching for a JS framework before Hotwire

If the page is server-rendered with occasional interactivity, Hotwire ships faster. Reserve React/Vue for genuinely SPA-shaped apps.

Best Practices

  • Keep controllers thin; push business logic into services.
  • Return Result objects from services so callers branch on outcome, not exceptions.
  • Wrap multi-record writes in a transaction; let notification/side-effect failures log without breaking the primary write.
  • Pass IDs to jobs, keep perform idempotent, set retry/discard explicitly.
  • Default to eager loading; treat an accidental N+1 as a bug, not a nuisance.
  • Reserve concerns for behavior shared across at least two unrelated models.
  • Reach for Hotwire before a client-side framework on server-rendered apps.

Related Skills

  • backend-patterns — service boundaries and adapter patterns (referenced by the Ruby patterns rules)
  • Ruby patterns rules (rules/ruby/patterns.md, installed as rules/ecc/ruby/patterns.md) — the decisions and when-to-use guidance this skill implements

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

accessibility

無料日本語概要

Web・iOS・Androidの画面を、読み上げやキーボード操作に対応させ、ラベル、配色、操作対象の大きさなどをWCAG 2.2に沿って設計・点検するスキル。

  • アイコンボタンの説明を付けたいとき
  • キーボード操作とモーダルの点検
  • コントラストや操作対象の大きさの確認
affaan-m/ECC27.8万2026年10月12日 更新

agent-architecture-audit

無料日本語概要

AIエージェントの不調を、指示・記憶・ツール実行・画面表示など12の層から調べるスキル。コードやログを根拠に原因を整理し、重要度順の指摘と修正案をまとめます。

  • アプリ内だけで起きる不調を調べたいとき
  • 過去の会話が混ざる原因を調査
  • ツールの未実行や実行の誤報を確認
affaan-m/ECC27.8万2026年10月12日 更新

agent-eval

無料日本語概要

実際の開発課題で複数のコーディングエージェントを比較するスキル。成功率、取得可能なAPI費用、所要時間、繰り返し実行の安定性を測り、選定や更新後の評価に使えます。

  • 実際の開発課題でエージェントを比較
  • 新しいツールやモデルの導入前評価
  • エージェント更新後の性能確認
affaan-m/ECC27.8万2026年10月12日 更新

agent-harness-construction

無料日本語概要

AIエージェントが使うツールの種類や入出力、エラーからの復帰手順を設計・見直します。文脈の情報量も整理し、作業完了率や再試行回数で改善を評価します。

  • エージェントのツールや入力形式の設計
  • ツールの結果と次の行動を明確にしたいとき
  • 安全な再試行と停止条件を定めたいとき
affaan-m/ECC27.8万2026年10月12日 更新

agent-introspection-debugging

無料日本語概要

AIエージェントの失敗や同じ操作の繰り返しを記録し、原因の切り分け、小さな復旧操作、結果の報告まで進める手順を示して、根拠のある再試行につなげるスキル。

  • 同じツール操作を繰り返す原因の調査
  • 会話の肥大化による品質低下の調査
  • ファイルパスや環境の食い違いの確認
affaan-m/ECC27.8万2026年10月12日 更新

agent-introspection-debugging

無料日本語概要

AIエージェントが失敗や同じ操作を繰り返す原因を、エラーと実行状況から整理します。小さな復旧操作を試し、結果と根拠を引き継げる報告にまとめるスキルです。

  • エージェントの連続失敗を診断したいとき
  • 同じツール操作のループ調査
  • 情報の蓄積による出力劣化の点検
affaan-m/ECC27.8万2026年10月5日 更新

affaan-m のスキルをすべて見る

このスキルの問題を報告する