Creates and refactors model and controller concerns for shared behavior following 37signals patterns. Use when extracting shared code, organizing models with horizontal concerns, DRYing up controllers, or when user mentions concerns, mixins, modules, or shared behavior.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rails-37signals-patterns:37signals-concernsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are an expert Rails architect specializing in extracting and organizing concerns for horizontal code sharing.
You are an expert Rails architect specializing in extracting and organizing concerns for horizontal code sharing.
Concerns for horizontal behavior, inheritance for vertical specialization.
Use concerns when multiple models/controllers need the same behavior. Each concern should be:
Closeable, Watchable, Searchable)Tech Stack: Rails 8.2 (edge), ActiveSupport::Concern
Patterns: Models are rich with many concerns, controllers have scoping concerns
Location: app/models/[model]/ for model concerns, app/controllers/concerns/ for controller concerns
ls app/models/concerns/ or ls app/models/card/bin/rails runner "puts Card.included_modules"bin/rails test test/models/grep -r "def close" app/models/# app/models/card/closeable.rb
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
scope :open, -> { where.missing(:closure) }
scope :closed, -> { joins(:closure) }
after_create_commit :track_card_created_event
end
def close(user: Current.user)
create_closure!(user: user)
track_event "card_closed", user: user
end
def reopen
closure&.destroy!
track_event "card_reopened"
end
def closed?
closure.present?
end
def open?
!closed?
end
def closed_at
closure&.created_at
end
def closed_by
closure&.user
end
private
def track_card_created_event
track_event "card_created" if open?
end
end
# app/models/card/assignable.rb
module Card::Assignable
extend ActiveSupport::Concern
included do
has_many :assignments, dependent: :destroy
has_many :assignees, through: :assignments, source: :user
scope :assigned_to, ->(user) { joins(:assignments).where(assignments: { user: user }) }
scope :unassigned, -> { where.missing(:assignments) }
end
def assign(user)
assignments.create!(user: user) unless assigned_to?(user)
track_event "card_assigned", user: user, particulars: { assignee_id: user.id }
end
def unassign(user)
assignments.where(user: user).destroy_all
track_event "card_unassigned", user: user, particulars: { assignee_id: user.id }
end
def assigned_to?(user)
assignees.include?(user)
end
end
# app/models/card/searchable.rb
module Card::Searchable
extend ActiveSupport::Concern
included do
scope :search, ->(query) { where("title LIKE ? OR body LIKE ?", "%#{query}%", "%#{query}%") }
scope :with_search_rank, ->(query) {
select("cards.*")
.select("CASE
WHEN title LIKE ? THEN 3
WHEN body LIKE ? THEN 2
ELSE 1
END as search_rank", "%#{query}%", "%#{query}%")
.order("search_rank DESC")
}
end
class_methods do
def search_across_accounts(query)
search(query).distinct
end
end
end
# app/models/card/eventable.rb
module Card::Eventable
include ::Eventable
PERMITTED_ACTIONS = %w[
card_created card_closed card_reopened
card_assigned card_unassigned
card_gilded card_ungilded
title_changed body_changed
]
def track_title_change(old_title)
track_event "title_changed", particulars: {
old_title: old_title,
new_title: title
}
end
def track_body_change
track_event "body_changed" if saved_change_to_body?
end
end
# app/controllers/concerns/card_scoped.rb
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card
before_action :set_board
end
private
def set_card
@card = Current.account.cards.find(params[:card_id])
end
def set_board
@board = @card.board
end
def render_card_replacement
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
dom_id(@card, :card_container),
partial: "cards/container",
locals: { card: @card.reload }
)
end
format.html { redirect_to @card }
end
end
end
# app/controllers/concerns/current_request.rb
module CurrentRequest
extend ActiveSupport::Concern
included do
before_action :set_current_request_details
end
private
def set_current_request_details
Current.user = current_user
Current.identity = current_identity
Current.session = current_session
Current.account = current_account
end
end
# app/controllers/concerns/filter_scoped.rb
module FilterScoped
extend ActiveSupport::Concern
included do
before_action :set_filter
helper_method :filter, :filtered?
end
private
def set_filter
@filter = if params[:filter_id].present?
Current.account.filters.find(params[:filter_id])
else
Filter.new(filter_params)
end
end
def filter
@filter
end
def filtered?
@filter.persisted? || filter_params.any?
end
def filter_params
params.fetch(:filter, {}).permit(:assignee_id, :column_id, :tag_id, :closed)
end
end
# app/controllers/concerns/current_timezone.rb
module CurrentTimezone
extend ActiveSupport::Concern
included do
around_action :set_time_zone
etag { Current.identity&.timezone }
helper_method :browser_timezone
end
private
def set_time_zone(&block)
Time.use_zone(browser_timezone, &block)
end
def browser_timezone
cookies[:timezone].presence || "UTC"
end
end
Repeated associations across models
# Multiple models have:
has_many :comments, as: :commentable
has_many :attachments, as: :attachable
# Extract to:
# app/models/concerns/commentable.rb
# app/models/concerns/attachable.rb
Repeated state patterns
# Multiple models have closure/publication/goldness pattern
has_one :closure
def close; end
def reopen; end
def closed?; end
# Extract to Card::Closeable, Board::Publishable, etc.
Repeated scopes
# Multiple models have:
scope :recent, -> { order(created_at: :desc) }
scope :by_creator, ->(user) { where(creator: user) }
# Extract to Timestampable or Ownable concern
Repeated controller patterns
# Multiple controllers have:
before_action :set_parent_resource
# Extract to ParentScoped concern
Closeable - can be closedPublishable - can be publishedWatchable - can be watchedAssignable - can be assignedSearchable - can be searchedEventable - tracks eventsBroadcastable - broadcasts updatesReadable - can be read/marked as readColorable - has colorPositionable - has positionCardScoped - scopes to cardBoardScoped - scopes to boardFilterScoped - handles filteringCurrentRequest - sets current attributesCurrentTimezone - handles timezoneAuthentication - handles authTurboFlash - flash via TurboModels include multiple concerns:
# app/models/card.rb
class Card < ApplicationRecord
include Assignable
include Attachments
include Broadcastable
include Closeable
include Colored
include Commentable
include Entropic
include Eventable
include Golden
include NotNowable
include Pinnable
include Positionable
include Readable
include Searchable
include Viewable
include Watchable
# Minimal model code - behavior is in concerns
belongs_to :board
belongs_to :column
validates :title, presence: true
end
module Card::Searchable
extend ActiveSupport::Concern
included do
scope :search, ->(query) { where("title LIKE ?", "%#{query}%") }
end
class_methods do
def search_with_ranking(query)
search(query).order("search_rank DESC")
end
def top_results(query, limit: 10)
search_with_ranking(query).limit(limit)
end
end
end
# test/models/concerns/closeable_test.rb
require "test_helper"
class CloseableTest < ActiveSupport::TestCase
class DummyCloseable < ApplicationRecord
self.table_name = "cards"
include Card::Closeable
end
setup do
@record = DummyCloseable.create!(title: "Test")
end
test "close creates closure record" do
assert_difference -> { Closure.count }, 1 do
@record.close
end
assert @record.closed?
end
test "reopen destroys closure record" do
@record.close
assert_difference -> { Closure.count }, -1 do
@record.reopen
end
assert @record.open?
end
test "closed scope finds closed records" do
@record.close
assert_includes DummyCloseable.closed, @record
refute_includes DummyCloseable.open, @record
end
end
# test/models/card_test.rb
class CardTest < ActiveSupport::TestCase
test "closing card tracks event" do
card = cards(:logo)
assert_difference -> { card.events.count }, 1 do
card.close
end
assert_equal "card_closed", card.events.last.action
end
end
When asked to extract a concern:
app/models/[model]/[concern].rb or app/controllers/concerns/[concern].rbinclude ConcernName to models/controllersWhen creating a concern:
app/models/card/closeable.rb or app/controllers/concerns/card_scoped.rbinclude ConcernNametest/models/concerns/closeable_test.rbCloseable - has_one :closure, close/reopen methodsPublishable - has_one :publication, publish/unpublish methodsGolden - has_one :goldness, gild/ungild methodsNotNowable - has_one :not_now, postpone/resume methodsAssignable - has_many :assignments, assign/unassign methodsWatchable - has_many :watches, watch/unwatch methodsCommentable - has_many :comments, as: :commentableAttachments - has_many :attachments, as: :attachableSearchable - search scopes and methodsPositionable - position attribute and orderingEventable - event trackingBroadcastable - Turbo Stream broadcastingReadable - read tracking for usersextend ActiveSupport::Concern, namespace model concerns under the modelincluded do block for callbacks/associations, forget to test concerns in isolation, create concerns for one-off code used by a single modelProvides a checklist for code reviews covering functionality, security, performance, maintainability, tests, and quality. Use for pull requests, audits, team standards, and developer training.
npx claudepluginhub joshyorko/agent-skills