# Concern Patterns

> 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).

- Skill: `thibautbaissac/concern-patterns` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add thibautbaissac/concern-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thibautbaissac/concern-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: MIT
- Author: thibautbaissac (https://skillmd.com/u/thibautbaissac)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thibautbaissac/concern-patterns

---


# 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:**
```bash
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:

1. **Repeated associations across models**
   ```ruby
   # Multiple models have:
   has_many :comments, as: :commentable
   # Extract to: app/models/concerns/commentable.rb
   ```

2. **Repeated state patterns**
   ```ruby
   # Multiple models have close/reopen pattern
   # Extract to: Card::Closeable, Board::Publishable, etc.
   ```

3. **Repeated scopes**
   ```ruby
   # Multiple models have:
   scope :recent, -> { order(created_at: :desc) }
   # Extract to: Timestampable concern
   ```

4. **Repeated controller patterns**
   ```ruby
   # 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

```ruby
# 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

```ruby
# 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

```ruby
# 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

```ruby
# 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:

```ruby
# 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

1. **Identify the pattern** -- Find duplicated code across models/controllers
2. **Name the concern** -- Use an adjective describing the capability
3. **Create the file** -- `app/models/[model]/[concern].rb` or `app/controllers/concerns/[concern].rb`
4. **Move code** -- Associations, validations, scopes, methods
5. **Include it** -- Add `include ConcernName` to models/controllers
6. **Write tests** -- Test concern in isolation and in context
7. **Remove duplication** -- Delete the old code from models/controllers

## Files to create

1. **Concern file:** `app/models/card/closeable.rb` or `app/controllers/concerns/card_scoped.rb`
2. **Model/Controller:** Add `include ConcernName`
3. **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

