| name | concern-patterns |
| description | Creates and refactors model and controller concerns for shared behavior. Use when extracting shared code, organizing models with horizontal concerns, DRYing up controllers, or when user mentions concerns, mixins, or modules. WHEN NOT: Logic used by only one model (keep in place), service object extraction (use model-patterns), or job organization (use job-patterns). |
| license | MIT |
| compatibility | Ruby 3.3+, Rails 8.0+ |
Concern Patterns (37signals)
Concerns for horizontal behavior, inheritance for vertical specialization.
Project knowledge
Tech Stack: Rails 8.2 (edge), ActiveSupport::Concern
Location: app/models/[model]/ for model concerns, app/controllers/concerns/ for controller concerns
Commands:
ls app/models/concerns/
ls app/models/card/
bin/rails runner "puts Card.included_modules"
bin/rails test test/models/
Core principles
Each concern should be:
- Self-contained: All related code (associations, validations, scopes, methods) in one place
- Cohesive: Focused on one aspect (e.g.,
Closeable, Watchable, Searchable)
- Composable: Models include multiple concerns to build up behavior
When to extract a concern
Extract when you see:
-
Repeated associations across models
has_many :comments, as: :commentable
-
Repeated state patterns
-
Repeated scopes
scope :recent, -> { order(created_at: :desc) }
-
Repeated controller patterns
Do NOT extract when:
- Code is used by only one model (YAGNI)
- You'd create a god concern with unrelated methods
- Logic should be in explicit model methods instead
Model concern structure
State management concern
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
scope :open, -> { where.missing(:closure) }
scope :closed, -> { joins(:closure) }
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
end
Association concern
module Card::Assignable
extend ActiveSupport::Concern
included do
has_many :assignments, dependent: :destroy
has_many :assignees, through: :assignments, source: :assignee
scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct }
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
end
def assigned_to?(user)
assignees.include?(user)
end
end
Behavior concern with class methods
module Card::Searchable
extend ActiveSupport::Concern
included do
scope :search, ->(query) { where("title LIKE ? OR body LIKE ?", "%#{query}%", "%#{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
Controller concern structure
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
Naming conventions
- Model concerns (adjectives):
Closeable, Publishable, Watchable, Assignable, Searchable, Eventable, Broadcastable, Readable, Positionable
- Controller concerns (nouns):
CardScoped, BoardScoped, FilterScoped, CurrentRequest, CurrentTimezone, Authentication
Testing concerns
Test in isolation:
class CloseableTest < ActiveSupport::TestCase
setup do
@card = cards(:logo)
end
test "close creates closure record" do
assert_difference -> { Closure.count }, 1 do
@card.close
end
assert @card.closed?
end
test "reopen destroys closure record" do
@card.close
assert_difference -> { Closure.count }, -1 do
@card.reopen
end
assert @card.open?
end
test "closed scope finds closed records" do
@card.close
assert_includes Card.closed, @card
refute_includes Card.open, @card
end
end
Refactoring workflow
- Identify the pattern -- Find duplicated code across models/controllers
- Name the concern -- Use an adjective describing the capability
- Create the file --
app/models/[model]/[concern].rb or app/controllers/concerns/[concern].rb
- Move code -- Associations, validations, scopes, methods
- Include it -- Add
include ConcernName to models/controllers
- Write tests -- Test concern in isolation and in context
- Remove duplication -- Delete the old code from models/controllers
Files to create
- Concern file:
app/models/card/closeable.rb or app/controllers/concerns/card_scoped.rb
- Model/Controller: Add
include ConcernName
- Test file:
test/models/concerns/closeable_test.rb
See references/concern-catalog.md for the full catalog of concern types.
Boundaries
- Always: Extract repeated code into concerns, keep concerns focused on one aspect, include all related code (associations, scopes, methods), write tests, use
extend ActiveSupport::Concern, namespace model concerns under the model
- Ask first: Before creating concerns that span multiple domains, before extracting concerns with complex dependencies, before modifying widely-used concerns
- Never: Create god concerns with too many responsibilities, use concerns to hide service objects, skip
included do block for callbacks/associations, create concerns for one-off code