ruby-on-rails-best-practices by sergiodxa/agent-skills
npx skills add https://github.com/sergiodxa/agent-skills --skill ruby-on-rails-best-practices从 Basecamp 的生产级 Rails 应用程序(Fizzy 和 Campfire)中提取的架构模式和编码规范。包含 6 个类别下的 16 条规则,重点关注代码组织、可维护性,以及遵循经过 Basecamp 优化的 "The Rails Way"。
在以下情况下参考这些指南:
将模型特定的关注点放在 app/models/model_name/ 目录下,而不是 app/models/concerns/。
# 目录结构
app/models/
├── card.rb
├── card/
│ ├── closeable.rb # Card::Closeable
│ ├── searchable.rb # Card::Searchable
│ └── assignable.rb # Card::Assignable
# app/models/card.rb
class Card < ApplicationRecord
include Closeable, Searchable, Assignable
# Ruby 会优先从 Card:: 命名空间解析
end
行为关注点使用 -able 后缀,功能关注点使用名词。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
# 行为:使用 -able 后缀
module Card::Closeable # 可关闭
module Card::Searchable # 可搜索
module User::Mentionable # 可提及
# 功能:使用名词
module User::Avatar # 拥有头像
module User::Role # 拥有角色
module Card::Mentions # 拥有 @提及
在共享的关注点中使用模板方法来实现可定制的行为。
# app/models/concerns/searchable.rb (共享)
module Searchable
def search_title
raise NotImplementedError
end
end
# app/models/card/searchable.rb (模型特定)
module Card::Searchable
include ::Searchable
def search_title
title # 实现钩子方法
end
end
为同步方法配对 _later 变体,用于将作业加入队列。
# app/models/card/readable.rb
def remove_inaccessible_notifications
# 同步实现
end
private
def remove_inaccessible_notifications_later
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
end
# app/jobs/card/remove_inaccessible_notifications_job.rb
class Card::RemoveInaccessibleNotificationsJob < ApplicationJob
def perform(card)
card.remove_inaccessible_notifications
end
end
作业调用模型方法。所有逻辑都存在于模型中。
# 不好:逻辑在作业中
class ProcessOrderJob < ApplicationJob
def perform(order)
order.items.each { |i| i.product.decrement!(:stock) }
order.update!(status: :processing)
end
end
# 好:作业委托给模型
class ProcessOrderJob < ApplicationJob
def perform(order)
order.process # 单一方法调用
end
end
为状态变更创建资源控制器,而不是自定义动作。
# 不好:自定义动作
resources :cards do
post :close
post :reopen
end
# 好:资源控制器
resources :cards do
resource :closure, only: [:create, :destroy]
end
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
def create
@card.close
end
def destroy
@card.reopen
end
end
使用像 CardScoped 这样的关注点来设置嵌套资源。
# app/controllers/concerns/card_scoped.rb
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card
end
private
def set_card
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
end
# 用法
class Cards::CommentsController < ApplicationController
include CardScoped
end
控制器直接调用丰富的模型 API。不使用服务对象。
# 好:精简控制器,丰富模型
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close # 所有逻辑在模型中
end
end
使用 Current 处理请求作用域的数据,并采用级联设置器。
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :account
def session=(value)
super(value)
self.user = session&.user
end
end
Current 仅在 Web 请求中自动填充。作业、邮件发送器和频道需要显式设置。
# 作业:扩展 ActiveJob 以序列化/恢复 Current.account
# 来自作业的邮件发送器:在 Current.with_account { mailer.deliver } 中包装
# 频道:在 Connection#connect 中设置 Current
根据上下文需求,在关联扩展和模型类方法之间选择。
# 当需要父级上下文时使用扩展(proxy_association.owner)
has_many :accesses do
def grant_to(users)
board = proxy_association.owner
Access.insert_all(users.map { |u| { user_id: u.id, board_id: board.id, account_id: board.account_id } })
end
end
# 当操作独立时使用类方法
class Access
def self.grant(board:, users:)
insert_all(users.map { |u| { user_id: u.id, board_id: board.id } })
end
end
对作业使用 after_commit,对简单操作使用内联 lambda。
# 作业:使用 after_commit
after_create_commit :notify_recipients_later
# 简单操作:使用内联 lambda
after_save -> { board.touch }, if: :published?
# 条件:记住并检查模式
before_update :remember_changes
after_update_commit :process_changes, if: :should_process?
从控制器显式广播,而不是通过回调。
# app/models/message/broadcasts.rb
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [room, :messages]
end
end
# 控制器显式调用
def create
@message = @room.messages.create!(message_params)
@message.broadcast_create
end
使用 fixtures,而不是 factories。在测试中镜像关注点结构。
# test/fixtures/cards.yml
logo:
title: The logo isn't big enough
board: writebook
creator: david
# test/models/card/closeable_test.rb
class Card::CloseableTest < ActiveSupport::TestCase
test "close creates closure" do
card = cards(:logo)
assert_difference -> { Closure.count } do
card.close
end
end
end
将服务对象放在模型命名空间下,而不是 app/services。
# 好:app/models/card/activity_spike/detector.rb
class Card::ActivitySpike::Detector
def initialize(card)
@card = card
end
def detect
# ...
end
end
优先使用展开的条件语句,按调用顺序排列方法。
# 展开的条件语句
def find_record
if record = find_by_id(id)
record
else
NullRecord.new
end
end
# 方法排序:调用者在前,被调用者在后
def process
step_one
step_two
end
private
def step_one; end
def step_two; end
这些模式体现了 "Vanilla Rails" —— 在最小化添加的基础上使用 Rails 约定:
每周安装次数
77
代码仓库
GitHub 星标数
81
首次出现
2026年2月1日
安全审计
安装于
opencode73
codex72
github-copilot72
gemini-cli71
amp66
kimi-cli66
Architecture patterns and coding conventions extracted from Basecamp's production Rails applications (Fizzy and Campfire). Contains 16 rules across 6 categories focused on code organization, maintainability, and following "The Rails Way" with Basecamp's refinements.
Reference these guidelines when:
Place model-specific concerns in app/models/model_name/ not app/models/concerns/.
# Directory structure
app/models/
├── card.rb
├── card/
│ ├── closeable.rb # Card::Closeable
│ ├── searchable.rb # Card::Searchable
│ └── assignable.rb # Card::Assignable
# app/models/card.rb
class Card < ApplicationRecord
include Closeable, Searchable, Assignable
# Ruby resolves from Card:: namespace first
end
Use -able suffix for behavior concerns, nouns for feature concerns.
# Behaviors: -able suffix
module Card::Closeable # Can be closed
module Card::Searchable # Can be searched
module User::Mentionable # Can be mentioned
# Features: nouns
module User::Avatar # Has avatar
module User::Role # Has role
module Card::Mentions # Has @mentions
Use template methods in shared concerns for customizable behavior.
# app/models/concerns/searchable.rb (shared)
module Searchable
def search_title
raise NotImplementedError
end
end
# app/models/card/searchable.rb (model-specific)
module Card::Searchable
include ::Searchable
def search_title
title # Implement the hook
end
end
Pair sync methods with _later variants that enqueue jobs.
# app/models/card/readable.rb
def remove_inaccessible_notifications
# Sync implementation
end
private
def remove_inaccessible_notifications_later
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
end
# app/jobs/card/remove_inaccessible_notifications_job.rb
class Card::RemoveInaccessibleNotificationsJob < ApplicationJob
def perform(card)
card.remove_inaccessible_notifications
end
end
Jobs call model methods. All logic lives in models.
# Bad: Logic in job
class ProcessOrderJob < ApplicationJob
def perform(order)
order.items.each { |i| i.product.decrement!(:stock) }
order.update!(status: :processing)
end
end
# Good: Job delegates to model
class ProcessOrderJob < ApplicationJob
def perform(order)
order.process # Single method call
end
end
Create resource controllers for state changes, not custom actions.
# Bad: Custom actions
resources :cards do
post :close
post :reopen
end
# Good: Resource controllers
resources :cards do
resource :closure, only: [:create, :destroy]
end
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
def create
@card.close
end
def destroy
@card.reopen
end
end
Use concerns like CardScoped for nested resource setup.
# app/controllers/concerns/card_scoped.rb
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card
end
private
def set_card
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
end
# Usage
class Cards::CommentsController < ApplicationController
include CardScoped
end
Controllers call rich model APIs directly. No service objects.
# Good: Thin controller, rich model
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close # All logic in model
end
end
Use Current for request-scoped data with cascading setters.
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :account
def session=(value)
super(value)
self.user = session&.user
end
end
Current is only auto-populated in web requests. Jobs, mailers, and channels need explicit setup.
# Jobs: extend ActiveJob to serialize/restore Current.account
# Mailers from jobs: wrap in Current.with_account { mailer.deliver }
# Channels: set Current in Connection#connect
Choose between association extensions and model class methods based on context needs.
# Use extension when you need parent context (proxy_association.owner)
has_many :accesses do
def grant_to(users)
board = proxy_association.owner
Access.insert_all(users.map { |u| { user_id: u.id, board_id: board.id, account_id: board.account_id } })
end
end
# Use class method when operation is independent
class Access
def self.grant(board:, users:)
insert_all(users.map { |u| { user_id: u.id, board_id: board.id } })
end
end
Use after_commit for jobs, inline lambdas for simple ops.
# Jobs: after_commit
after_create_commit :notify_recipients_later
# Simple ops: inline lambda
after_save -> { board.touch }, if: :published?
# Conditional: remember and check pattern
before_update :remember_changes
after_update_commit :process_changes, if: :should_process?
Explicit broadcasts from controllers, not callbacks.
# app/models/message/broadcasts.rb
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [room, :messages]
end
end
# Controller calls explicitly
def create
@message = @room.messages.create!(message_params)
@message.broadcast_create
end
Use fixtures, not factories. Mirror concern structure in tests.
# test/fixtures/cards.yml
logo:
title: The logo isn't big enough
board: writebook
creator: david
# test/models/card/closeable_test.rb
class Card::CloseableTest < ActiveSupport::TestCase
test "close creates closure" do
card = cards(:logo)
assert_difference -> { Closure.count } do
card.close
end
end
end
Place service objects under model namespace, not app/services.
# Good: app/models/card/activity_spike/detector.rb
class Card::ActivitySpike::Detector
def initialize(card)
@card = card
end
def detect
# ...
end
end
Prefer expanded conditionals, order methods by invocation.
# Expanded conditionals
def find_record
if record = find_by_id(id)
record
else
NullRecord.new
end
end
# Method ordering: caller before callees
def process
step_one
step_two
end
private
def step_one; end
def step_two; end
These patterns embody "Vanilla Rails" - using Rails conventions with minimal additions:
Weekly Installs
77
Repository
GitHub Stars
81
First Seen
Feb 1, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode73
codex72
github-copilot72
gemini-cli71
amp66
kimi-cli66
Perl安全编程指南:输入验证、注入防护与安全编码实践
1,200 周安装