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
- Convention Over Configuration - 一貫性のためにRailsの規約に従う
- DRY (Don't Repeat Yourself) - 共通パターンを再利用可能なコンポーネントに抽出
- Fat Models, Skinny Controllers - ビジネスロジックはモデルに、コントローラは調整役
- Service Objects for Complex Logic - 複数モデルの操作は抽出する
- 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
| Feature | Mountable Engine | Full 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:
- Routes: すべてのルートがMyEngineの下に名前空間化される
- Tables: データベーステーブルに
my_engine_プレフィックスが付く - Controllers:
MyEngine::ApplicationControllerから継承 - Models:
MyEngineモジュール内に含まれる - Helpers:
MyEngineの下に名前空間化される - 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を使用してはいけない場合:
- シングルアプリ、小さな機能 - 通常のRails構造を使用
- 再利用性の予定なし - オーバーヘッドの価値がない
- 頻繁に変更される境界 - モジュール式Railsフォルダで十分
- 密接な結合が必要 - Engine分離が負担になる
- シンプルな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
関連スキル
doubt-driven-development
重要な判断はすべて、本番環境への展開前に新しい視点から対抗的レビューを実施します。速度より正確性が重要な場合、不慣れなコードを扱う場合、本番環境・セキュリティに関わるロジック・取り消し不可の操作など影響度が高い場合、または後でバグを修正するよりも今検証する方が効率的な場合に活用してください。
apprun-skills
TypeScriptを使用したAppRunアプリケーションのMVU設計に関する総合的なガイダンスが得られます。コンポーネントパターン、イベントハンドリング、状態管理(非同期ジェネレータを含む)、パラメータと保護機能を備えたルーティング・ナビゲーション、vistestを使用したテストに対応しています。AppRunコンポーネントの設計・レビュー、ルートの配線、状態フローの管理、AppRunテストの作成時に活用してください。
desloppify
コードベースのヘルスチェックと技術負債の追跡ツールです。コード品質、技術負債、デッドコード、大規模ファイル、ゴッドクラス、重複関数、コードスメル、命名規則の問題、インポートサイクル、結合度の問題についてユーザーが質問した場合に使用してください。また、ヘルススコアの確認、次の改善項目の提案、クリーンアップ計画の作成をリクエストされた際にも対応します。29言語に対応しています。
debugging-and-error-recovery
テストが失敗したり、ビルドが壊れたり、動作が期待と異なったり、予期しないエラーが発生したりした場合に、体系的な根本原因デバッグをガイドします。推測ではなく、根本原因を見つけて修正するための体系的なアプローチが必要な場合に使用してください。
test-driven-development
テスト駆動開発により実装を進めます。ロジックの実装、バグの修正、動作の変更など、あらゆる場面で活用できます。コードが正常に動作することを証明する必要がある場合、バグ報告を受けた場合、既存機能を修正する予定がある場合に使用してください。
incremental-implementation
変更を段階的に実施します。複数のファイルに影響する機能や変更を実装する場合に使用してください。大量のコードを一度に書こうとしている場合や、タスクが一度では完結できないほど大きい場合に活用します。