Agent Skills by ALSEL
汎用ソフトウェア開発⭐ リポ 2品質スコア 64/100

rails-patterns

Ruby on Railsプロジェクトで使用します。モデル、コントローラー、サービス、Rails Enginesアーキテクチャのパターンを提供できます。

description の原文を見る

Use when working with Ruby on Rails projects - provides patterns for models, controllers, services, and Rails Engines architecture

SKILL.md 本文

Rails Patterns Skill

Authority: FINAL

Purpose

Rails Engineを活用したモジュール型アプリケーション開発を通じて、Ruby on Railsの開発パターン、アーキテクチャ、およびベストプラクティスについて深い専門知識を習得します。

Core Principles

  1. Convention Over Configuration - 一貫性のためにRailsの規約に従う
  2. DRY (Don't Repeat Yourself) - 共通パターンを再利用可能なコンポーネントに抽出
  3. Fat Models, Skinny Controllers - ビジネスロジックはモデルに、コントローラは調整役
  4. Service Objects for Complex Logic - 複数モデルの操作は抽出する
  5. Modularity with Engines - 保守性と独立性を持つコンポーネントを構築

Rails Engines (CRITICAL SECTION)

Rails Enginesはホストアプリケーションに機能を提供するミニアプリケーションです。モジュール型で保守性の高いRailsアプリケーションを構築するための基礎であり、DeviseやSpreeなどのような再利用可能なgemを作成するために使用されます。

Engine Types

Mountable Engine

Mountable Engineはホストアプリケーションから分離され、独自の名前空間を持ちます。完全に独立したアプリケーションのように機能します。

Creation:

rails plugin new my_engine --mountable

Characteristics:

  • isolate_namespaceで分離された名前空間
  • 特定のパスにマウントされた独自のルート
  • 名前空間付きディレクトリ内の独自のモデル、コントローラ、ビュー
  • エンジン名でプレフィックス付けされたデータベーステーブル
  • 親アプリから独立(最小限の結合度)

Directory Structure:

my_engine/
├── app/
│   ├── controllers/
│   │   └── my_engine/
│   │       ├── application_controller.rb
│   │       └── posts_controller.rb
│   ├── models/
│   │   └── my_engine/
│   │       └── post.rb
│   └── views/
│       └── my_engine/
│           └── posts/
├── config/
│   └── routes.rb
├── db/
│   └── migrate/
├── lib/
│   ├── my_engine/
│   │   ├── engine.rb
│   │   └── version.rb
│   └── my_engine.rb
├── test/
└── my_engine.gemspec

When to Use:

  • 複数のアプリケーション向けの再利用可能な機能の構築
  • 管理画面またはCMSシステムの作成
  • チーム組織のための大規模機能の分離
  • 機能をgemに抽出する予定がある
  • 親アプリから完全に分離する必要がある

Full Engine

Full Engineは親アプリケーションと名前空間を共有し、分離度が低くなります。

Creation:

rails plugin new my_engine --full

Characteristics:

  • 親アプリと名前空間を共有
  • モデル/コントローラが自動的に名前空間化されない
  • 親アプリとの結合度がより高い
  • 親アプリ機能への簡単なアクセス
  • 内部モジュール化には以下

Directory Structure:

my_engine/
├── app/
│   ├── controllers/
│   │   └── posts_controller.rb  # 名前空間なし
│   ├── models/
│   │   └── post.rb  # 名前空間なし
│   └── views/
│       └── posts/
├── config/
│   └── routes.rb
├── lib/
│   ├── my_engine/
│   │   └── engine.rb
│   └── my_engine.rb
└── my_engine.gemspec

When to Use:

  • 内部モジュール化のみ
  • 親アプリクラスへの簡単なアクセスが必要
  • 厳密な分離が不要
  • gemとして配布しない
  • エンジン機能のプロトタイピング

Comparison Table

FeatureMountable EngineFull Engine
名前空間の分離あり (isolate_namespace)なし
ルートのマウント必須オプション
テーブルプレフィックスあり (my_engine_posts)なし (posts)
Gemとしての配布理想的可能だが理想的ではない
親アプリへのアクセス間接的(設定経由)直接
複雑さ高い低い
再利用性高い中程度

Engine Structure and Organization

Engine Class Definition

lib/my_engine/engine.rb:

module MyEngine
  class Engine < ::Rails::Engine
    isolate_namespace MyEngine

    # エンジン設定
    config.generators do |g|
      g.test_framework :rspec
      g.fixture_replacement :factory_bot
      g.factory_bot dir: 'spec/factories'
    end

    # 親アプリからデコレータをロード
    config.to_prepare do
      Dir.glob(Engine.root.join("app", "decorators", "**", "*_decorator*.rb")).each do |c|
        Rails.configuration.cache_classes ? require(c) : load(c)
      end
    end

    # エンジンアセットをプリコンパイル
    initializer "my_engine.assets.precompile" do |app|
      app.config.assets.precompile += %w( my_engine/application.js my_engine/application.css )
    end

    # エンジンパスを親アプリに追加
    initializer "my_engine.add_middleware" do |app|
      app.middleware.use MyEngine::Middleware::CustomMiddleware
    end
  end
end

Namespace Isolation

isolate_namespaceの役割:

module MyEngine
  class Engine < ::Rails::Engine
    isolate_namespace MyEngine
  end
end

Effects:

  1. Routes: すべてのルートがMyEngineの下に名前空間化される
  2. Tables: データベーステーブルにmy_engine_プレフィックスが付く
  3. Controllers: MyEngine::ApplicationControllerから継承
  4. Models: MyEngineモジュール内に含まれる
  5. Helpers: MyEngineの下に名前空間化される
  6. URL Helpers: エンジン名でプレフィックス付け

Engine Configuration

親アプリの config/initializers/my_engine.rb:

MyEngine.configure do |config|
  config.default_locale = :en
  config.max_items_per_page = 25
  config.enable_caching = Rails.env.production?

  # 親アプリのモデル/コントローラを提供
  config.user_class = "User"
  config.admin_class = "Admin"

  # コールバック
  config.after_create_post = ->(post) { NotificationService.notify(post) }
end

lib/my_engine.rb:

require "my_engine/engine"

module MyEngine
  mattr_accessor :default_locale, :max_items_per_page, :enable_caching
  mattr_accessor :user_class, :admin_class, :after_create_post

  def self.configure
    yield self
  end

  # 設定されたクラス名を解決
  def self.user_class_constant
    user_class.constantize
  end

  def self.admin_class_constant
    admin_class.constantize
  end
end

Mounting Engines

Routes Configuration

親アプリの config/routes.rb:

Rails.application.routes.draw do
  # ルートパスでマウント
  mount MyEngine::Engine => "/"

  # 特定のパスでマウント
  mount MyEngine::Engine => "/blog", as: "blog"

  # 制約付きでマウント
  mount MyEngine::Engine => "/admin", constraints: { subdomain: "admin" }

  # 複数エンジンのマウント
  mount BlogEngine::Engine => "/blog"
  mount ForumEngine::Engine => "/forum"
  mount ShopEngine::Engine => "/shop"
end

Engine Routes

Engine config/routes.rb:

MyEngine::Engine.routes.draw do
  root to: "posts#index"

  resources :posts do
    resources :comments
    member do
      post :publish
      post :archive
    end
  end

  resources :categories, only: [:index, :show]

  namespace :admin do
    resources :posts
    resources :settings
  end

  # 親アプリへの直接ルート (main_appを使用)
  get "/profile", to: redirect { |params, request|
    main_app.profile_path
  }
end

Route Helpers

親アプリからエンジンルートにアクセス:

<!-- 親アプリビュー -->
<%= link_to "Blog", my_engine.root_path %>
<%= link_to "New Post", my_engine.new_post_path %>
<%= link_to "Post", my_engine.post_path(@post) %>

<!-- マウントパス付き -->
<%= link_to "Blog", blog.root_path %>
<%= link_to "All Posts", blog.posts_path %>

エンジンから親アプリルートにアクセス:

<!-- エンジンビュー -->
<%= link_to "Home", main_app.root_path %>
<%= link_to "User Profile", main_app.user_path(@user) %>
<%= link_to "Settings", main_app.settings_path %>

コントローラ内:

module MyEngine
  class PostsController < ApplicationController
    def create
      @post = Post.create(post_params)
      if @post.persisted?
        redirect_to @post  # エンジンルート
      else
        render :new
      end
    end

    def back_to_app
      redirect_to main_app.root_path  # 親アプリルート
    end
  end
end

URL生成:

# 親アプリ内
MyEngine::Engine.routes.url_helpers.post_path(post)
blog.post_path(post)  # 'blog'としてマウントされている場合

# エンジン内
main_app.user_path(user)
MyEngine::Engine.routes.url_helpers.post_path(post)

Isolation and Shared Resources

Accessing Parent App from Engine

親アプリモデルの参照:

module MyEngine
  class Post < ApplicationRecord
    # オプション1: 直接参照(分離度が低下)
    belongs_to :user, class_name: "::User"

    # オプション2: 設定されたクラス(推奨)
    def user_class
      MyEngine.user_class_constant
    end

    def user
      user_class.find(user_id)
    end
  end
end

親アプリサービスの呼び出し:

module MyEngine
  class PostsController < ApplicationController
    def create
      @post = Post.create(post_params)

      # 親アプリサービスを呼び出し
      ::NotificationService.notify_new_post(@post)

      # または設定されたコールバックを使用
      MyEngine.after_create_post&.call(@post)

      redirect_to @post
    end
  end
end

親アプリヘルパーの使用:

module MyEngine
  class ApplicationController < ActionController::Base
    helper_method :current_user

    def current_user
      # 親アプリに委譲
      main_app.current_user if main_app.respond_to?(:current_user)
    end
  end
end

Accessing Engine from Parent App

エンジンモデルの使用:

# 親アプリコントローラ
class DashboardController < ApplicationController
  def index
    @recent_posts = MyEngine::Post.recent.limit(5)
    @post_count = MyEngine::Post.count
  end
end

エンジンコントローラのオーバーライド:

# 親アプリ: app/controllers/my_engine/posts_controller.rb
module MyEngine
  class PostsController < MyEngine::PostsController
    # 特定のアクションをオーバーライド
    def index
      @posts = Post.where(published: true).by_user(current_user)
      render "my_engine/posts/index"
    end

    # 新しいアクションを追加
    def featured
      @posts = Post.featured
    end
  end
end

エンジンモデルの装飾:

# 親アプリ: app/decorators/my_engine/post_decorator.rb
MyEngine::Post.class_eval do
  # 関連を追加
  has_many :likes, class_name: "::Like", as: :likeable

  # メソッドを追加
  def featured?
    featured_at.present? && featured_at > 30.days.ago
  end

  # メソッドをオーバーライド
  def display_title
    published? ? title : "[Draft] #{title}"
  end
end

エンジン内でデコレータをロード:

# エンジン: lib/my_engine/engine.rb
module MyEngine
  class Engine < ::Rails::Engine
    config.to_prepare do
      # 親アプリデコレータをロード
      decorator_path = Rails.root.join("app", "decorators", "my_engine")
      if decorator_path.exist?
        Dir.glob(decorator_path.join("**", "*_decorator.rb")).each do |file|
          Rails.configuration.cache_classes ? require(file) : load(file)
        end
      end
    end
  end
end

Shared Concerns

エンジンの関心事:

# エンジン: app/models/concerns/my_engine/publishable.rb
module MyEngine
  module Publishable
    extend ActiveSupport::Concern

    included do
      scope :published, -> { where.not(published_at: nil) }
      scope :draft, -> { where(published_at: nil) }
    end

    def publish!
      update(published_at: Time.current)
    end

    def published?
      published_at.present?
    end
  end
end

親アプリでの使用:

# 親アプリモデル
class Article < ApplicationRecord
  include MyEngine::Publishable

  # ArticleはNow publish!, published?, published, draftスコープを持つ
end

Migrations

Creating Migrations

エンジン内でマイグレーションを生成:

cd engines/my_engine
rails generate migration CreatePosts title:string body:text published:boolean

生成されたマイグレーション:

# engines/my_engine/db/migrate/20260206120000_create_my_engine_posts.rb
class CreateMyEnginePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :my_engine_posts do |t|
      t.string :title
      t.text :body
      t.boolean :published, default: false

      t.timestamps
    end

    add_index :my_engine_posts, :published
  end
end

Installing Migrations

親アプリにマイグレーションをコピー:

# 親アプリディレクトリから
rake my_engine:install:migrations

# またはすべてのエンジン
rake railties:install:migrations

What happens:

  • エンジンから親アプリのdb/migrate/にマイグレーションをコピー
  • 順序を維持するためにタイムスタンプでプレフィックス
  • 出処を追跡するスコープコメントを追加

Result:

# 親アプリ: db/migrate/20260206120000_create_my_engine_posts.my_engine.rb
# This migration comes from my_engine (originally 20260206120000)
class CreateMyEnginePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :my_engine_posts do |t|
      t.string :title
      t.text :body
      t.boolean :published, default: false

      t.timestamps
    end

    add_index :my_engine_posts, :published
  end
end

マイグレーションを実行:

rake db:migrate

Migration Conflicts

問題: 同じマイグレーションが2回コピーされた

Solution 1: コピー前に確認

# 既にコピー済みならスキップ
rake my_engine:install:migrations SKIP_EXISTING=true

Solution 2: バージョン管理を使用

# エンジン: lib/tasks/install.rake
namespace :my_engine do
  namespace :install do
    desc "MyEngineマイグレーションをインストール"
    task :migrations_with_check do
      # マイグレーションが既にインストールされているか確認
      existing = Dir.glob(Rails.root.join("db/migrate/*my_engine.rb"))
      if existing.any?
        puts "Migrations already installed. Skipping."
      else
        Rake::Task["my_engine:install:migrations"].invoke
      end
    end
  end
end

Referencing Parent App Tables

親アプリテーブルへの外部キー:

class AddUserRefToMyEnginePosts < ActiveRecord::Migration[7.0]
  def change
    add_reference :my_engine_posts, :user, foreign_key: true
  end
end

ポリモーフィックな関連:

class CreateMyEngineComments < ActiveRecord::Migration[7.0]
  def change
    create_table :my_engine_comments do |t|
      t.references :commentable, polymorphic: true, null: false
      t.text :body

      t.timestamps
    end
  end
end

Rollback Strategies

エンジンマイグレーションのロールバック:

# 最後のマイグレーションをロールバック
rake db:rollback

# 特定のバージョンをロールバック
rake db:migrate:down VERSION=20260206120000

# すべてのエンジンマイグレーションをロールバック
rake db:migrate:down VERSION=<first_engine_migration_version>

マイグレーションステータスを追跡:

rake db:migrate:status

Generators

Custom Generators

エンジンジェネレータ構造:

engines/my_engine/lib/generators/
└── my_engine/
    ├── install/
    │   ├── install_generator.rb
    │   └── templates/
    │       └── initializer.rb
    └── post/
        ├── post_generator.rb
        └── templates/
            └── post.rb.erb

Install Generator

engines/my_engine/lib/generators/my_engine/install/install_generator.rb:

module MyEngine
  module Generators
    class InstallGenerator < Rails::Generators::Base
      source_root File.expand_path("templates", __dir__)

      desc "MyEngineイニシャライザを作成し、マイグレーションをコピー"

      def copy_initializer
        template "initializer.rb", "config/initializers/my_engine.rb"
      end

      def copy_migrations
        rake "my_engine:install:migrations"
      end

      def mount_engine
        route 'mount MyEngine::Engine => "/blog"'
      end

      def show_readme
        readme "README" if behavior == :invoke
      end
    end
  end
end

templates/initializer.rb:

MyEngine.configure do |config|
  # ユーザークラスを設定
  config.user_class = "User"

  # ページネーションを設定
  config.max_items_per_page = 25

  # コールバックを設定
  # config.after_create_post = ->(post) {
  #   # カスタムロジックをここに記述
  # }
end

Usage:

rails generate my_engine:install

Model Generator

engines/my_engine/lib/generators/my_engine/post/post_generator.rb:

module MyEngine
  module Generators
    class PostGenerator < Rails::Generators::NamedBase
      source_root File.expand_path("templates", __dir__)

      argument :attributes, type: :array, default: [], banner: "field:type field:type"

      def create_model_file
        template "post.rb.erb", "app/models/my_engine/#{file_name}.rb"
      end

      def create_migration_file
        migration_template "migration.rb.erb",
                          "db/migrate/create_my_engine_#{table_name}.rb",
                          migration_version: migration_version
      end

      private

      def migration_version
        "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
      end

      def attributes_hash
        return "{}" if attributes.empty?
        attributes.map { |attr| "#{attr.name}: #{attr.type}" }.join(", ")
      end
    end
  end
end

templates/post.rb.erb:

module MyEngine
  class <%= class_name %> < ApplicationRecord
    # バリデーション
    validates :title, presence: true

    # 関連
    belongs_to :user, class_name: MyEngine.user_class

    # スコープ
    scope :recent, -> { order(created_at: :desc) }

    # インスタンスメソッド
    def to_s
      title
    end
  end
end

Usage:

rails generate my_engine:post Article title:string body:text

Scaffold Generator

engines/my_engine/lib/generators/my_engine/scaffold/scaffold_generator.rb:

module MyEngine
  module Generators
    class ScaffoldGenerator < Rails::Generators::NamedBase
      source_root File.expand_path("templates", __dir__)

      argument :attributes, type: :array, default: []

      def create_model
        invoke "my_engine:post", [name] + attributes.map(&:to_s)
      end

      def create_controller
        template "controller.rb.erb",
                 "app/controllers/my_engine/#{file_name.pluralize}_controller.rb"
      end

      def create_views
        %w[index show new edit _form].each do |view|
          template "views/#{view}.html.erb",
                   "app/views/my_engine/#{file_name.pluralize}/#{view}.html.erb"
        end
      end

      def add_routes
        route "resources :#{file_name.pluralize}"
      end
    end
  end
end

Generator Hooks

ジェネレータ動作をカスタマイズ:

module MyEngine
  class Engine < ::Rails::Engine
    config.generators do |g|
      g.test_framework :rspec, fixture: false
      g.fixture_replacement :factory_bot, dir: "spec/factories"
      g.assets false
      g.helper false
    end
  end
end

Testing Engines

Test Structure

エンジンテストディレクトリ:

engines/my_engine/
├── spec/
│   ├── controllers/
│   │   └── my_engine/
│   ├── models/
│   │   └── my_engine/
│   ├── features/
│   ├── factories/
│   ├── support/
│   ├── rails_helper.rb
│   └── spec_helper.rb
└── test/
    └── dummy/  # テスト用ダミーRailsアプリ
        ├── app/
        ├── config/
        └── db/

Dummy Application

test/dummy/config/application.rb:

module Dummy
  class Application < Rails::Application
    config.load_defaults 7.0

    # テスト用に設定
    config.eager_load = false
    config.consider_all_requests_local = true
    config.action_controller.perform_caching = false
  end
end

test/dummy/config/routes.rb:

Rails.application.routes.draw do
  mount MyEngine::Engine => "/my_engine"
end

RSpec Configuration

spec/rails_helper.rb:

ENV['RAILS_ENV'] ||= 'test'

require File.expand_path('../test/dummy/config/environment', __dir__)

abort("Running in production mode!") if Rails.env.production?

require 'rspec/rails'
require 'factory_bot_rails'
require 'capybara/rspec'

# サポートファイルをロード
Dir[MyEngine::Engine.root.join('spec/support/**/*.rb')].each { |f| require f }

RSpec.configure do |config|
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!

  # FactoryBot
  config.include FactoryBot::Syntax::Methods

  # エンジンルート
  config.include MyEngine::Engine.routes.url_helpers

  # ダミーアプリルート
  config.include Rails.application.routes.url_helpers, type: :controller
end

Model Specs

spec/models/my_engine/post_spec.rb:

require 'rails_helper'

module MyEngine
  RSpec.describe Post, type: :model do
    describe "validations" do
      it { should validate_presence_of(:title) }
      it { should validate_presence_of(:body) }
    end

    describe "associations" do
      it { should belong_to(:user) }
      it { should have_many(:comments) }
    end

    describe "scopes" do
      let!(:published_post) { create(:my_engine_post, published_at: 1.day.ago) }
      let!(:draft_post) { create(:my_engine_post, published_at: nil) }

      describe ".published" do
        it "公開済みの投稿のみを返す" do
          expect(Post.published).to include(published_post)
          expect(Post.published).not_to include(draft_post)
        end
      end

      describe ".draft" do
        it "下書き投稿のみを返す" do
          expect(Post.draft).to include(draft_post)
          expect(Post.draft).not_to include(published_post)
        end
      end
    end

    describe "#publish!" do
      let(:post) { create(:my_engine_post, published_at: nil) }

      it "published_atを設定する" do
        expect { post.publish! }.to change { post.published_at }.from(nil)
      end

      it "投稿を公開済みにマーク" do
        post.publish!
        expect(post).to be_published
      end
    end
  end
end

Controller Specs

spec/controllers/my_engine/posts_controller_spec.rb:

require 'rails_helper'

module MyEngine
  RSpec.describe PostsController, type: :controller do
    routes { MyEngine::Engine.routes }

    let(:user) { create(:user) }
    let(:post_attrs) { attributes_for(:my_engine_post) }

    before { sign_in user }

    describe "GET #index" do
      let!(:posts) { create_list(:my_engine_post, 3) }

      it "成功レスポンスを返す" do
        get :index
        expect(response).to be_successful
      end

      it "@postsを割り当てる" do
        get :index
        expect(assigns(:posts)).to match_array(posts)
      end
    end

    describe "GET #show" do
      let(:post) { create(:my_engine_post) }

      it "成功レスポンスを返す" do
        get :show, params: { id: post.id }
        expect(response).to be_successful
      end
    end

    describe "POST #create" do
      context "有効なパラメータの場合" do
        it "新しいPostを作成する" do
          expect {
            post :create, params: { post: post_attrs }
          }.to change(Post, :count).by(1)
        end

        it "作成された投稿にリダイレクト" do
          post :create, params: { post: post_attrs }
          expect(response).to redirect_to(Post.last)
        end
      end

      context "無効なパラメータの場合" do
        let(:invalid_attrs) { { title: "" } }

        it "投稿を作成しない" do
          expect {
            post :create, params: { post: invalid_attrs }
          }.not_to change(Post, :count)
        end

        it "newテンプレートをレンダリング" do
          post :create, params: { post: invalid_attrs }
          expect(response).to render_template(:new)
        end
      end
    end
  end
end

Feature Specs

spec/features/my_engine/posts_spec.rb:

require 'rails_helper'

module MyEngine
  RSpec.feature "Posts", type: :feature do
    let(:user) { create(:user) }

    before { login_as(user) }

    scenario "ユーザーが新しい投稿を作成する" do
      visit my_engine.root_path
      click_link "New Post"

      fill_in "Title", with: "My First Post"
      fill_in "Body", with: "This is the post body"
      click_button "Create Post"

      expect(page).to have_content("Post was successfully created")
      expect(page).to have_content("My First Post")
    end

    scenario "ユーザーが投稿を編集する" do
      post = create(:my_engine_post, user: user)

      visit my_engine.post_path(post)
      click_link "Edit"

      fill_in "Title", with: "Updated Title"
      click_button "Update Post"

      expect(page).to have_content("Post was successfully updated")
      expect(page).to have_content("Updated Title")
    end

    scenario "ユーザーが下書き投稿を公開する" do
      post = create(:my_engine_post, user: user, published_at: nil)

      visit my_engine.post_path(post)
      click_button "Publish"

      expect(page).to have_content("Post published")
      expect(post.reload).to be_published
    end
  end
end

Testing with Parent App

統合テスト:

# 親アプリ: spec/features/blog_integration_spec.rb
require 'rails_helper'

RSpec.feature "Blog Integration", type: :feature do
  let(:user) { create(:user) }

  scenario "ユーザーがホームからブログに移動する" do
    visit root_path
    click_link "Blog"

    expect(current_path).to eq(blog.root_path)
    expect(page).to have_content("Blog Posts")
  end

  scenario "投稿作成は通知を送信する" do
    login_as(user)

    expect(NotificationService).to receive(:notify_new_post)

    visit blog.new_post_path
    fill_in "Title", with: "Test Post"
    fill_in "Body", with: "Test body"
    click_button "Create Post"
  end
end

Factory Bot Setup

spec/factories/my_engine/posts.rb:

FactoryBot.define do
  factory :my_engine_post, class: 'MyEngine::Post' do
    sequence(:title) { |n| "Post #{n}" }
    body { "This is the post body" }
    published_at { 1.day.ago }
    association :user

    trait :draft do
      published_at { nil }
    end

    trait :with_comments do
      after(:create) do |post|
        create_list(:my_engine_comment, 3, post: post)
      end
    end
  end
end

Dependencies Management

Gemspec Configuration

my_engine.gemspec:

$:.push File.expand_path("lib", __dir__)

require "my_engine/version"

Gem::Specification.new do |spec|
  spec.name        = "my_engine"
  spec.version     = MyEngine::VERSION
  spec.authors     = ["Your Name"]
  spec.email       = ["your.email@example.com"]
  spec.homepage    = "https://github.com/yourname/my_engine"
  spec.summary     = "ブログ機能のためのRailsエンジン"
  spec.description = "ブログ投稿、コメント、カテゴリを提供"
  spec.license     = "MIT"

  spec.files = Dir[
    "{app,config,db,lib}/**/*",
    "MIT-LICENSE",
    "Rakefile",
    "README.md"
  ]

  # Rails依存関係
  spec.add_dependency "rails", ">= 7.0.0"

  # 追加依存関係
  spec.add_dependency "kaminari", "~> 1.2"  # ページネーション
  spec.add_dependency "redcarpet", "~> 3.5"  # Markdown
  spec.add_dependency "pundit", "~> 2.3"  # 認可

  # 開発依存関係
  spec.add_development_dependency "rspec-rails", "~> 6.0"
  spec.add_development_dependency "factory_bot_rails", "~> 6.2"
  spec.add_development_dependency "capybara", "~> 3.39"
  spec.add_development_dependency "sqlite3", "~> 1.6"
  spec.add_development_dependency "puma", "~> 6.0"
end

Parent App Requirements

親アプリ Gemfile:

# ローカル開発
gem 'my_engine', path: 'engines/my_engine'

# Gitリポジトリ
gem 'my_engine', git: 'https://github.com/yourname/my_engine.git'

# Rubygems(公開)
gem 'my_engine', '~> 1.0'

# 複数エンジン
gem 'blog_engine', path: 'engines/blog_engine'
gem 'forum_engine', path: 'engines/forum_engine'
gem 'shop_engine', path: 'engines/shop_engine'

Inter-Engine Dependencies

別のエンジンに依存するエンジン:

# shop_engine.gemspec
spec.add_dependency "blog_engine", "~> 1.0"

共有エンジンの使用:

# shop_engine内
module ShopEngine
  class Product < ApplicationRecord
    # ブログエンジンを製品記事に使用
    has_many :blog_posts,
             class_name: "BlogEngine::Post",
             foreign_key: :product_id
  end
end

共有設定:

# lib/my_engine/engine.rb
module MyEngine
  class Engine < ::Rails::Engine
    # 他のエンジンを必須化
    require 'shared_engine'

    config.after_initialize do
      # 他のエンジンがロードされていることを確認
      unless defined?(SharedEngine::Engine)
        raise "MyEngine requires SharedEngine"
      end
    end
  end
end

Configuration and Initialization

Initializers

エンジンイニシャライザ:

# lib/my_engine/engine.rb
module MyEngine
  class Engine < ::Rails::Engine
    isolate_namespace MyEngine

    # 他のイニシャライザより前に実行
    initializer "my_engine.early_setup", before: :load_config_initializers do
      # 初期設定をセットアップ
    end

    # アセットプリコンパイル
    initializer "my_engine.assets.precompile" do |app|
      app.config.assets.precompile += %w(
        my_engine/application.js
        my_engine/application.css
        my_engine/admin.js
        my_engine/admin.css
      )
    end

    # デコレータをロード
    initializer "my_engine.load_decorators" do
      config.to_prepare do
        Dir.glob(Engine.root.join("app", "decorators", "**", "*_decorator.rb")).each do |c|
          Rails.configuration.cache_classes ? require(c) : load(c)
        end
      end
    end

    # ミドルウェアを追加
    initializer "my_engine.middleware" do |app|
      app.middleware.use MyEngine::Middleware::TrackingMiddleware
    end

    # MIMEタイプを登録
    initializer "my_engine.mime_types" do
      Mime::Type.register "application/vnd.my_engine+json", :my_engine_json
    end

    # ActiveSupportの通知をサブスクライブ
    initializer "my_engine.notifications" do
      ActiveSupport::Notifications.subscribe("post.created") do |*args|
        event = ActiveSupport::Notifications::Event.new(*args)
        # イベントを処理
      end
    end
  end
end

Environment-Specific Config

環境ごとのエンジン設定:

# lib/my_engine/engine.rb
module MyEngine
  class Engine < ::Rails::Engine
    config.before_configuration do
      # 環境固有の設定をロード
      config_file = Engine.root.join("config", "environments", "#{Rails.env}.rb")
      load(config_file) if File.exist?(config_file)
    end
  end
end

config/environments/production.rb:

MyEngine.configure do |config|
  config.enable_caching = true
  config.cache_store = :redis_cache_store
  config.log_level = :info
end

config/environments/development.rb:

MyEngine.configure do |config|
  config.enable_caching = false
  config.log_level = :debug
end

Secrets and Credentials

親アプリの認証情報にアクセス:

module MyEngine
  class ApiClient
    def api_key
      Rails.application.credentials.my_engine[:api_key]
    end
  end
end

親アプリの credentials.yml.enc:

my_engine:
  api_key: abc123xyz
  api_secret: secret456

When to Use Engines

Use Cases for Engines

1. 再利用可能なコンポーネント

  • 認証システム(Deviseなど)
  • 管理パネル(ActiveAdminなど)
  • CMS機能
  • E-commerceプラットフォーム
  • フォーラムシステム
  • マルチテナント

2. アプリケーションのモジュール化

  • 大規模モノリスの分割
  • 機能ベースの構成
  • チームベースのコード所有権
  • マイクロサービスへの段階的移行

3. クライアントカスタマイズ

  • ホワイトレーベル製品
  • 設定可能な基本機能
  • クライアント固有のオーバーライド

4. 複数アプリケーション間の共有

  • アプリケーション間の共通機能
  • 企業全体のコンポーネント
  • APIクライアントをエンジンとして

Engines vs Gems

Engineを使用する場合:

  • Rails統合が必要(モデル、コントローラ、ビュー)
  • データベーステーブルが必要
  • ルートとUIを持つ
  • 親アプリにマウントする必要がある
  • Railsと密接に結合

Gemを使用する場合:

  • 純粋なRuby機能
  • Rails依存関係が不要
  • ユーティリティ関数
  • サービスクライアント
  • データベース操作なし

例の判断:

要件ソリューション
アプリにブログを追加Engine(モデル、コントローラ、ビュー)
HTTPクライアントGem(Rails不要)
管理ダッシュボードEngine(ルート、UI、モデル)
日付フォーマットGem(ユーティリティ関数)
フォーラムシステムEngine(複雑なRails機能)
APIラッパーGem(ARモデルが不要な場合)

Anti-Patterns

Engineを使用してはいけない場合:

  1. シングルアプリ、小さな機能 - 通常のRails構造を使用
  2. 再利用性の予定なし - オーバーヘッドの価値がない
  3. 頻繁に変更される境界 - モジュール式Railsフォルダで十分
  4. 密接な結合が必要 - Engine分離が負担になる
  5. シンプルなgemで十分 - Rails オーバーヘッドを不要に追加

過度なエンジニアリングの警告:

# 悪い: 小さすぎるエンジン
engines/
├── user_profile_engine/  # モデル1つだけ
├── settings_engine/      # コントローラ1つだけ
├── notification_engine/  # サービスオブジェクトでよい
└── email_engine/         # ActionMailerでよい

# 良い: 合理的なエンジンの境界
engines/
├── accounts_engine/      # ユーザー管理、プロファイル、設定
└── messaging_engine/     # 通知、メール、チャット

良いエンジンの境界:

  • 明確なドメイン境界
  • 親アプリとの最小限の結合
  • 自己完結した機能
  • 再利用可能またはextractable
  • チーム所有権の整列

MVC Architecture

Models (Active Record)

Model Basics

基本的なモデル:

class User < ApplicationRecord
  # バリデーション
  validates :email, presence: true, uniqueness: true
  validates :name, presence: true
  validates :age, numericality: { greater_than: 0 }

  # 関連
  has_many :posts, dependent: :destroy
  has_many :comments
  has_one :profile, dependent: :destroy

  # コールバック
  before_save :normalize_email
  after_create :send_welcome_email

  # スコープ
  scope :active, -> { where(active: true) }
  scope :recent, -> { order(created_at: :desc) }

  private

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

  def send_welcome_email
    UserMailer.welcome(self).deliver_later
  end
end

Associations

すべての関連タイプ:

class User < ApplicationRecord
  # 一対多
  has_many :posts
  has_many :comments

  # オプション付き一対多
  has_many :published_posts,
           -> { where(published: true) },
           class_name: "Post"

  # 多対多(結合テーブル)
  has_many :group_memberships
  has_many :groups, through: :group_memberships

  # 一対一
  has_one :profile, dependent: :destroy
  has_one :address, through: :profile

  # ポリモーフィック
  has_many :comments, as: :commentable
end

class Post < ApplicationRecord
  belongs_to :user
  has_many :comments, as: :commentable, dependent: :destroy
  has_many :taggings
  has_many :tags, through: :taggings
end

class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :commentable, polymorphic: true
end

Validations

共通バリデーション:

class User < ApplicationRecord
  validates :email,
            presence: true,
            uniqueness: { case_sensitive: false },
            format: { with: URI::MailTo::EMAIL_REGEXP }

  validates :password,
            length: { minimum: 8 },
            if: :password_required?

  validates :age,
            numericality: {
              greater_than_or_equal_to: 18,
              less_than: 120
            }

  validates :username,
            presence: true,
            uniqueness: true,
            length: { in: 3..20 },
            format: {
              with: /\A[a-zA-Z0-9_]+\z/,
              message: "only letters, numbers, and underscores"
            }

  validates :terms_of_service,
            acceptance: true

  validates :role,
            inclusion: { in: %w[admin user guest] }

  validate :custom_validation

  private

  def password_required?
    new_record? || password.present?
  end

  def custom_validation
    if username.present? && username.start_with?("admin")
      errors.add(:username, "cannot start with 'admin'")
    end
  end
end

Callbacks

コールバック順序:

class Post < ApplicationRecord
  # 作成時のコールバック
  before_validation :set_defaults
  after_validation :log_validation
  before_save :prepare_content
  around_save :log_save
  before_create :set_publication_date
  after_create :notify_subscribers
  after_save :clear_cache
  after_commit :index_for_search, on: :create

  # 更新時のコールバック
  before_update :check_changes
  after_update :notify_if_published

  # 削除時のコールバック
  before_destroy :check_dependencies
  after_destroy :cleanup_assets

  private

  def set_defaults
    self.status ||= "draft"
  end

  def prepare_content
    self.content = ContentProcessor.process(content)
  end

  def log_save
    Rails.logger.info "Saving post #{id}"
    yield
    Rails.logger.info "Saved post #{id}"
  end

  def notify_subscribers
    NotificationJob.perform_later(id)
  end
end

Scopes and Queries

スコープパターン:

class Post < ApplicationRecord
  # 基本的なスコープ
  scope :published, -> { where(published: true) }
  scope :draft, -> { where(published: false) }
  scope :recent, -> { order(created_at: :desc) }

  # パラメータ付きスコープ
  scope :by_author, ->(author) { where(author: author) }
  scope :created_after, ->(date) { where("created_at > ?", date) }
  scope :with_tag, ->(tag) { joins(:tags).where(tags: { name: tag }) }

  # チェーン可能なスコープ
  scope :popular, -> { where("views > ?", 1000) }
  scope :recent_popular, -> { recent.popular }

  # デフォルトスコープ(慎重に使用)
  default_scope { where(deleted_at: nil) }

  # クラスメソッドをスコープとして
  def self.search(query)
    where("title LIKE ? OR content LIKE ?", "%#{query}%", "%#{query}%")
  end
end

# 使用方法
Post.published.recent.limit(10)
Post.by_author("John").created_after(1.week.ago)

Controllers

Controller Basics

RESTfulコントローラ:

class PostsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_post, only: [:show, :edit, :update, :destroy]
  before_action :authorize_post, only: [:edit, :update, :destroy]

  def index
    @posts = Post.published.recent.page(params[:page])
  end

  def show
    @comments = @post.comments.includes(:user)
  end

  def new
    @post = current_user.posts.build
  end

  def create
    @post = current_user.posts.build(post_params)

    if @post.save
      redirect_to @post, notice: "Post created successfully"
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
  end

  def update
    if @post.update(post_params)
      redirect_to @post, notice: "Post updated successfully"
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @post.destroy
    redirect_to posts_url, notice: "Post deleted successfully"
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def authorize_post
    unless @post.user == current_user || current_user.admin?
      redirect_to root_path, alert: "Not authorized"
    end
  end

  def post_params
    params.require(:post).permit(:title, :content, :published, tag_ids: [])
  end
end

Concerns

コントローラの関心事:

# app/controllers/concerns/paginatable.rb
module Paginatable
  extend ActiveSupport::Concern

  included do
    helper_method :page, :per_page
  end

  def page
    params[:page] || 1
  end

  def per_page
    params[:per_page] || 25
  end

  def paginate(collection)
    collection.page(page).per(per_page)
  end
end

# コントローラでの使用
class PostsController < ApplicationController
  include Paginatable

  def index
    @posts = paginate(Post.published)
  end
end

Strong Parameters

ネストされたパラメータ:

class PostsController < ApplicationController
  private

  def post_params
    params.require(:post).permit(
      :title,
      :content,
      :published,
      tag_ids: [],
      comments_attributes: [:id, :body, :_destroy],
      metadata: [:description, :keywords]
    )
  end
end

Views

View Helpers

カスタムヘルパー:

# app/helpers/application_helper.rb
module ApplicationHelper
  def formatted_date(date)
    date.strftime("%B %d, %Y") if date
  end

  def user_avatar(user, size: 40)
    image_tag user.avatar_url, size: "#{size}x#{size}", class: "avatar"
  end

  def markdown(text)
    return "" unless text

    markdown = Redcarpet::Markdown.new(
      Redcarpet::Render::HTML,
      autolink: true,
      tables: true
    )
    markdown.render(text).html_safe
  end
end

Partials

パーシャルの使用:

<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>

<div class="posts">
  <%= render @posts %>
</div>

<!-- app/views/posts/_post.html.erb -->
<div class="post">
  <h2><%= link_to post.title, post %></h2>
  <p><%= post.excerpt %></p>
  <%= render "shared/post_meta", post: post %>
</div>

<!-- app/views/shared/_post_meta.html.erb -->
<div class="post-meta">
  <span>By <%= post.author.name %></span>
  <span><%= formatted_date(post.created_at) %></span>
</div>

Query Optimization

N+1 Queries

問題:

# 悪い - N+1クエリ
@posts = Post.all
@posts.each do |post|
  puts post.user.name  # 各投稿に対する追加クエリ
end

Solution:

# 良い - Eager loading
@posts = Post.includes(:user)
@posts.each do |post|
  puts post.user.name  # 追加クエリなし
end

Eager Loading

異なるローディング戦略:

# includes - LEFT OUTER JOINまたは個別クエリを使用
Post.includes(:user, :comments)

# preload - 常に個別クエリを使用
Post.preload(:user, :comments)

# eager_load - 常にLEFT OUTER JOINを使用
Post.eager_load(:user, :comments)

# ネストされた関連
Post.includes(comments: :user)

# 複数関連
Post.includes(:user, :tags, comments: [:user, :likes])

Select and Pluck

クエリを最適化:

# 特定のカラムを選択
Post.select(:id, :title, :created_at)

# 単一値に対してpluckを使用
Post.pluck(:title)  # タイトルの配列を返す
Post.pluck(:id, :title)  # 配列の配列を返す

# 重複を削除
Post.select(:author_id).distinct

# レコードをロードせずにカウント
Post.where(published: true).count

Indexes

インデックス付きマイグレーション:

class AddIndexesToPosts < ActiveRecord::Migration[7.0]
  def change
    add_index :posts, :user_id
    add_index :posts, :published
    add_index :posts, [:user_id, :created_at]
    add_index :posts, :title, unique: true
  end
end

Service Objects

Service objectsを使用する場合:

  • 複数のモデルにまたがる複雑なビジネスロジック
  • 外部API呼び出しが必要な操作
  • トランザクションを含む複数ステップのプロセス
  • モデル/コントローラに適切に当てはまらないロジック

Service objectパターン:

# app/services/posts/publish_service.rb
module Posts
  class PublishService
    def initialize(post, user)
      @post = post
      @user = user
    end

    def call
      return failure("Already published") if @post.published?
      return failure("Not authorized") unless can_publish?

      ActiveRecord::Base.transaction do
        @post.update!(published: true, published_at: Time.current)
        notify_subscribers
        index_for_search
      end

      success(@post)
    rescue => e
      failure(e.message)
    end

    private

    def can_publish?
      @user.admin? || @post.user == @user
    end

    def notify_subscribers
      NotificationJob.perform_later(@post.id)
    end

    def index_for_search
      SearchIndexJob.perform_later(@post.id)
    end

    def success(data)
      OpenStruct.new(success?: true, data: data, error: nil)
    end

    def failure(error)
      OpenStruct.new(success?: false, data: nil, error: error)
    end
  end
end

# コントローラでの使用
def publish
  result = Posts::PublishService.new(@post, current_user).call

  if result.success?
    redirect_to result.data, notice: "Published successfully"
  else
    redirect_to @post, alert: result.error
  end
end

Form Objects

複雑なフォーム処理:

# app/forms/user_registration_form.rb
class UserRegistrationForm
  include ActiveModel::Model

  attr_accessor :email, :password, :password_confirmation
  attr_accessor :company_name, :company_address

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, presence: true, length: { minimum: 8 }
  validates :password_confirmation, presence: true
  validates :company_name, presence: true

  validate :passwords_match

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      @user = User.create!(
        email: email,
        password: password
      )

      @company = Company.create!(
        name: company_name,
        address: company_address,
        owner: @user
      )

      @user.update!(company: @company)
    end

    true
  rescue => e
    errors.add(:base, e.message)
    false
  end

  attr_reader :user, :company

  private

  def passwords_match
    if password != password_confirmation
      errors.add(:password_confirmation, "doesn't match password")
    end
  end
end

# コントローラ
def create
  @form = UserRegistrationForm.new(registration_params)

  if @form.save
    redirect_to @form.user, notice: "Registration successful"
  else
    render :new
  end
end

Decorators/Presenters

Decoratorパターン:

# app/decorators/post_decorator.rb
class PostDecorator < SimpleDelegator
  include ActionView::Helpers::TextHelper
  include ActionView::Helpers::UrlHelper

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

  def excerpt(length: 200)
    truncate(content, length: length)
  end

  def author_link
    link_to author.name, author
  end

  def status_badge
    published? ? "Published" : "Draft"
  end

  def reading_time
    words = content.split.size
    minutes = (words / 200.0).ceil
    "#{minutes} min read"
  end
end

# 使用
@post = PostDecorator.new(Post.find(params[:id]))

Background Jobs

ActiveJob

ジョブ構造:

# app/jobs/notification_job.rb
class NotificationJob < ApplicationJob
  queue_as :default

  retry_on StandardError, wait: 5.seconds, attempts: 3
  discard_on ActiveJob::DeserializationError

  def perform(post_id)
    post = Post.find(post_id)
    post.subscribers.each do |subscriber|
      UserMailer.new_post(subscriber, post).deliver_now
    end
  end
end

# ジョブをエンキュー
NotificationJob.perform_later(post.id)
NotificationJob.set(wait: 1.hour).perform_later(post.id)

ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ

詳細情報

作者
FaisalAlqarni
リポジトリ
FaisalAlqarni/sp-ecc
ライセンス
MIT
最終更新
2026/3/14

Source: https://github.com/FaisalAlqarni/sp-ecc / ライセンス: MIT

関連スキル

汎用ソフトウェア開発⭐ リポ 39,967

doubt-driven-development

重要な判断はすべて、本番環境への展開前に新しい視点から対抗的レビューを実施します。速度より正確性が重要な場合、不慣れなコードを扱う場合、本番環境・セキュリティに関わるロジック・取り消し不可の操作など影響度が高い場合、または後でバグを修正するよりも今検証する方が効率的な場合に活用してください。

by addyosmani
汎用ソフトウェア開発⭐ リポ 1,175

apprun-skills

TypeScriptを使用したAppRunアプリケーションのMVU設計に関する総合的なガイダンスが得られます。コンポーネントパターン、イベントハンドリング、状態管理(非同期ジェネレータを含む)、パラメータと保護機能を備えたルーティング・ナビゲーション、vistestを使用したテストに対応しています。AppRunコンポーネントの設計・レビュー、ルートの配線、状態フローの管理、AppRunテストの作成時に活用してください。

by yysun
OpenAIソフトウェア開発⭐ リポ 797

desloppify

コードベースのヘルスチェックと技術負債の追跡ツールです。コード品質、技術負債、デッドコード、大規模ファイル、ゴッドクラス、重複関数、コードスメル、命名規則の問題、インポートサイクル、結合度の問題についてユーザーが質問した場合に使用してください。また、ヘルススコアの確認、次の改善項目の提案、クリーンアップ計画の作成をリクエストされた際にも対応します。29言語に対応しています。

by Git-on-my-level
汎用ソフトウェア開発⭐ リポ 39,967

debugging-and-error-recovery

テストが失敗したり、ビルドが壊れたり、動作が期待と異なったり、予期しないエラーが発生したりした場合に、体系的な根本原因デバッグをガイドします。推測ではなく、根本原因を見つけて修正するための体系的なアプローチが必要な場合に使用してください。

by addyosmani
汎用ソフトウェア開発⭐ リポ 39,967

test-driven-development

テスト駆動開発により実装を進めます。ロジックの実装、バグの修正、動作の変更など、あらゆる場面で活用できます。コードが正常に動作することを証明する必要がある場合、バグ報告を受けた場合、既存機能を修正する予定がある場合に使用してください。

by addyosmani
汎用ソフトウェア開発⭐ リポ 39,967

incremental-implementation

変更を段階的に実施します。複数のファイルに影響する機能や変更を実装する場合に使用してください。大量のコードを一度に書こうとしている場合や、タスクが一度では完結できないほど大きい場合に活用します。

by addyosmani
本サイトは GitHub 上で公開されているオープンソースの SKILL.md ファイルをクロール・インデックス化したものです。 各スキルの著作権は原作者に帰属します。掲載に問題がある場合は info@alsel.co.jp または /takedown フォームよりご連絡ください。
原作者: FaisalAlqarni · FaisalAlqarni/sp-ecc · ライセンス: MIT