
Ruby On Rails Best Practices
- 241 installs
- 93 repo stars
- Updated February 1, 2026
- sergiodxa/agent-skills
Implement or refactor Rails APIs, models, controllers, and conventions so server-side features stay idiomatic, secure, and maintainable across releases.
About
Guides agents through Ruby on Rails backend development using framework conventions, solid ActiveRecord usage, clean controllers, and maintainable service patterns for production SaaS and API apps.
- Rails conventions and MVC structure
- ActiveRecord query and N+1 avoidance
- Controller and service object patterns
- Security and performance idioms
- Testing-friendly backend design
Ruby On Rails Best Practices by the numbers
- 241 all-time installs (skills.sh)
- +6 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,620 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sergiodxa/agent-skills --skill ruby-on-rails-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 241 |
|---|---|
| repo stars | ★ 93 |
| Last updated | February 1, 2026 |
| Repository | sergiodxa/agent-skills ↗ |
What it does
Implement or refactor Rails APIs, models, controllers, and conventions so server-side features stay idiomatic, secure, and maintainable across releases.
Files
Ruby on Rails Best Practices
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.
When to Apply
Reference these guidelines when:
- Organizing models, concerns, and controllers
- Writing background jobs
- Implementing real-time features with Turbo Streams
- Deciding where code should live
- Writing tests for Rails applications
- Reviewing Rails code for architectural consistency
Rules Summary
Model Organization (HIGH)
model-scoped-concerns - @rules/model-scoped-concerns.md
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
endconcern-naming - @rules/concern-naming.md
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 @mentionstemplate-method-concerns - @rules/template-method-concerns.md
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
endBackground Jobs (HIGH)
paired-async-methods - @rules/paired-async-methods.md
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
endthin-jobs - @rules/thin-jobs.md
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
endControllers (HIGH)
resource-controllers - @rules/resource-controllers.md
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
endscoping-concerns - @rules/scoping-concerns.md
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
endthin-controllers - @rules/thin-controllers.md
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
endRequest Context (MEDIUM)
current-attributes - @rules/current-attributes.md
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
endcurrent-in-other-contexts - @rules/current-in-other-contexts.md
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#connectAssociations & Callbacks (MEDIUM)
association-extensions - @rules/association-extensions.md
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
endcallbacks-patterns - @rules/callbacks-patterns.md
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?Turbo & Real-time (MEDIUM)
turbo-broadcasts - @rules/turbo-broadcasts.md
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
endTesting (MEDIUM)
fixtures-testing - @rules/fixtures-testing.md
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
endCode Organization (LOW-MEDIUM)
nested-service-objects - @rules/nested-service-objects.md
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
endcode-style - @rules/code-style.md
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; endPhilosophy
These patterns embody "Vanilla Rails" - using Rails conventions with minimal additions:
1. Rich models, thin controllers - Domain logic in models and concerns 2. No service object layer - Controllers talk to models directly 3. Co-located code - Concerns, jobs, and services near the models they serve 4. Explicit over implicit - Call broadcasts explicitly, not via callbacks 5. Convention over configuration - Follow naming patterns for predictability
Association Extensions vs Model Class Methods
Choose between association extensions and model class methods based on whether the operation needs parent context.
The Decision
Use association extensions when the operation:
- Needs access to the parent record (
proxy_association.owner) - Is fundamentally about "this parent's children" (e.g., "this board's accesses")
- Should be called as a command on the collection
Use model class methods when the operation:
- Is independent of any specific parent
- Could be called from anywhere with explicit parameters
- Is a general utility for that model
Example: Granting Access
Association Extension Approach
Use when the operation is "grant access to THIS board":
# app/models/board.rb
class Board < ApplicationRecord
has_many :accesses, dependent: :delete_all do
def grant_to(users)
board = proxy_association.owner
Access.insert_all(
Array(users).map do |user|
{
id: SecureRandom.uuid,
board_id: board.id,
user_id: user.id,
account_id: board.account_id # Needs parent's account
}
end
)
end
def revoke_from(users)
# Needs parent to check all_access? setting
destroy_by(user: users) unless proxy_association.owner.all_access?
end
end
end
# Usage - reads as a command on the board's accesses
board.accesses.grant_to(users)
board.accesses.revoke_from(old_users)Why extension works here:
grant_toneeds the board'sidandaccount_idrevoke_fromneeds to check the board'sall_access?setting- The API
board.accesses.grant_to(users)reads naturally as "grant these users access to this board"
Model Class Method Approach
Use when the operation is generic and doesn't need parent context:
# app/models/access.rb
class Access < ApplicationRecord
def self.grant(board:, users:)
insert_all(
Array(users).map do |user|
{
id: SecureRandom.uuid,
board_id: board.id,
user_id: user.id,
account_id: board.account_id
}
end
)
end
def self.revoke(board:, users:)
where(board: board, user: users).destroy_all unless board.all_access?
end
end
# Usage - explicit about what board
Access.grant(board: board, users: users)
Access.revoke(board: board, users: old_users)Why class method works here:
- All parameters are explicit - no hidden context
- Easier to discover - it's in the Access model where you'd look for it
- Can be called from anywhere without having a board's accesses collection
When to Use Each
Use Association Extensions For:
1. Operations that need multiple parent attributes:
has_many :accesses do
def grant_to(users)
board = proxy_association.owner
# Needs board.id, board.account_id, board.all_access?
Access.insert_all(users.map { |u|
{ board_id: board.id, account_id: board.account_id, user_id: u.id }
})
end
end2. Operations with behavior that varies by parent state:
has_many :accesses do
def revoke_from(users)
# Behavior depends on parent's all_access? setting
destroy_by(user: users) unless proxy_association.owner.all_access?
end
end3. Collection-scoped commands:
has_many :memberships do
def revise(granted: [], revoked: [])
transaction do
grant_to(granted)
revoke_from(revoked)
end
end
end
# Reads as: "revise this room's memberships"
room.memberships.revise(granted: new_users, revoked: old_users)Use Model Class Methods For:
1. Operations that only need IDs (no parent behavior):
class Membership < ApplicationRecord
def self.bulk_create(room_id:, user_ids:)
insert_all(user_ids.map { |uid| { room_id: room_id, user_id: uid } })
end
end
# Can be called from anywhere
Membership.bulk_create(room_id: room.id, user_ids: user_ids)2. General utilities:
class Access < ApplicationRecord
def self.cleanup_expired
where("expires_at < ?", Time.current).delete_all
end
end3. When discoverability matters more than fluent API:
If developers would naturally look in the Access model for access-related operations, put it there.
Accessing the Parent in Extensions
Use proxy_association.owner to access the parent record:
has_many :memberships do
def connected
where(user: proxy_association.owner.connected_users)
end
def grant_to(users)
room = proxy_association.owner
Membership.insert_all(
users.map { |user| { room_id: room.id, user_id: user.id } }
)
end
endCombining Both Approaches
Sometimes you want both - an extension for the fluent API and a class method for the implementation:
# app/models/access.rb
class Access < ApplicationRecord
def self.grant(board:, users:)
insert_all(
Array(users).map do |user|
{ id: SecureRandom.uuid, board_id: board.id, user_id: user.id, account_id: board.account_id }
end
)
end
end
# app/models/board.rb
class Board < ApplicationRecord
has_many :accesses do
def grant_to(users)
Access.grant(board: proxy_association.owner, users: users)
end
end
end
# Both work:
Access.grant(board: board, users: users) # Direct call
board.accesses.grant_to(users) # Fluent APIRules
1. Need parent context? Use association extension 2. Independent operation? Use model class method 3. Want both? Extension can delegate to class method 4. Access parent via proxy_association.owner 5. Choose based on how the code reads at the call site
Callback Patterns and Organization
Use callbacks strategically with consistent patterns. Prefer after_*_commit for async work, inline lambdas for simple operations, and the "remember and check" pattern for conditional callbacks.
Why
- Reliability:
after_commitensures database state is persisted before side effects - Readability: Inline lambdas are clear for simple operations
- Control: "Remember and check" pattern prevents unintended callback execution
- Testability: Predictable callback behavior is easier to test
Pattern 1: after\_\*\_commit for Jobs
Always use after_*_commit (not after_save) when enqueuing jobs:
# Bad: Job might run before transaction commits
after_create :notify_recipients_later
# Good: Job runs after transaction is committed
after_create_commit :notify_recipients_later
# Good: Specific to creation
after_create_commit :send_welcome_email
# Good: Specific to updates
after_update_commit :broadcast_changesmodule Notifiable
extend ActiveSupport::Concern
included do
has_many :notifications, as: :source, dependent: :destroy
after_create_commit :notify_recipients_later
end
private
def notify_recipients_later
NotifyRecipientsJob.perform_later(self)
end
endPattern 2: Inline Lambdas for Simple Operations
Use inline lambdas for simple, one-line callbacks:
class Card < ApplicationRecord
# Good: Simple touch operations
after_save -> { board.touch }, if: :published?
after_touch -> { board.touch }, if: :published?
# Good: Simple dependent updates
after_destroy_commit -> { creator.recalculate_stats }
end
class Board < ApplicationRecord
# Good: Touch all related records
after_update_commit -> { cards.touch_all }, if: :saved_change_to_name?
end
class Membership < ApplicationRecord
# Good: Reset connections on destroy
after_destroy_commit { user.reset_remote_connections }
endPattern 3: Remember and Check
For callbacks that depend on changes detected during before_*, use instance variables to "remember" the condition:
module Card::Stallable
extend ActiveSupport::Concern
included do
before_update :remember_to_detect_activity_spikes
after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes?
end
private
def remember_to_detect_activity_spikes
@should_detect_activity_spikes = published? && last_active_at_changed?
end
def should_detect_activity_spikes?
@should_detect_activity_spikes
end
def detect_activity_spikes_later
Card::ActivitySpike::DetectionJob.perform_later(self)
end
endWhy this works:
1. before_update runs inside the transaction, can see dirty attributes 2. Instance variable stores the decision 3. after_update_commit runs after commit, dirty state is gone but variable persists
Pattern 4: Conditional Callbacks
Use :if and :unless for simple conditions:
class Card < ApplicationRecord
after_create_commit :notify_watchers, if: :published?
after_update_commit :broadcast_changes, if: :saved_change_to_title?
after_save_commit :index_for_search, unless: :draft?
endFor complex conditions, use a method:
class Message < ApplicationRecord
after_create_commit :deliver_to_webhooks, if: :should_deliver_webhooks?
private
def should_deliver_webhooks?
room.bots_enabled? && !creator.bot? && mentionees.any?(&:bot?)
end
endPattern 5: Custom Callbacks with define_callbacks
For complex lifecycle events that don't fit CRUD:
module Account::Cancellable
extend ActiveSupport::Concern
included do
has_one :cancellation, dependent: :destroy
define_callbacks :cancel
define_callbacks :reactivate
end
def cancel(initiated_by: Current.user)
with_lock do
if cancellable? && active?
run_callbacks :cancel do
create_cancellation!(initiated_by: initiated_by)
end
send_cancellation_email
end
end
end
def reactivate
run_callbacks :reactivate do
cancellation&.destroy
end
end
end
# Other concerns can hook into these callbacks
module Account::Subscription
extend ActiveSupport::Concern
included do
set_callback :cancel, :after, :cancel_stripe_subscription
set_callback :reactivate, :after, :resume_stripe_subscription
end
endPattern 6: Callback Ordering in Concerns
Define callbacks in the included block, with the callback method below:
module Card::Searchable
extend ActiveSupport::Concern
included do
after_save_commit :update_search_index, if: :searchable?
after_destroy_commit :remove_from_search_index
end
def update_search_index
Search::Entry.upsert(search_attributes)
end
def remove_from_search_index
Search::Entry.where(searchable: self).delete_all
end
endAnti-Patterns to Avoid
Don't Use after_save for Jobs
# Bad: Transaction might rollback after job is enqueued
after_save :send_notification_later
# Good: Wait for commit
after_save_commit :send_notification_laterDon't Check Dirty Attributes in after_commit
# Bad: Dirty state is cleared after commit
after_update_commit :log_change, if: :title_changed?
# Good: Use saved_change_to_* or remember pattern
after_update_commit :log_change, if: :saved_change_to_title?Don't Create Complex Callback Chains
# Bad: Hard to follow and debug
after_create :step_one
after_create :step_two
after_create :step_three
# Good: Single callback that calls a method with clear steps
after_create_commit :handle_creation
private
def handle_creation
step_one
step_two
step_three
endRules
1. Use after_*_commit for any async work or external effects 2. Use inline lambdas for simple touch/update operations 3. Use "remember and check" when you need to detect changes in before_* but act in after_commit 4. Use saved_change_to_* in after_commit callbacks (not *_changed?) 5. Keep callbacks focused - one callback, one purpose 6. Define custom callbacks with define_callbacks for domain-specific lifecycle events
Ruby Code Style Conventions
Follow consistent code style conventions for readable, maintainable Ruby code. These patterns are inspired by Basecamp's coding style.
Conditional Returns
Prefer expanded conditionals over guard clauses in most cases:
# Bad: Guard clause can be hard to follow
def todos_for_new_group
ids = params.require(:todolist)[:todo_ids]
return [] unless ids
@bucket.recordings.todos.find(ids.split(","))
end
# Good: Expanded conditional
def todos_for_new_group
if ids = params.require(:todolist)[:todo_ids]
@bucket.recordings.todos.find(ids.split(","))
else
[]
end
endException: Early Returns for Preconditions
Guard clauses are acceptable when:
1. The return is at the very beginning 2. The main body is non-trivial
# OK: Early return for precondition
def after_recorded_as_commit(recording)
return if recording.parent.was_created?
if recording.was_created?
broadcast_new_column(recording)
else
broadcast_column_change(recording)
end
endMethod Ordering
Order methods by their call hierarchy:
1. class methods (if any) 2. public instance methods with initialize first 3. private methods in invocation order
class SomeClass
def some_method
method_1
method_2
end
private
def method_1
method_1_1
method_1_2
end
def method_1_1
# ...
end
def method_1_2
# ...
end
def method_2
# ...
end
endVisibility Modifiers
Indent under private, no newline after the modifier:
# Good
class SomeClass
def public_method
# ...
end
private
def private_method_1
# ...
end
def private_method_2
# ...
end
endFor modules with only private methods, don't indent:
module SomeModule
private
def some_private_method
# ...
end
endBang Methods
Only use ! suffix when there's a corresponding method without !:
# Good: Bang indicates "raises on failure" variant
def save; end
def save!; end
def find_by; end
def find_by!; end
# Bad: No non-bang counterpart exists
def destroy!; end # Just use destroy
# Bad: Using bang for "destructive" without counterpart
def delete_all!; end # Just use delete_allLine Length and Breaking
Keep lines readable. Break long method chains:
# Bad: Too long
result = users.active.where(role: :admin).includes(:profile).order(created_at: :desc).limit(10)
# Good: Break at method calls
result = users
.active
.where(role: :admin)
.includes(:profile)
.order(created_at: :desc)
.limit(10)Break long argument lists:
# Good: Arguments on separate lines
create_notification!(
user: recipient,
source: self,
creator: Current.user,
action: :mentioned
)Hash Syntax
Use modern hash syntax:
# Bad
{ :name => "David", :email => "david@example.com" }
# Good
{ name: "David", email: "david@example.com" }String Interpolation
Use interpolation over concatenation:
# Bad
"Hello, " + user.name + "!"
# Good
"Hello, #{user.name}!"Blocks
Use do...end for multi-line blocks, { } for single line:
# Good: Single line
users.map { |u| u.name.upcase }
# Good: Multi-line
users.each do |user|
user.send_notification
user.update!(notified_at: Time.current)
endConcern Structure: What Goes Where
Concerns have three distinct areas, each with a specific purpose:
1. included Block: Class-Level Macros
The included block runs when the concern is included into a class. Put class-level macros here - things that configure the class itself:
- Associations:
has_many,belongs_to,has_one - Validations:
validates,validate - Callbacks:
after_save,before_create, etc. - Scopes:
scope :active, -> { ... }
included do
has_one :closure, dependent: :destroy
validates :title, presence: true
scope :closed, -> { joins(:closure) }
after_create_commit :notify_creator
endWhy it must be in `included`: These are method calls on the class (like Card.has_one). They need to run when the concern is included, not when the module is defined. Without included, they would run when Ruby loads the module file, before any class has included it.
2. Outside included: Instance Methods
Regular instance methods go outside the included block. They're automatically added to any class that includes the concern:
def closed?
closure.present?
end
def close
create_closure!
end
private
def notify_creator
NotifyJob.perform_later(self)
endWhy outside: Instance methods don't need special timing - Ruby's include automatically adds the module's methods to the class.
3. class_methods Block: Class Methods
Use the class_methods block for methods you want to call on the class itself:
class_methods do
def search(query)
where("title LIKE ?", "%#{query}%")
end
endComplete Example
module Card::Closeable
extend ActiveSupport::Concern
# 1. Class-level macros - configure the including class
included do
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
after_create_commit :notify_creator
end
# 2. Class methods - called on the class (Card.find_closed)
class_methods do
def find_closed(id)
closed.find(id)
end
end
# 3. Instance methods - called on instances (card.closed?)
def closed?
closure.present?
end
def close
create_closure!
end
private
def notify_creator
# ...
end
endCommon Mistake
# WRONG: This runs when the file is loaded, not when included
module Card::Closeable
extend ActiveSupport::Concern
has_one :closure # Error! No class to call has_one on yet
def closed?
closure.present?
end
end
# CORRECT: Macros in included block
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure # Runs when Card includes this concern
end
def closed?
closure.present?
end
endPredicate Methods
Name boolean-returning methods with ?:
def closed?
closure.present?
end
def can_edit?(user)
creator == user || user.admin?
end
def published?
status == "published"
endAvoid Negated Conditions in Method Names
# Bad
def not_published?
!published?
end
# Good: Use the positive form
def draft?
!published?
end
# Or check the actual state
def draft?
status == "draft"
endAvoid Double Negatives
# Bad
unless !user.active?
# ...
end
# Good
if user.active?
# ...
endRules
1. Prefer expanded conditionals over guard clauses 2. Order methods by invocation hierarchy 3. Indent under private, no newline after modifier 4. Only use ! suffix when a non-bang method exists 5. Break long lines at natural points 6. Use modern hash syntax 7. Use do...end for multi-line blocks 8. Name predicate methods with ? suffix 9. Avoid negated method names
Concern Naming Conventions
Use consistent naming patterns for concerns that communicate their purpose at a glance.
Why
- Self-documenting: Good names tell you what a model can do or has
- Consistency: Predictable naming makes the codebase easier to navigate
- Discoverability: Developers can guess concern names without searching
Naming Patterns
Adjectives ending in -able (Most Common)
Use for capabilities or behaviors the model can perform:
module Card::Closeable # Can be closed
module Card::Assignable # Can be assigned
module Card::Searchable # Can be searched
module Card::Watchable # Can be watched
module Card::Taggable # Can be tagged
module Card::Postponable # Can be postponed
module User::Mentionable # Can be mentioned
module User::Transferable # Can be transferred
module Board::Accessible # Has access control
module Account::Cancellable # Can be cancelledAdjectives ending in -ed (State/Tracking)
Use for concerns that track or materialize state:
module Storage::Tracked # Tracks storage usage
module Storage::Totaled # Has materialized totalsNouns (Features/Concepts)
Use when the concern represents a distinct feature or concept:
module User::Avatar # Has avatar functionality
module User::Role # Has role/permissions
module Card::Mentions # Has @mentions functionality
module Card::Statuses # Has status management
module Account::Storage # Has storage managementPresent Participles (Actions)
Use sparingly for action-focused concerns:
module User::Filtering # Provides filtering capabilities
module Card::Broadcastable # Handles Turbo broadcastingDerived from Associations
Sometimes name after the association it manages:
module User::Accessor # Manages Access records
module User::Assignee # Acts as an assignee
module Board::Cards # Manages cards relationshipBad: Inconsistent or Unclear Names
module CardHelpers # Too vague
module CardMixin # Meaningless suffix
module DoesCardStuff # Not a proper adjective/noun
module CardClosingBehavior # Overly verbose
module CloseableCard # Wrong order - model name comes first in namespaceGood: Clear, Consistent Names
module Card::Closeable
module Card::Assignable
module Card::Eventable
module User::Notifiable
module Board::AccessibleNaming Decision Tree
1. Does it add a capability the model can do? → Use -able (e.g., Searchable) 2. Does it track state or provide data? → Use -ed or noun (e.g., Tracked, Storage) 3. Does it represent a distinct feature? → Use noun (e.g., Avatar, Role) 4. Does it manage an association? → Consider deriving from association name
Rules
1. Prefer -able suffix for behaviors and capabilities 2. Use the model namespace prefix (Card::, not Card prefix) 3. Keep names short - one word when possible 4. Be consistent across the codebase 5. Names should be guessable - a developer should be able to find concerns without searching
Use Current Attributes for Request Context
Use ActiveSupport::CurrentAttributes to store request-scoped data like the current user, account, and request metadata. Design attribute setters to cascade related values.
Why
- Global access: Any model or service can access
Current.userwithout passing it around - Thread safety: CurrentAttributes is thread-local and request-scoped
- Clean interfaces: Models don't need user/account parameters everywhere
- Automatic cleanup: Values are reset between requests
Basic Setup
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :account
attribute :request_id, :user_agent, :ip_address
def session=(value)
super(value)
self.user = session&.user
end
endPattern: Cascading Attribute Setters
When setting one attribute should automatically set related attributes:
# app/models/current.rb (multi-tenant app)
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :identity, :account
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
# Setting session cascades to identity
def session=(value)
super(value)
self.identity = session&.identity if value.present?
end
# Setting identity cascades to user (scoped to account)
def identity=(identity)
super(identity)
self.user = identity&.users&.find_by(account: account) if identity.present?
end
# Helper for running code in a specific account context
def with_account(value, &)
with(account: value, &)
end
def without_account(&)
with(account: nil, &)
end
endSingle-Tenant Version
# app/models/current.rb (single-tenant app)
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :request
delegate :host, :protocol, to: :request, prefix: true, allow_nil: true
def session=(value)
super(value)
self.user = session&.user if value.present?
end
def account
Account.first # Single tenant always uses first account
end
endSetting Current from Controllers
Use a concern to set Current values from the request:
# app/controllers/concerns/set_current_request.rb
module SetCurrentRequest
extend ActiveSupport::Concern
included do
before_action :set_current_request
end
private
def set_current_request
Current.request = request
Current.request_id = request.uuid
Current.user_agent = request.user_agent
Current.ip_address = request.ip
end
end
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
included do
before_action :resume_session
end
private
def resume_session
Current.session = find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(token: cookies.signed[:session_token])
end
endUsing Current in Models
Default Values
class Comment < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
end
class Event < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
before_create do
self.request_id ||= Current.request_id
self.ip_address ||= Current.ip_address
end
endAuthorization Checks
class Card < ApplicationRecord
def editable_by?(user = Current.user)
creator == user || user.admin?
end
endScoped Queries
class Board < ApplicationRecord
scope :accessible, -> {
where(id: Current.user.accessible_board_ids)
}
endCurrent in Tests
Set up Current in test helpers:
# test/test_helper.rb
class ActiveSupport::TestCase
setup do
Current.account = accounts(:primary)
end
teardown do
Current.reset_all
end
end
# For specific test contexts
def with_current_user(user)
original = Current.user
Current.user = user
yield
ensure
Current.user = original
endImportant Limitation
Current is only auto-populated in web requests via controller concerns. Other contexts (jobs, mailers called from jobs, ActionCable channels) need explicit setup. See current-in-other-contexts.md for how to handle those cases.
Rules
1. Define Current as a subclass of ActiveSupport::CurrentAttributes 2. Use cascading setters to automatically set related attributes 3. Set Current values in controller concerns, not individual actions 4. Use Current.user for default values in models 5. Always reset Current in tests (teardown) 6. Access Current through the class, never store references to attributes
Populating Current in Jobs, Mailers, and Channels
Current is only auto-populated in web requests. Jobs, mailers called from jobs, and ActionCable channels run in separate contexts where Current starts empty. Each context needs explicit setup.
The Problem
When you use Current.account in a model:
class Card < ApplicationRecord
belongs_to :account, default: -> { Current.account }
endThis works in web requests because controllers set Current.session, which cascades to set other values. But in a background job, Current.account is nil because:
1. Jobs run in a separate process/thread 2. There's no HTTP request to extract context from 3. Current is reset between requests/jobs
Background Jobs
To have Current.account available in jobs, extend ActiveJob to capture it at enqueue time and restore it at perform time:
# config/initializers/active_job.rb
module CurrentAttributesJobExtensions
extend ActiveSupport::Concern
prepended do
attr_reader :account
# Wait for transaction to commit before enqueueing
self.enqueue_after_transaction_commit = true
end
# Capture Current.account when job is created (during web request)
def initialize(...)
super
@account = Current.account
end
# Store account in job payload
def serialize
super.merge("account" => @account&.to_gid)
end
# Restore account when job is deserialized by worker
def deserialize(job_data)
super
if gid = job_data["account"]
@account = GlobalID::Locator.locate(gid)
end
end
# Wrap job execution in Current context
def perform_now
if account.present?
Current.with_account(account) { super }
else
super
end
end
end
ActiveSupport.on_load(:active_job) do
prepend CurrentAttributesJobExtensions
endHow It Works
Web Request Background Worker
----------- -----------------
User clicks button
↓
Controller enqueues job
↓
Job.new captures Current.account ───────→ Job serialized to queue
↓
Worker picks up job
↓
deserialize restores @account
↓
perform_now sets Current.account
↓
Job runs with Current.account setWhy NOT Serialize Current.user?
You typically don't serialize Current.user because:
- Jobs often run much later when user context is stale
- The user who triggered the action may not be the right context for the job
- If a job needs a user, pass it explicitly as an argument
# Good: Pass user explicitly when needed
class NotifyUserJob < ApplicationJob
def perform(user, message)
user.notify(message)
end
end
# In controller
NotifyUserJob.perform_later(Current.user, "Hello")Mailers Called from Jobs
When a job calls a mailer, the mailer also has no Current context. Set it in the model's delivery method:
# app/models/notification/bundle.rb
class Notification::Bundle < ApplicationRecord
def deliver
user.in_time_zone do
Current.with_account(user.account) do # Set Current before mailer
processing!
Notification::BundleMailer.notification(self).deliver if deliverable?
delivered!
end
end
end
endThe mailer can then use Current.account:
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
private
def default_url_options
if Current.account
super.merge(script_name: Current.account.slug) # Multi-tenant URLs
else
super
end
end
end
# app/mailers/notification/bundle_mailer.rb
class Notification::BundleMailer < ApplicationMailer
def notification(bundle)
@bundle = bundle
@user = bundle.user
mail \
to: @user.identity.email_address,
subject: "Fizzy#{" (#{Current.account.name})" if @user.identity.accounts.many?}: New notifications"
end
endThe key is that deliver wraps the mailer call in Current.with_account, so by the time the mailer runs, Current.account is set.
ActionCable Channels
Each WebSocket connection must set up its own Current context in the connect method:
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = find_session_by_cookie
# Extract account from request (e.g., from URL or subdomain)
account = Account.find_by(external_account_id: request.env["fizzy.external_account_id"])
Current.account = account
self.current_user = session.identity.users.find_by!(account: account) if account
end
end
def find_session_by_cookie
Session.find_signed(cookies.signed[:session_token])
end
end
endCurrent set in connect persists for that connection. All channel subscriptions on the same connection share the same Current values.
Console and Scripts
In Rails console or scripts, set Current manually:
# In console
Current.account = Account.first
Current.user = User.find_by(email: "admin@example.com")
# In a script
Account.find_each do |account|
Current.with_account(account) do
# Do work in this account's context
end
endContext Summary
| Context | Current Populated? | How to Set Up |
|---|---|---|
| Web Request | Yes | Controller concerns with cascading setters |
| Background Job | No | Extend ActiveJob to serialize/restore |
| Mailer from Job | No | Wrap mailer call in Current.with_account |
| ActionCable | No | Set in Connection#connect |
| Console | No | Set manually |
| Tests | No | Set in setup, reset in teardown |
Rules
1. Jobs: Extend ActiveJob to serialize Current.account at enqueue and restore at perform 2. Mailers from jobs: Wrap mailer calls in Current.with_account { ... } 3. Channels: Set Current in Connection#connect 4. Don't serialize Current.user in jobs - pass users explicitly as arguments 5. Use Current.with_account for temporary context changes 6. Remember: if Current.account is nil unexpectedly, you're probably in a non-request context
Use Fixtures for Test Data
Use Rails fixtures instead of factories (FactoryBot). Create a comprehensive fixture set that represents your domain, with deterministic IDs for predictable ordering.
Why
- Speed: Fixtures load once per test run, factories create objects per test
- Realism: Fixtures represent a complete, coherent dataset
- Simplicity: No factory DSL to learn or maintain
- Predictability: Deterministic IDs make debugging easier
- Discovery: All test data is visible in YAML files
Fixture Organization
test/fixtures/
├── accounts.yml
├── users.yml
├── boards.yml
├── cards.yml
├── comments.yml
├── events.yml
├── sessions.yml
└── files/ # Binary fixtures (images, etc.)
└── avatar.pngBasic Fixture Patterns
Named Fixtures with References
# test/fixtures/users.yml
david:
name: David
email: david@example.com
account: primary
role: admin
kevin:
name: Kevin
email: kevin@example.com
account: primary
role: memberUUID Primary Keys
For apps using UUIDs, generate deterministic IDs:
# test/fixtures/cards.yml
logo:
id: <%= ActiveRecord::FixtureSet.identify("logo", :uuid) %>
title: The logo isn't big enough
board: writebook
creator: david
status: published
created_at: <%= 1.week.ago %>Foreign Key References
Use fixture names directly (Rails resolves them):
# test/fixtures/comments.yml
first_comment:
card: logo # References cards(:logo)
creator: kevin # References users(:kevin)
body: I agree!For UUID foreign keys, use the _uuid suffix convention:
# test/fixtures/events.yml
logo_published:
id: <%= ActiveRecord::FixtureSet.identify("logo_published", :uuid) %>
board: writebook_uuid
creator: david_uuid
eventable: logo (Card)Polymorphic Associations
# test/fixtures/notifications.yml
logo_notification:
user: kevin
source: logo_published (Event) # Type in parenthesesJSON/JSONB Columns
# test/fixtures/events.yml
card_assignment:
particulars: <%= { assignee_ids: [ActiveRecord::FixtureSet.identify("kevin", :uuid)] }.to_json %>Accessing Fixtures in Tests
class CardTest < ActiveSupport::TestCase
test "card has a title" do
card = cards(:logo)
assert_equal "The logo isn't big enough", card.title
end
test "card belongs to board" do
assert_equal boards(:writebook), cards(:logo).board
end
endMultiple Fixtures at Once
test "all cards are valid" do
cards(:logo, :layout, :draft).each do |card|
assert card.valid?
end
endTest Setup with Current
Set up Current attributes in test helper:
# test/test_helper.rb
class ActiveSupport::TestCase
fixtures :all
setup do
Current.account = accounts(:primary)
end
teardown do
Current.reset_all
end
endSession/User Context
# test/test_helpers/session_test_helper.rb
module SessionTestHelper
def sign_in_as(user_or_fixture)
user = user_or_fixture.is_a?(Symbol) ? users(user_or_fixture) : user_or_fixture
Current.session = sessions(user.name.parameterize.to_sym)
Current.user = user
end
def with_current_user(user)
original = Current.user
Current.user = user
yield
ensure
Current.user = original
end
endTesting Concerns
Test concerns in files mirroring their location:
test/models/
├── card_test.rb
├── card/
│ ├── closeable_test.rb # Tests Card::Closeable
│ ├── searchable_test.rb # Tests Card::Searchable
│ └── watchable_test.rb # Tests Card::Watchable
└── concerns/
└── mentions_test.rb # Tests shared Mentions concern# test/models/card/closeable_test.rb
class Card::CloseableTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "close creates a closure" do
card = cards(:logo)
assert_difference -> { Closure.count }, 1 do
card.close
end
assert card.closed?
end
test "reopen destroys the closure" do
card = cards(:shipping) # Already closed in fixtures
assert_difference -> { Closure.count }, -1 do
card.reopen
end
assert_not card.closed?
end
endIntegration Test Patterns
class CardsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :david
end
test "create a new card" do
assert_difference -> { Card.count }, 1 do
post board_cards_path(boards(:writebook)),
params: { card: { title: "New card" } }
end
assert_redirected_to card_path(Card.last)
end
test "unauthorized user cannot access card" do
sign_in_as :other_account_user
get card_path(cards(:logo))
assert_response :not_found
end
endTesting Turbo Streams
test "closing card returns turbo stream" do
card = cards(:logo)
post card_closure_path(card), as: :turbo_stream
assert_turbo_stream action: :replace, target: dom_id(card, :card_container)
endTesting Jobs
test "closing card enqueues notification job" do
card = cards(:logo)
assert_enqueued_with(job: NotifyRecipientsJob) do
card.close
end
end
test "job calls model method" do
card = cards(:logo)
perform_enqueued_jobs only: Card::ActivitySpike::DetectionJob do
card.update!(last_active_at: Time.current)
end
assert card.reload.stalled?
endRules
1. Use fixtures, not factories 2. Create a coherent dataset that represents real usage 3. Use deterministic IDs for predictable ordering 4. Reference fixtures by name in tests 5. Set up Current attributes in test setup 6. Mirror concern location in test file structure 7. Use assert_enqueued_with for job testing 8. Use assert_turbo_stream for Turbo response testing
Use Model-Scoped Concerns
Place concerns specific to a single model in a subdirectory named after the model (app/models/model_name/), not in the shared app/models/concerns/ directory.
Why
- Co-location: Related code lives together, making it easier to understand a model's full behavior
- Namespace clarity:
Card::Closeableclearly belongs to Card, not a shared utility - Avoid bloated concerns directory: The shared concerns folder stays small and truly reusable
- Natural discovery: When exploring a model, you immediately see all its behaviors in its directory
Directory Structure
app/models/
├── card.rb
├── card/
│ ├── closeable.rb # Card::Closeable
│ ├── assignable.rb # Card::Assignable
│ ├── searchable.rb # Card::Searchable (overrides shared)
│ └── activity_spike/
│ └── detector.rb # Card::ActivitySpike::Detector (service)
├── user.rb
├── user/
│ ├── avatar.rb # User::Avatar
│ ├── notifiable.rb # User::Notifiable
│ └── role.rb # User::Role
├── concerns/ # Only truly shared concerns
│ ├── searchable.rb # Generic Searchable (template)
│ └── mentions.rb # Generic Mentions (template)Bad: Everything in Shared Concerns
# app/models/concerns/card_closeable.rb
module CardCloseable
extend ActiveSupport::Concern
# ...
end
# app/models/concerns/card_assignable.rb
module CardAssignable
extend ActiveSupport::Concern
# ...
end
# app/models/card.rb
class Card < ApplicationRecord
include CardCloseable, CardAssignable
endProblems:
- Concerns directory becomes a dumping ground
- Naming requires prefixes to avoid collisions
- Hard to see what behaviors a model has without searching
Good: Model-Scoped Concerns
# app/models/card/closeable.rb
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
end
def closed?
closure.present?
end
def close
create_closure!
end
def reopen
closure&.destroy
end
end
# app/models/card.rb
class Card < ApplicationRecord
include Closeable, Assignable, Searchable, Watchable
# Ruby resolves these from Card:: namespace first
endWhen to Use Shared Concerns
Place concerns in app/models/concerns/ only when:
1. Multiple models use identical behavior (not just similar) 2. The concern provides a template that model-specific concerns override
# app/models/concerns/searchable.rb (shared template)
module Searchable
extend ActiveSupport::Concern
included do
after_save_commit :update_search_index
end
# Template methods - models override these
def search_title
raise NotImplementedError
end
def searchable?
true
end
end
# app/models/card/searchable.rb (model-specific)
module Card::Searchable
extend ActiveSupport::Concern
included do
include ::Searchable # Include shared template
end
def search_title
title
end
def searchable?
published?
end
endRules
1. Default to model-scoped concerns (app/models/model_name/concern.rb) 2. Name concerns using the model namespace (Card::Closeable, not CardCloseable) 3. Include without namespace prefix - Ruby resolves Card::Closeable automatically 4. Use shared concerns only for true cross-model abstractions or templates 5. Nest service objects and value objects under the model namespace too
Nest Service Objects Under Model Namespaces
When you need service objects, value objects, or plain Ruby classes, place them under the model namespace they operate on rather than in a separate app/services directory.
Why
- Co-location: Related code lives together
- Discoverability: Find all card-related code in
app/models/card/ - No separate layer: Avoids proliferating directory structures
- Natural namespacing:
Card::ActivitySpike::Detectorclearly belongs to Card
Directory Structure
app/models/
├── card.rb
├── card/
│ ├── closeable.rb # Concern
│ ├── searchable.rb # Concern
│ ├── activity_spike/
│ │ └── detector.rb # Service object
│ └── eventable/
│ └── system_commenter.rb # Service object
├── user.rb
├── user/
│ ├── day_timeline.rb # Value object
│ └── day_timeline/
│ ├── column.rb # Nested value object
│ └── serializable.rb # Concern for value object
├── room.rb
└── room/
└── message_pusher.rb # Service objectBad: Separate Services Directory
app/
├── models/
│ └── card.rb
├── services/
│ ├── card_activity_spike_detector.rb
│ ├── card_system_commenter.rb
│ ├── room_message_pusher.rb
│ └── user_day_timeline_builder.rbProblems:
- Services directory becomes a dumping ground
- Awkward naming to avoid collisions
- Related code is scattered
Good: Nested Under Models
Service Object Example
# app/models/card/activity_spike/detector.rb
class Card::ActivitySpike::Detector
attr_reader :card
def initialize(card)
@card = card
end
def detect
if has_activity_spike?
register_activity_spike
true
else
false
end
end
private
def has_activity_spike?
card.entropic? &&
(multiple_people_commented? || card_was_just_assigned? || card_was_just_reopened?)
end
def multiple_people_commented?
recent_comments.distinct.count(:creator_id) >= 3
end
def recent_comments
card.comments.where("created_at > ?", 24.hours.ago)
end
def register_activity_spike
card.create_activity_spike!
end
endUsage:
# app/models/card/stallable.rb
module Card::Stallable
def detect_activity_spikes
Card::ActivitySpike::Detector.new(self).detect
end
private
def detect_activity_spikes_later
Card::ActivitySpike::DetectionJob.perform_later(self)
end
endValue Object Example
# app/models/user/day_timeline.rb
class User::DayTimeline
include Serializable
attr_reader :user, :day, :filter
delegate :today?, to: :day
def initialize(user, day, filter)
@user, @day, @filter = user, day, filter
end
def events
@events ||= user.events_for(day).filtered_by(filter)
end
def has_activity?
events.any?
end
def columns
@columns ||= group_events_into_columns
end
private
def group_events_into_columns
events.group_by(&:hour).map do |hour, hour_events|
User::DayTimeline::Column.new(hour, hour_events)
end
end
end
# app/models/user/day_timeline/column.rb
class User::DayTimeline::Column
attr_reader :hour, :events
def initialize(hour, events)
@hour, @events = hour, events
end
def time_label
hour.strftime("%l %p")
end
endUsage:
# app/models/user.rb
class User < ApplicationRecord
def timeline_for(day, filter:)
User::DayTimeline.new(self, day, filter)
end
endSystem Commenter Example
# app/models/card/eventable/system_commenter.rb
class Card::Eventable::SystemCommenter
include ERB::Util
attr_reader :card, :event
def initialize(card, event)
@card, @event = card, event
end
def comment
return unless comment_body.present?
card.comments.create!(
creator: card.account.system_user,
body: comment_body,
created_at: event.created_at
)
end
private
def comment_body
case event.action
when "closed" then "Closed by #{event.creator.name}"
when "reopened" then "Reopened by #{event.creator.name}"
when "assigned" then "Assigned to #{assignee_names}"
end
end
def assignee_names
event.particulars["assignee_names"].to_sentence
end
endMessage Pusher Example
# app/models/room/message_pusher.rb
class Room::MessagePusher
attr_reader :room, :message
def initialize(room:, message:)
@room, @message = room, message
end
def push
payload = build_payload
push_to_subscribers(payload)
end
private
def build_payload
{
title: room.name,
body: message.preview,
data: { room_id: room.id, message_id: message.id }
}
end
def push_to_subscribers(payload)
room.push_subscriptions.find_each do |subscription|
subscription.deliver(payload)
end
end
endWhen to Create Service Objects
Create service objects when:
1. Complex operation - Too much logic for a single model method 2. Multiple collaborators - Coordinates between multiple models 3. Reusable logic - Same operation used in multiple places 4. Testable unit - Logic benefits from isolated testing
Don't create service objects for:
1. Simple CRUD - Use model methods 2. Single model operations - Put in model or concern 3. Every controller action - This isn't Java
Rules
1. Place service objects under the model namespace they operate on 2. Use descriptive class names (Detector, Commenter, Pusher) 3. Keep the interface simple - usually initialize + one public method 4. Value objects are also nested under the model namespace 5. No separate app/services directory 6. If it doesn't clearly belong to one model, it might belong in the model it creates/modifies
Pair Synchronous Methods with Async \_later Variants
When a model method needs to run asynchronously, create a paired method with the _later suffix that enqueues a job calling the synchronous version.
Why
- Testability: The synchronous method can be tested directly without job infrastructure
- Flexibility: Callers choose sync or async based on context
- Clarity: The naming convention makes async behavior explicit
- Consistency: A predictable pattern across the entire codebase
Pattern Structure
# Model provides both sync and async versions
class Card < ApplicationRecord
def do_something
# Synchronous implementation
end
def do_something_later
DoSomethingJob.perform_later(self)
end
end
# Job is a thin wrapper
class DoSomethingJob < ApplicationJob
def perform(card)
card.do_something
end
endBad: Logic in the Job
# app/jobs/remove_inaccessible_notifications_job.rb
class RemoveInaccessibleNotificationsJob < ApplicationJob
def perform(card)
# Business logic buried in job
accessible_user_ids = card.board.accesses.pluck(:user_id)
card.notifications.where.not(user_id: accessible_user_ids).destroy_all
end
end
# app/models/card.rb
class Card < ApplicationRecord
def remove_inaccessible_notifications_later
RemoveInaccessibleNotificationsJob.perform_later(self)
end
# No way to call this synchronously!
endGood: Logic in Model, Job Delegates
# app/models/card/readable.rb
module Card::Readable
extend ActiveSupport::Concern
def remove_inaccessible_notifications
accessible_user_ids = board.accesses.pluck(:user_id)
notification_sources.each do |sources|
inaccessible_notifications_from(sources, accessible_user_ids)
.in_batches
.destroy_all
end
end
private
def remove_inaccessible_notifications_later
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
end
end
# app/jobs/card/remove_inaccessible_notifications_job.rb
class Card::RemoveInaccessibleNotificationsJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.remove_inaccessible_notifications
end
endNaming Convention: _later vs _now
Use _later for the async version. If needed, use _now for emphasis:
# Standard case: method + method_later
def deliver
# sync delivery
end
def deliver_later
DeliveryJob.perform_later(self)
end
# When called from a callback (async default), add _now for sync
# app/models/event/relaying.rb
module Event::Relaying
included do
after_create_commit :relay_later
end
def relay_later
Event::RelayJob.perform_later(self)
end
def relay_now
# Synchronous relay logic
end
end
# app/jobs/event/relay_job.rb
class Event::RelayJob < ApplicationJob
def perform(event)
event.relay_now
end
endReal-World Examples
Storage Materialization
# app/models/concerns/storage/totaled.rb
module Storage::Totaled
def materialize_storage
total = create_or_find_storage_total
total.with_lock do
total.update!(bytes_stored: calculate_current_storage)
end
end
def materialize_storage_later
Storage::MaterializeJob.perform_later(self)
end
end
# app/jobs/storage/materialize_job.rb
class Storage::MaterializeJob < ApplicationJob
queue_as :backend
limits_concurrency to: 1, key: ->(owner) { owner }
discard_on ActiveJob::DeserializationError
def perform(owner)
owner.materialize_storage
end
endWebhook Delivery
# app/models/webhook/delivery.rb
class Webhook::Delivery < ApplicationRecord
after_create_commit :deliver_later
def deliver_later
Webhook::DeliveryJob.perform_later(self)
end
def deliver
in_progress!
self.response = perform_request
completed!
rescue => e
errored!
raise
end
endMentions Creation
# app/models/concerns/mentions.rb
module Mentions
included do
after_save_commit :create_mentions_later, if: :should_create_mentions?
end
def create_mentions(mentioner: Current.user)
scan_mentionees.each do |mentionee|
mentionee.mentioned_by mentioner, at: self
end
end
private
def create_mentions_later
Mention::CreateJob.perform_later(self, mentioner: Current.user)
end
endVisibility Guidelines
- The
_latermethod is often private when called from callbacks - Make it public if controllers or other models need to call it directly
- The synchronous method should always be public for testing and direct use
module Card::Accessible
def clean_inaccessible_data
# Public sync method
end
private
def clean_inaccessible_data_later
# Private - only called from callbacks
Card::CleanInaccessibleDataJob.perform_later(self)
end
endRules
1. Name async methods with _later suffix 2. Keep jobs thin - they only call the model method 3. The synchronous method contains all business logic 4. Use _now suffix when the async version is the default (e.g., from callbacks) 5. Make _later private if only used from callbacks 6. Always add discard_on ActiveJob::DeserializationError to handle deleted records
Model Everything as Resource Controllers
When an action doesn't map cleanly to standard CRUD verbs, introduce a new resource rather than adding custom actions. Every controller action should be one of: index, show, new, create, edit, update, destroy.
Why
- Predictability: Developers always know where to find code for any feature
- Simpler controllers: Each controller has fewer actions, each doing one thing
- Better routing: RESTful routes are self-documenting
- Easier testing: Standard CRUD actions have predictable test patterns
Bad: Custom Actions
# config/routes.rb
resources :cards do
post :close
post :reopen
post :archive
post :pin
post :unpin
patch :assign
patch :move_to_column
end
# app/controllers/cards_controller.rb
class CardsController < ApplicationController
def show; end
def create; end
def update; end
def destroy; end
def close; end # Custom
def reopen; end # Custom
def archive; end # Custom
def pin; end # Custom
def unpin; end # Custom
def assign; end # Custom
def move_to_column; end # Custom
endProblems:
- Controller has many actions
- Non-standard verbs (what HTTP method for
close?) - Inconsistent patterns
- Hard to extend without adding more custom actions
Good: Resource Controllers for State Changes
# config/routes.rb
resources :cards do
resource :closure, only: [:create, :destroy]
resource :archive, only: [:create, :destroy]
resource :pin, only: [:create, :destroy]
resource :assignment, only: [:create, :update, :destroy]
scope module: :cards do
resources :comments
resources :taggings, only: [:create, :destroy]
end
end
resources :columns do
resources :cards do
resource :drop, only: :create, module: :cards
end
endClosure Controller (Toggle On/Off)
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@card.reopen
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
endPin Controller (User-Specific Toggle)
# app/controllers/cards/pins_controller.rb
class Cards::PinsController < ApplicationController
include CardScoped
def create
@pin = @card.pin_by(Current.user)
broadcast_add_pin_to_tray
end
def destroy
@pin = @card.unpin_by(Current.user)
broadcast_remove_pin_from_tray
end
endWatch Controller
# app/controllers/cards/watches_controller.rb
class Cards::WatchesController < ApplicationController
include CardScoped
def create
@card.watch_by(Current.user)
end
def destroy
@card.unwatch_by(Current.user)
end
endCommon Resource Patterns
Binary State Changes
For toggling states like open/closed, published/draft:
resource :closure # create = close, destroy = reopen
resource :publication # create = publish, destroy = unpublish
resource :archive # create = archive, destroy = unarchiveUser-Specific Resources
For things a user can add/remove:
resource :pin # create = pin, destroy = unpin
resource :watch # create = watch, destroy = unwatch
resource :bookmark # create = bookmark, destroy = unbookmarkNested Actions on Collections
# Moving cards between columns
resources :columns do
resources :cards do
scope module: :cards do
scope module: :drops do
resource :column, only: :create # Drop into column
resource :closure, only: :create # Drop into closed
resource :stream, only: :create # Drop into triage
end
end
end
endPosition Changes
resources :columns do
resource :left_position, only: :create # Move left
resource :right_position, only: :create # Move right
endController Size Guide
Each controller should have at most 7 actions (the CRUD set). If you're adding more:
1. Extract a new resource - Most custom actions are just CRUD on a hidden resource 2. Ask: What is being created/updated/destroyed? - That's your new resource
| Custom Action | New Resource |
|---|---|
cards#close | Cards::ClosuresController#create |
cards#add_tag | Cards::TaggingsController#create |
cards#assign | Cards::AssignmentsController#create |
columns#move_left | Columns::LeftPositionsController#create |
boards#publish | Boards::PublicationsController#create |
Rules
1. Controllers only have standard CRUD actions 2. State changes become singular resources (resource :closure) 3. User-specific toggles are resources scoped to the parent 4. Movement/position changes are their own resources 5. Nest controllers under the parent resource (Cards::ClosuresController) 6. Use module in routes to organize without deep URL nesting
Use Scoping Concerns for Nested Resources
Extract parent resource lookup and authorization into reusable controller concerns like BoardScoped, CardScoped, etc. Controllers include these concerns to get consistent before_action setup.
Why
- DRY: Parent lookup code isn't repeated across controllers
- Consistent authorization: All controllers access resources through the same scoped queries
- Shared helpers: Common operations (like rendering replacements) live in one place
- Clear dependencies:
include CardScopedimmediately tells you what a controller needs
Bad: Repeated Setup in Controllers
# app/controllers/cards/comments_controller.rb
class Cards::CommentsController < ApplicationController
before_action :set_card
def create
@comment = @card.comments.create!(comment_params)
end
private
def set_card
@card = Current.user.accessible_cards.find(params[:card_id])
end
end
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
before_action :set_card # Duplicated
def create
@card.close
end
private
def set_card # Duplicated
@card = Current.user.accessible_cards.find(params[:card_id])
end
end
# app/controllers/cards/watches_controller.rb
class Cards::WatchesController < ApplicationController
before_action :set_card # Duplicated again!
# ...
endGood: Scoping Concern
# app/controllers/concerns/card_scoped.rb
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card, :set_board
end
private
def set_card
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
def set_board
@board = @card.board
end
# Shared helpers for card controllers
def render_card_replacement
render turbo_stream: turbo_stream.replace(
[@card, :card_container],
partial: "cards/container",
method: :morph,
locals: { card: @card.reload }
)
end
def capture_card_location
@source_column = @card.column
@was_in_stream = @card.awaiting_triage?
end
def refresh_stream_if_needed
if @was_in_stream
set_page_and_extract_portion_from(
@board.cards.awaiting_triage.latest.preloaded
)
end
end
end
# app/controllers/cards/comments_controller.rb
class Cards::CommentsController < ApplicationController
include CardScoped
def create
@comment = @card.comments.create!(comment_params)
end
end
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
capture_card_location
@card.close
refresh_stream_if_needed
end
end
# app/controllers/cards/watches_controller.rb
class Cards::WatchesController < ApplicationController
include CardScoped
def create
@card.watch_by(Current.user)
end
endScoping Concern Examples
BoardScoped
# app/controllers/concerns/board_scoped.rb
module BoardScoped
extend ActiveSupport::Concern
included do
before_action :set_board
end
private
def set_board
@board = Current.user.boards.find(params[:board_id])
end
def ensure_permission_to_admin_board
head :forbidden unless Current.user.can_administer_board?(@board)
end
endRoomScoped (with Membership)
# app/controllers/concerns/room_scoped.rb
module RoomScoped
extend ActiveSupport::Concern
included do
before_action :set_room
end
private
def set_room
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
@room = @membership.room
end
endFilterScoped (with Composition)
# app/controllers/concerns/filter_scoped.rb
module FilterScoped
extend ActiveSupport::Concern
included do
before_action :set_filter
before_action :set_user_filtering
end
private
def set_filter
if params[:filter_id].present?
@filter = Current.user.filters.find(params[:filter_id])
else
@filter = Current.user.filters.from_params(filter_params)
end
end
def set_user_filtering
@user_filtering = User::Filtering.new(Current.user, @filter)
end
end
# Concerns can compose other concerns
module DayTimelinesScoped
extend ActiveSupport::Concern
included do
include FilterScoped # Composition!
before_action :set_day_timeline
end
private
def set_day_timeline
@day_timeline = Current.user.timeline_for(day, filter: @filter)
end
endAlways Scope Through Current User
Never find records without scoping to the current user:
# Bad: Insecure - any user could access any card
def set_card
@card = Card.find(params[:card_id])
end
# Good: Scoped to accessible records
def set_card
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
# Good: Scoped through association
def set_board
@board = Current.user.boards.find(params[:board_id])
end
# Good: Scoped through membership
def set_room
@membership = Current.user.memberships.find_by!(room_id: params[:room_id])
@room = @membership.room
endOverriding Before Actions
Child controllers can skip or extend the inherited before_action:
class Cards::ClosuresController < ApplicationController
include CardScoped
before_action :capture_card_location, only: :create
def create
@card.close
refresh_stream_if_needed
end
endRules
1. Create concerns for each parent resource (BoardScoped, CardScoped, RoomScoped) 2. Always scope resource lookups through Current.user 3. Include shared helpers for common operations (rendering, capturing state) 4. Concerns can compose other concerns (include FilterScoped) 5. Use find_by! with a custom param (like number:) if needed 6. Authorization checks belong in the concern (ensure_permission_to_admin_board)
Use Template Method Pattern in Shared Concerns
When multiple models need similar but not identical behavior, create a shared concern with template methods (hooks) that model-specific concerns override.
Why
- DRY without rigidity: Share structure while allowing customization
- Explicit contracts: Template methods document what subclasses must implement
- Layered behavior: Model-specific concerns can add functionality on top of shared behavior
- Testing clarity: Each layer can be tested independently
Pattern Structure
app/models/
├── concerns/
│ └── searchable.rb # Shared template with hooks
├── card/
│ └── searchable.rb # Card-specific implementation
└── comment/
└── searchable.rb # Comment-specific implementationBad: Duplicated Logic
# app/models/card/searchable.rb
module Card::Searchable
extend ActiveSupport::Concern
included do
after_save_commit :update_search_index
after_destroy_commit :remove_from_search_index
end
def update_search_index
Search::Entry.upsert(...) # Duplicated
end
end
# app/models/comment/searchable.rb
module Comment::Searchable
extend ActiveSupport::Concern
included do
after_save_commit :update_search_index # Duplicated
after_destroy_commit :remove_from_search_index # Duplicated
end
def update_search_index
Search::Entry.upsert(...) # Same logic, different data
end
endGood: Template Method Pattern
Step 1: Shared Concern with Template Methods
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
after_save_commit :update_search_index, if: :searchable?
after_destroy_commit :remove_from_search_index
end
# Shared implementation
def update_search_index
Search::Entry.upsert(
id: search_entry_id,
title: search_title,
content: search_content,
searchable_type: self.class.name,
searchable_id: id
)
end
def remove_from_search_index
Search::Entry.where(searchable: self).delete_all
end
# Template methods - must be overridden
def search_title
raise NotImplementedError, "#{self.class} must implement #search_title"
end
def search_content
raise NotImplementedError, "#{self.class} must implement #search_content"
end
# Template methods with sensible defaults
def searchable?
true
end
def search_entry_id
"#{self.class.name.underscore}_#{id}"
end
endStep 2: Model-Specific Concerns Override Hooks
# app/models/card/searchable.rb
module Card::Searchable
extend ActiveSupport::Concern
included do
include ::Searchable # Include the shared template
scope :mentioning, ->(query, user:) do
# Card-specific search scope
joins(:search_entry).where("search_entries.content LIKE ?", "%#{query}%")
end
end
# Implement required template methods
def search_title
title
end
def search_content
description.to_plain_text
end
# Override default template method
def searchable?
published? # Cards are only searchable when published
end
end
# app/models/comment/searchable.rb
module Comment::Searchable
extend ActiveSupport::Concern
included do
include ::Searchable
end
def search_title
"Comment on #{card.title}"
end
def search_content
body.to_plain_text
end
def searchable?
card.published? # Comments are searchable if their card is
end
endStep 3: Models Include Their Specific Concern
# app/models/card.rb
class Card < ApplicationRecord
include Searchable # Resolves to Card::Searchable
end
# app/models/comment.rb
class Comment < ApplicationRecord
include Searchable # Resolves to Comment::Searchable
endReal-World Example: Eventable
# app/models/concerns/eventable.rb
module Eventable
extend ActiveSupport::Concern
included do
has_many :events, as: :eventable, dependent: :destroy
after_create_commit :create_event
end
def create_event
events.create!(
action: event_action,
creator: event_creator,
particulars: event_particulars
).tap { |event| event_was_created(event) }
end
# Template methods
def event_action
"created"
end
def event_creator
Current.user
end
def event_particulars
{}
end
# Hook for post-creation behavior
def event_was_created(event)
# Override in model-specific concerns
end
end
# app/models/card/eventable.rb
module Card::Eventable
extend ActiveSupport::Concern
include ::Eventable
included do
before_create { self.last_active_at ||= Time.current }
after_save :track_title_change, if: :saved_change_to_title?
end
def event_was_created(event)
create_system_comment_for(event)
touch_last_active_at
end
private
def track_title_change
events.create!(action: "title_changed", creator: Current.user)
end
endRules
1. Place shared template in app/models/concerns/ 2. Place model-specific implementations in app/models/model_name/ 3. Use include ::Searchable (with ::) to reference the shared concern 4. Define required template methods that raise NotImplementedError 5. Provide sensible defaults for optional template methods 6. Use _was_created or similar hooks for post-action customization
Keep Controllers Thin with Rich Domain Models
Controllers should be thin orchestrators that call rich model APIs directly. Avoid service objects, interactors, or other intermediaries between controllers and models.
Why
- Simplicity: Fewer layers means less code to maintain
- Discoverability: Domain logic lives where you expect it (in models)
- Rails-native: Works with Rails conventions and tooling
- Testability: Models can be unit tested, controllers integration tested
Bad: Fat Controllers
class Cards::ClosuresController < ApplicationController
def create
@card = Current.user.accessible_cards.find(params[:card_id])
# Business logic in controller
return head :forbidden unless @card.closeable?
@card.transaction do
@card.update!(status: :closed, closed_at: Time.current)
@card.closure.create!(user: Current.user)
@card.events.create!(action: :closed, creator: Current.user)
end
# Notification logic in controller
@card.watchers.each do |watcher|
NotificationMailer.card_closed(@card, watcher).deliver_later
end
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
endBad: Service Objects
# app/services/close_card_service.rb
class CloseCardService
def initialize(card, user)
@card = card
@user = user
end
def call
return false unless @card.closeable?
@card.transaction do
@card.update!(status: :closed)
@card.closure.create!(user: @user)
@card.events.create!(action: :closed, creator: @user)
end
notify_watchers
true
end
end
# Controller
class Cards::ClosuresController < ApplicationController
def create
service = CloseCardService.new(@card, Current.user)
if service.call
respond_to { |format| format.turbo_stream }
else
head :unprocessable_entity
end
end
endProblems with services:
- Extra layer of indirection
- Logic is harder to discover
- Often becomes a dumping ground
- Duplicates what models should do
Good: Thin Controller, Rich Model
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close # All logic in model method
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@card.reopen
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end
# app/models/card/closeable.rb
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
end
def close
transaction do
create_closure!(user: Current.user)
events.create!(action: :closed, creator: Current.user)
end
notify_watchers_later
end
def reopen
transaction do
closure.destroy!
events.create!(action: :reopened, creator: Current.user)
end
end
def closed?
closure.present?
end
endController Action Patterns
Simple CRUD Operations
Direct ActiveRecord operations are fine:
class Cards::CommentsController < ApplicationController
include CardScoped
def create
@comment = @card.comments.create!(comment_params)
end
def update
@comment = @card.comments.find(params[:id])
@comment.update!(comment_params)
end
endState Changes
Call intention-revealing model methods:
class Cards::GoldnessesController < ApplicationController
include CardScoped
def create
@card.gild
end
def destroy
@card.ungild
end
endToggles
Models provide toggle methods:
class Cards::AssignmentsController < ApplicationController
include CardScoped
def update
@card.toggle_assignment(Current.user)
end
end
# In model
def toggle_assignment(user)
if assigned_to?(user)
unassign(user)
else
assign(user)
end
endComplex Operations
Models expose rich APIs that hide complexity:
class BoardsController < ApplicationController
def create
@board = Current.user.draft_new_board(board_params)
# Model handles: creating board, granting access, setting defaults
redirect_to @board
end
endWhen Service Objects Are Acceptable
Service objects or form objects may be justified when:
1. Multiple models are created without a clear owner 2. External APIs are called with complex orchestration 3. Form handling spans multiple models
But even then, keep them simple and don't treat them as a pattern to follow everywhere:
# Acceptable: FirstRun handles initial account + user setup
class FirstRunsController < ApplicationController
def create
user = FirstRun.create!(user_params)
start_new_session_for(user)
redirect_to root_url
end
endRules
1. Controllers call model methods directly 2. All business logic belongs in models (or model concerns) 3. Service objects are the exception, not the rule 4. Controller actions should be 1-5 lines typically 5. Use scoping concerns to DRY up resource lookup 6. Respond with appropriate format (turbo_stream, json, html)
Keep Jobs Thin
Jobs should be thin wrappers that receive records and call model methods. All business logic belongs in the model layer.
Why
- Testability: Model methods can be unit tested without job infrastructure
- Reusability: The same logic can be called sync or async
- Debuggability: Easier to trace issues when logic isn't buried in jobs
- Consistency: Models are the single source of truth for domain logic
Bad: Business Logic in Jobs
class ProcessOrderJob < ApplicationJob
def perform(order)
return if order.processed?
order.transaction do
order.line_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
order.update!(
status: :processing,
processed_at: Time.current
)
order.payments.pending.each(&:capture!)
end
OrderMailer.confirmation(order).deliver_later
WebhookService.notify(:order_processed, order)
end
endProblems:
- Can't test processing logic without jobs
- Can't process synchronously when needed
- Logic is hidden from model/domain layer
Good: Jobs Delegate to Models
# app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(order)
order.process
end
end
# app/models/order.rb
class Order < ApplicationRecord
def process
return if processed?
transaction do
decrement_stock
mark_as_processing
capture_payments
end
send_confirmation
notify_webhooks
end
def process_later
ProcessOrderJob.perform_later(self)
end
private
def decrement_stock
line_items.each do |item|
item.product.decrement!(:stock, item.quantity)
end
end
def mark_as_processing
update!(status: :processing, processed_at: Time.current)
end
def capture_payments
payments.pending.each(&:capture!)
end
def send_confirmation
OrderMailer.confirmation(self).deliver_later
end
def notify_webhooks
WebhookService.notify(:order_processed, self)
end
endJob Responsibilities
Jobs should ONLY:
1. Receive arguments (records, simple values) 2. Call a single model method 3. Handle job-specific concerns (retries, discards, queues)
class Card::ActivitySpike::DetectionJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.detect_activity_spikes # Single method call
end
end
class Notification::Bundle::DeliverJob < ApplicationJob
include SmtpDeliveryErrorHandling # Job concern for retries
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform(bundle)
bundle.deliver # Single method call
end
endWhen Jobs Can Have More Logic
Batch Operations
Jobs that process collections may have iteration logic:
class DeleteUnusedTagsJob < ApplicationJob
def perform
Tag.unused.find_each do |tag|
tag.destroy!
end
end
endBut even here, consider a class method:
# Better: model class method
class Tag < ApplicationRecord
def self.delete_unused
unused.find_each(&:destroy!)
end
end
class DeleteUnusedTagsJob < ApplicationJob
def perform
Tag.delete_unused
end
endKeyword Arguments
Jobs can accept keyword arguments alongside records:
class Mention::CreateJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(record, mentioner:)
record.create_mentions(mentioner: mentioner)
end
endJob Naming Convention
Jobs should be namespaced to mirror the model they operate on:
| Model/Concern | Job |
|---|---|
Card::Accessible | Card::CleanInaccessibleDataJob |
Card::Stallable | Card::ActivitySpike::DetectionJob |
Storage::Totaled | Storage::MaterializeJob |
Notification::Bundle | Notification::Bundle::DeliverJob |
Webhook::Delivery | Webhook::DeliveryJob |
Rules
1. Jobs call one method on the received record 2. All business logic lives in models 3. Jobs handle only job-specific concerns (queues, retries, error handling) 4. Namespace jobs to mirror model structure 5. Always include discard_on ActiveJob::DeserializationError
Turbo Stream Broadcast Patterns
Encapsulate broadcast logic in model concerns and call broadcasts explicitly from controllers. Use composite stream names for targeting specific audiences.
Why
- Explicit control: Broadcasts happen when you intend, not automatically
- Testability: Broadcasts can be tested in isolation
- Flexibility: Different contexts may need different broadcast behavior
- Performance: No unexpected broadcasts on every save
Pattern 1: Broadcast Concerns in Models
Encapsulate broadcast logic in a model concern:
# app/models/message/broadcasts.rb
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages,
target: [room, :messages]
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end
def broadcast_update
broadcast_replace_to room, :messages,
target: [self, :presentation],
partial: "messages/presentation",
attributes: { maintain_scroll: true }
end
def broadcast_remove
broadcast_remove_to room, :messages
end
end
# app/models/message.rb
class Message < ApplicationRecord
include Broadcasts
endPattern 2: Call Broadcasts from Controllers
Explicitly call broadcasts in controller actions:
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def create
@message = @room.messages.create!(message_params)
@message.broadcast_create
end
def update
@message.update!(message_params)
@message.broadcast_update
end
def destroy
@message.destroy
@message.broadcast_remove
end
endPattern 3: Composite Stream Names
Use arrays for stream names to create hierarchical channels:
<%# Subscribe to room-specific messages %>
<%= turbo_stream_from @room, :messages %>
<%# Subscribe to global room list %>
<%= turbo_stream_from :rooms %>
<%# Subscribe to user-specific room updates %>
<%= turbo_stream_from Current.user, :rooms %>
<%# Subscribe to card activity %>
<%= turbo_stream_from @card, :activity %>This generates stream names like:
"Z2lkOi8vYXBwL1Jvb20vMQ:messages"(room + messages)"rooms"(global)"Z2lkOi8vYXBwL1VzZXIvMQ:rooms"(user + rooms)
Pattern 4: Targeted Broadcasts by Audience
Different users may need different broadcasts:
# app/controllers/rooms/opens_controller.rb
class Rooms::OpensController < RoomsController
def create
room = Rooms::Open.create!(room_params)
broadcast_create_room(room)
end
private
# Open rooms: broadcast to everyone
def broadcast_create_room(room)
broadcast_prepend_to :rooms,
target: :shared_rooms,
partial: "sidebars/room",
locals: { room: room }
end
end
# app/controllers/rooms/closeds_controller.rb
class Rooms::ClosedsController < RoomsController
private
# Closed rooms: broadcast only to members
def broadcast_create_room(room)
html = render_to_string(partial: "sidebars/room", locals: { room: room })
room.users.each do |user|
broadcast_prepend_to user, :rooms,
target: :shared_rooms,
html: html # Render once, broadcast to many
end
end
endPattern 5: Controller Broadcast Helpers
Include Turbo broadcast modules in ApplicationController:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Turbo::Streams::Broadcasts
include Turbo::Streams::StreamName
endThen broadcast directly from controller actions:
class RoomsController < ApplicationController
def destroy
@room.destroy
broadcast_remove_to :rooms, target: [@room, :list]
end
endPattern 6: Broadcast with morph
Use method: :morph for smart DOM updates:
def broadcast_card_update
broadcast_replace_to @board,
target: [@card, :card_container],
partial: "cards/container",
method: :morph,
locals: { card: self }
endPattern 7: Custom Attributes
Pass custom attributes for client-side handling:
def broadcast_update
broadcast_replace_to room, :messages,
target: [self, :presentation],
partial: "messages/presentation",
attributes: { maintain_scroll: true } # Custom attribute
endHandle in JavaScript:
// app/javascript/controllers/maintain_scroll_controller.js
beforeStreamRender(event) {
if (event.detail.newStream.hasAttribute("maintain_scroll")) {
// Preserve scroll position
}
}Pattern 8: Conditional Broadcasting
Broadcast based on context:
module Card::Broadcastable
def broadcast_changes
return unless published?
broadcast_replace_to board,
target: [self, :card_container],
partial: "cards/container",
method: :morph
end
endTurbo Stream Template Organization
app/views/
├── cards/
│ ├── closures/
│ │ ├── create.turbo_stream.erb
│ │ └── destroy.turbo_stream.erb
│ ├── comments/
│ │ ├── create.turbo_stream.erb
│ │ └── update.turbo_stream.erb
│ └── update.turbo_stream.erbExample template:
<%# app/views/cards/closures/create.turbo_stream.erb %>
<%= turbo_stream.replace(
[@card, :card_container],
partial: "cards/container",
method: :morph,
locals: { card: @card.reload }
) %>
<% if @source_column %>
<%= turbo_stream.replace(
dom_id(@source_column),
partial: "columns/column",
method: :morph,
locals: { column: @source_column }
) %>
<% end %>When NOT to Use Callbacks for Broadcasts
# Bad: Broadcasts on every save, even in background jobs
after_save_commit :broadcast_changes
# Good: Explicit broadcast when needed
# Called from controller:
@card.update!(card_params)
@card.broadcast_changesRules
1. Encapsulate broadcast logic in model concerns 2. Call broadcasts explicitly from controllers 3. Use composite stream names ([room, :messages]) for scoping 4. Render once, broadcast to many for multi-user broadcasts 5. Use method: :morph for smart DOM updates 6. Don't use callbacks for broadcasts (be explicit) 7. Custom attributes can signal client-side behavior