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/ # List shared concerns
ls app/models/card/ # List Card concerns
bin/rails runner "puts Card.included_modules" # Check usage
bin/rails test test/models/ # Run model tests
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
# Multiple models have: has_many :comments, as: :commentable # Extract to: app/models/concerns/commentable.rb -
Repeated state patterns
# Multiple models have close/reopen pattern # Extract to: Card::Closeable, Board::Publishable, etc. -
Repeated scopes
# Multiple models have: scope :recent, -> { order(created_at: :desc) } # Extract to: Timestampable concern -
Repeated controller patterns
# Multiple controllers load parent resource # Extract to: ParentScoped concern
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
# 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) }
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
# app/models/card/assignable.rb
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
# app/models/card/searchable.rb
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
# 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
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:
# test/models/concerns/closeable_test.rb
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].rborapp/controllers/concerns/[concern].rb - Move code -- Associations, validations, scopes, methods
- Include it -- Add
include ConcernNameto 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.rborapp/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 doblock for callbacks/associations, create concerns for one-off code