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].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
1---2name: concern-patterns3description: 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).4license: MIT5---67# Concern Patterns (37signals)89Concerns for horizontal behavior, inheritance for vertical specialization.1011## Project knowledge1213**Tech Stack:** Rails 8.2 (edge), ActiveSupport::Concern14**Location:** `app/models/[model]/` for model concerns, `app/controllers/concerns/` for controller concerns1516**Commands:**17```bash18ls app/models/concerns/ # List shared concerns19ls app/models/card/ # List Card concerns20bin/rails runner "puts Card.included_modules" # Check usage21bin/rails test test/models/ # Run model tests22```2324## Core principles2526Each concern should be:27- **Self-contained:** All related code (associations, validations, scopes, methods) in one place28- **Cohesive:** Focused on one aspect (e.g., `Closeable`, `Watchable`, `Searchable`)29- **Composable:** Models include multiple concerns to build up behavior3031## When to extract a concern3233### Extract when you see:34351. **Repeated associations across models**36 ```ruby37 # Multiple models have:38 has_many :comments, as: :commentable39 # Extract to: app/models/concerns/commentable.rb40 ```41422. **Repeated state patterns**43 ```ruby44 # Multiple models have close/reopen pattern45 # Extract to: Card::Closeable, Board::Publishable, etc.46 ```47483. **Repeated scopes**49 ```ruby50 # Multiple models have:51 scope :recent, -> { order(created_at: :desc) }52 # Extract to: Timestampable concern53 ```54554. **Repeated controller patterns**56 ```ruby57 # Multiple controllers load parent resource58 # Extract to: ParentScoped concern59 ```6061### Do NOT extract when:62- Code is used by only one model (YAGNI)63- You'd create a god concern with unrelated methods64- Logic should be in explicit model methods instead6566## Model concern structure6768### State management concern6970```ruby71# app/models/card/closeable.rb72module Card::Closeable73 extend ActiveSupport::Concern7475 included do76 has_one :closure, dependent: :destroy7778 scope :open, -> { where.missing(:closure) }79 scope :closed, -> { joins(:closure) }80 end8182 def close(user: Current.user)83 create_closure!(user: user)84 track_event "card_closed", user: user85 end8687 def reopen88 closure&.destroy!89 track_event "card_reopened"90 end9192 def closed?93 closure.present?94 end9596 def open?97 !closed?98 end99100 def closed_at101 closure&.created_at102 end103104 def closed_by105 closure&.user106 end107end108```109110### Association concern111112```ruby113# app/models/card/assignable.rb114module Card::Assignable115 extend ActiveSupport::Concern116117 included do118 has_many :assignments, dependent: :destroy119 has_many :assignees, through: :assignments, source: :assignee120121 scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct }122 scope :unassigned, -> { where.missing(:assignments) }123 end124125 def assign(user)126 assignments.create!(user: user) unless assigned_to?(user)127 track_event "card_assigned", user: user, particulars: { assignee_id: user.id }128 end129130 def unassign(user)131 assignments.where(user: user).destroy_all132 end133134 def assigned_to?(user)135 assignees.include?(user)136 end137end138```139140### Behavior concern with class methods141142```ruby143# app/models/card/searchable.rb144module Card::Searchable145 extend ActiveSupport::Concern146147 included do148 scope :search, ->(query) { where("title LIKE ? OR body LIKE ?", "%#{query}%", "%#{query}%") }149 end150151 class_methods do152 def search_with_ranking(query)153 search(query).order("search_rank DESC")154 end155156 def top_results(query, limit: 10)157 search_with_ranking(query).limit(limit)158 end159 end160end161```162163## Controller concern structure164165```ruby166# app/controllers/concerns/card_scoped.rb167module CardScoped168 extend ActiveSupport::Concern169170 included do171 before_action :set_card172 before_action :set_board173 end174175 private176177 def set_card178 @card = Current.account.cards.find(params[:card_id])179 end180181 def set_board182 @board = @card.board183 end184185 def render_card_replacement186 respond_to do |format|187 format.turbo_stream do188 render turbo_stream: turbo_stream.replace(189 dom_id(@card, :card_container),190 partial: "cards/container",191 locals: { card: @card.reload }192 )193 end194 format.html { redirect_to @card }195 end196 end197end198```199200## Naming conventions201202- **Model concerns** (adjectives): `Closeable`, `Publishable`, `Watchable`, `Assignable`, `Searchable`, `Eventable`, `Broadcastable`, `Readable`, `Positionable`203- **Controller concerns** (nouns): `CardScoped`, `BoardScoped`, `FilterScoped`, `CurrentRequest`, `CurrentTimezone`, `Authentication`204205## Testing concerns206207### Test in isolation:208209```ruby210# test/models/concerns/closeable_test.rb211class CloseableTest < ActiveSupport::TestCase212 setup do213 @card = cards(:logo)214 end215216 test "close creates closure record" do217 assert_difference -> { Closure.count }, 1 do218 @card.close219 end220 assert @card.closed?221 end222223 test "reopen destroys closure record" do224 @card.close225 assert_difference -> { Closure.count }, -1 do226 @card.reopen227 end228 assert @card.open?229 end230231 test "closed scope finds closed records" do232 @card.close233 assert_includes Card.closed, @card234 refute_includes Card.open, @card235 end236end237```238239## Refactoring workflow2402411. **Identify the pattern** -- Find duplicated code across models/controllers2422. **Name the concern** -- Use an adjective describing the capability2433. **Create the file** -- `app/models/[model]/[concern].rb` or `app/controllers/concerns/[concern].rb`2444. **Move code** -- Associations, validations, scopes, methods2455. **Include it** -- Add `include ConcernName` to models/controllers2466. **Write tests** -- Test concern in isolation and in context2477. **Remove duplication** -- Delete the old code from models/controllers248249## Files to create2502511. **Concern file:** `app/models/card/closeable.rb` or `app/controllers/concerns/card_scoped.rb`2522. **Model/Controller:** Add `include ConcernName`2533. **Test file:** `test/models/concerns/closeable_test.rb`254255See `references/concern-catalog.md` for the full catalog of concern types.256257## Boundaries258259- **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 model260- **Ask first:** Before creating concerns that span multiple domains, before extracting concerns with complex dependencies, before modifying widely-used concerns261- **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