UI はローカライズ済みです。スキルの紹介と説明文書は選択した言語へ AI が自動翻訳し、コード・コマンド・パス・専門用語は原文のままです。原作者の文書が優先されます。
スキルを探す
開発・技術 · SKILL.md

concern-patterns

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-p…

Independent GitHub snapshot · Automated classification · Original documentation governs

スキルの説明

GitHub

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/                      # 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

    # Multiple models have:
    has_many :comments, as: :commentable
    # Extract to: app/models/concerns/commentable.rb
    
  2. Repeated state patterns

    # Multiple models have close/reopen pattern
    # Extract to: Card::Closeable, Board::Publishable, etc.
    
  3. Repeated scopes

    # Multiple models have:
    scope :recent, -> { order(created_at: :desc) }
    # Extract to: Timestampable concern
    
  4. 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

  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
保存したスキル