NYC

rails-concern

SKILL.md

Rails Concern Generator (TDD)

Creates concerns (ActiveSupport::Concern modules) for shared behavior with tests first.

Quick Start

  1. Write failing test for the concern behavior
  2. Run test to confirm RED
  3. Implement concern in app/models/concerns/ or app/controllers/concerns/
  4. Run test to confirm GREEN

When to Use Concerns

Good use cases:

  • Shared validations across multiple models
  • Common scopes used by several models
  • Shared callbacks (e.g., UUID generation, slug creation)
  • Controller authentication/authorization helpers
  • Pagination or filtering logic
  • Auditing and tracking behavior

Avoid concerns when:

  • Logic is only used in one place (YAGNI)
  • Creating "god" concerns with unrelated methods
  • Logic should be a service object instead
  • Concern would need its own state/config (use a class)

TDD Workflow

Step 1: Create Concern Test (RED)

For Model Concerns, test via a model that includes it:

# test/models/concerns/has_uuid_test.rb
require "test_helper"

class HasUuidTest < ActiveSupport::TestCase
  test "generates uuid before validation on create" do
    event = Event.new(name: "Test", account: accounts(:one))
    event.valid?
    assert_present event.uuid
  end

  test "does not overwrite existing uuid" do
    event = Event.new(name: "Test", uuid: "custom-uuid", account: accounts(:one))
    event.valid?
    assert_equal "custom-uuid", event.uuid
  end

  test "validates uuid uniqueness" do
    existing = events(:one)
    event = Event.new(uuid: existing.uuid)
    assert_not event.valid?
    assert_includes event.errors[:uuid], "has already been taken"
  end

  test "find_by_uuid! finds record" do
    event = events(:one)
    assert_equal event, Event.find_by_uuid!(event.uuid)
  end

  test "find_by_uuid! raises for missing uuid" do
    assert_raises(ActiveRecord::RecordNotFound) do
      Event.find_by_uuid!("nonexistent")
    end
  end
end

Alternative: Use a shared test module for concerns used by many models:

# test/support/shared_tests/has_uuid_tests.rb
module HasUuidTests
  extend ActiveSupport::Concern

  included do
    test "generates uuid on create" do
      record = build_record_for_concern
      record.valid?
      assert_present record.uuid
    end
  end
end

# test/models/event_test.rb
class EventTest < ActiveSupport::TestCase
  include HasUuidTests

  private

  def build_record_for_concern
    Event.new(name: "Test", account: accounts(:one))
  end
end

For Controller Concerns, test via integration tests:

# test/controllers/concerns/filterable_test.rb
require "test_helper"

class FilterableTest < ActionDispatch::IntegrationTest
  setup do
    sign_in users(:one)
  end

  test "filters resources by status" do
    get resources_path(status: "active")
    assert_response :success
    assert_includes response.body, resources(:active).name
    assert_not_includes response.body, resources(:inactive).name
  end
end

Step 2: Run Test (Confirm RED)

bin/rails test test/models/concerns/has_uuid_test.rb

Step 3: Implement Concern (GREEN)

Model Concern:

# app/models/concerns/has_uuid.rb
module HasUuid
  extend ActiveSupport::Concern

  included do
    before_validation :generate_uuid, on: :create
    validates :uuid, presence: true, uniqueness: true
  end

  class_methods do
    def find_by_uuid!(uuid)
      find_by!(uuid: uuid)
    end
  end

  private

  def generate_uuid
    self.uuid ||= SecureRandom.uuid
  end
end

Controller Concern:

# app/controllers/concerns/filterable.rb
module Filterable
  extend ActiveSupport::Concern

  private

  def apply_filters(scope, allowed_filters)
    allowed_filters.each do |filter|
      if params[filter].present?
        scope = scope.where(filter => params[filter])
      end
    end
    scope
  end
end

Step 4: Run Test (Confirm GREEN)

bin/rails test test/models/concerns/has_uuid_test.rb

Common Concern Patterns

Pattern 1: UUID Generation

# app/models/concerns/has_uuid.rb
module HasUuid
  extend ActiveSupport::Concern

  included do
    before_validation :generate_uuid, on: :create
    validates :uuid, presence: true, uniqueness: true
  end

  private

  def generate_uuid
    self.uuid ||= SecureRandom.uuid
  end
end

Pattern 2: Soft Delete

# app/models/concerns/soft_deletable.rb
module SoftDeletable
  extend ActiveSupport::Concern

  included do
    scope :kept, -> { where(deleted_at: nil) }
    scope :discarded, -> { where.not(deleted_at: nil) }
  end

  def discard
    update(deleted_at: Time.current)
  end

  def undiscard
    update(deleted_at: nil)
  end

  def discarded?
    deleted_at.present?
  end
end

Pattern 3: Searchable

# app/models/concerns/searchable.rb
module Searchable
  extend ActiveSupport::Concern

  class_methods do
    def search(query)
      return all if query.blank?

      columns = searchable_columns.map { |c| "#{table_name}.#{c}" }
      conditions = columns.map { |c| "#{c} LIKE :q" }.join(" OR ")
      where(conditions, q: "%#{sanitize_sql_like(query)}%")
    end

    def searchable_columns
      %w[name]
    end
  end
end

Pattern 4: Auditable

# app/models/concerns/auditable.rb
module Auditable
  extend ActiveSupport::Concern

  included do
    has_many :audit_logs, as: :auditable, dependent: :destroy
    after_create :log_creation
    after_update :log_update
  end

  private

  def log_creation
    audit_logs.create(action: "created", changes_data: attributes)
  end

  def log_update
    return unless saved_changes.any?
    audit_logs.create(action: "updated", changes_data: saved_changes)
  end
end

Pattern 5: Sluggable

# app/models/concerns/sluggable.rb
module Sluggable
  extend ActiveSupport::Concern

  included do
    before_validation :generate_slug, on: :create
    validates :slug, presence: true, uniqueness: { scope: slug_scope }
  end

  class_methods do
    def slug_scope
      nil
    end

    def find_by_slug!(slug)
      find_by!(slug: slug)
    end
  end

  def to_param
    slug
  end

  private

  def generate_slug
    return if slug.present?
    base_slug = slug_source.parameterize
    self.slug = base_slug
    counter = 1
    while self.class.exists?(slug: self.slug)
      self.slug = "#{base_slug}-#{counter}"
      counter += 1
    end
  end

  def slug_source
    respond_to?(:name) ? name : to_s
  end
end

Pattern 6: Accountable (Multi-Tenancy)

# app/models/concerns/accountable.rb
module Accountable
  extend ActiveSupport::Concern

  included do
    belongs_to :account
    validates :account, presence: true

    scope :for_account, ->(account) { where(account: account) }
  end
end

Pattern 7: Tokenizable

# app/models/concerns/tokenizable.rb
module Tokenizable
  extend ActiveSupport::Concern

  included do
    has_secure_token :api_token
  end

  class_methods do
    def find_by_api_token!(token)
      find_by!(api_token: token)
    end
  end

  def regenerate_api_token!
    regenerate_api_token
    save!
  end
end

Usage

In Models:

class Event < ApplicationRecord
  include HasUuid
  include SoftDeletable
  include Searchable
  include Accountable
end

In Controllers:

class ApplicationController < ActionController::Base
  include Authentication
  include Filterable
end

Checklist

  • Test written first (RED)
  • Uses extend ActiveSupport::Concern
  • included block for callbacks/validations/scopes
  • class_methods block for class-level methods
  • Instance methods outside blocks
  • Single responsibility (one purpose per concern)
  • Well-named (describes what it adds: HasUuid, SoftDeletable, Searchable)
  • Database-agnostic (no PostgreSQL-specific SQL like ILIKE)
  • All tests GREEN
Weekly Installs
2
First Seen
7 days ago
Installed on
opencode2
gemini-cli2
antigravity2
claude-code2
windsurf2
codex2