$npx -y skills add ThibautBaissac/rails_ai_agents --skill concern-patternsCreates 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),
| 1 | # Concern Patterns (37signals) |
| 2 | |
| 3 | Concerns for horizontal behavior, inheritance for vertical specialization. |
| 4 | |
| 5 | ## Project knowledge |
| 6 | |
| 7 | **Tech Stack:** Rails 8.2 (edge), ActiveSupport::Concern |
| 8 | **Location:** `app/models/[model]/` for model concerns, `app/controllers/concerns/` for controller concerns |
| 9 | |
| 10 | **Commands:** |
| 11 | ```bash |
| 12 | ls app/models/concerns/ # List shared concerns |
| 13 | ls app/models/card/ # List Card concerns |
| 14 | bin/rails runner "puts Card.included_modules" # Check usage |
| 15 | bin/rails test test/models/ # Run model tests |
| 16 | ``` |
| 17 | |
| 18 | ## Core principles |
| 19 | |
| 20 | Each concern should be: |
| 21 | - **Self-contained:** All related code (associations, validations, scopes, methods) in one place |
| 22 | - **Cohesive:** Focused on one aspect (e.g., `Closeable`, `Watchable`, `Searchable`) |
| 23 | - **Composable:** Models include multiple concerns to build up behavior |
| 24 | |
| 25 | ## When to extract a concern |
| 26 | |
| 27 | ### Extract when you see: |
| 28 | |
| 29 | 1. **Repeated associations across models** |
| 30 | ```ruby |
| 31 | # Multiple models have: |
| 32 | has_many :comments, as: :commentable |
| 33 | # Extract to: app/models/concerns/commentable.rb |
| 34 | ``` |
| 35 | |
| 36 | 2. **Repeated state patterns** |
| 37 | ```ruby |
| 38 | # Multiple models have close/reopen pattern |
| 39 | # Extract to: Card::Closeable, Board::Publishable, etc. |
| 40 | ``` |
| 41 | |
| 42 | 3. **Repeated scopes** |
| 43 | ```ruby |
| 44 | # Multiple models have: |
| 45 | scope :recent, -> { order(created_at: :desc) } |
| 46 | # Extract to: Timestampable concern |
| 47 | ``` |
| 48 | |
| 49 | 4. **Repeated controller patterns** |
| 50 | ```ruby |
| 51 | # Multiple controllers load parent resource |
| 52 | # Extract to: ParentScoped concern |
| 53 | ``` |
| 54 | |
| 55 | ### Do NOT extract when: |
| 56 | - Code is used by only one model (YAGNI) |
| 57 | - You'd create a god concern with unrelated methods |
| 58 | - Logic should be in explicit model methods instead |
| 59 | |
| 60 | ## Model concern structure |
| 61 | |
| 62 | ### State management concern |
| 63 | |
| 64 | ```ruby |
| 65 | # app/models/card/closeable.rb |
| 66 | module Card::Closeable |
| 67 | extend ActiveSupport::Concern |
| 68 | |
| 69 | included do |
| 70 | has_one :closure, dependent: :destroy |
| 71 | |
| 72 | scope :open, -> { where.missing(:closure) } |
| 73 | scope :closed, -> { joins(:closure) } |
| 74 | end |
| 75 | |
| 76 | def close(user: Current.user) |
| 77 | create_closure!(user: user) |
| 78 | track_event "card_closed", user: user |
| 79 | end |
| 80 | |
| 81 | def reopen |
| 82 | closure&.destroy! |
| 83 | track_event "card_reopened" |
| 84 | end |
| 85 | |
| 86 | def closed? |
| 87 | closure.present? |
| 88 | end |
| 89 | |
| 90 | def open? |
| 91 | !closed? |
| 92 | end |
| 93 | |
| 94 | def closed_at |
| 95 | closure&.created_at |
| 96 | end |
| 97 | |
| 98 | def closed_by |
| 99 | closure&.user |
| 100 | end |
| 101 | end |
| 102 | ``` |
| 103 | |
| 104 | ### Association concern |
| 105 | |
| 106 | ```ruby |
| 107 | # app/models/card/assignable.rb |
| 108 | module Card::Assignable |
| 109 | extend ActiveSupport::Concern |
| 110 | |
| 111 | included do |
| 112 | has_many :assignments, dependent: :destroy |
| 113 | has_many :assignees, through: :assignments, source: :assignee |
| 114 | |
| 115 | scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct } |
| 116 | scope :unassigned, -> { where.missing(:assignments) } |
| 117 | end |
| 118 | |
| 119 | def assign(user) |
| 120 | assignments.create!(user: user) unless assigned_to?(user) |
| 121 | track_event "card_assigned", user: user, particulars: { assignee_id: user.id } |
| 122 | end |
| 123 | |
| 124 | def unassign(user) |
| 125 | assignments.where(user: user).destroy_all |
| 126 | end |
| 127 | |
| 128 | def assigned_to?(user) |
| 129 | assignees.include?(user) |
| 130 | end |
| 131 | end |
| 132 | ``` |
| 133 | |
| 134 | ### Behavior concern with class methods |
| 135 | |
| 136 | ```ruby |
| 137 | # app/models/card/searchable.rb |
| 138 | module Card::Searchable |
| 139 | extend ActiveSupport::Concern |
| 140 | |
| 141 | included do |
| 142 | scope :search, ->(query) { where("title LIKE ? OR body LIKE ?", "%#{query}%", "%#{query}%") } |
| 143 | end |
| 144 | |
| 145 | class_methods do |
| 146 | def search_with_ranking(query) |
| 147 | search(query).order("search_rank DESC") |
| 148 | end |
| 149 | |
| 150 | def top_results(query, limit: 10) |
| 151 | search_with_ranking(query).limit(limit) |
| 152 | end |
| 153 | end |
| 154 | end |
| 155 | ``` |
| 156 | |
| 157 | ## Controller concern structure |
| 158 | |
| 159 | ```ruby |
| 160 | # app/controllers/concerns/card_scoped.rb |
| 161 | module CardScoped |
| 162 | extend ActiveSupport::Concern |
| 163 | |
| 164 | included do |
| 165 | before_action :set_card |
| 166 | before_action :set_board |
| 167 | end |
| 168 | |
| 169 | private |
| 170 | |
| 171 | def set_card |
| 172 | @card = Current.account.cards.find(params[:card_id]) |
| 173 | end |
| 174 | |
| 175 | def set_board |
| 176 | @board = @card.board |
| 177 | end |
| 178 | |
| 179 | def render_card_replacement |
| 180 | respond_to do |format| |
| 181 | format.turbo_stream do |
| 182 | render turbo_stream: turbo_stream.replace( |
| 183 | dom_id(@card, :card_container), |
| 184 | partial: "cards/container", |
| 185 | locals: { card: @card.reload } |
| 186 | ) |
| 187 | end |
| 188 | format.html { redirect_to @card } |
| 189 | end |
| 190 | end |
| 191 | end |
| 192 | ``` |
| 193 | |
| 194 | ## Naming conventions |
| 195 | |
| 196 | - **Model concerns** (adjectives): `Closeable`, `Publishable`, `Watchable`, `Assignable`, `Searchable`, `Eventable`, `Broadcastable`, `Readable`, `Positionable` |
| 197 | - **Controller concerns** (nouns) |