rails-models
Skill2 starsChanged 8 days agoMITView on GitHub
Use when writing ActiveRecord models with concerns, validations, callbacks, scopes, and associations
1 other project uses a version of this.
Adopted by
Most recently taken up by
- kurenn/roundhousetheir own version, first seen Jun 9, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- kurenn/roundhouse★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository24 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- rails-activestorageSkill
- rails-apiSkill
- rails-auditSkill
- rails-clean-test-outputSkill
- rails-controllersSkill
- rails-coreSkill
- rails-database-performanceSkill
- rails-i18nSkill
- rails-jobsSkill
- rails-loggingSkill
- rails-mailersSkill
- rails-migrationsSkill
- rails-multi-tenancySkill
- rails-performanceSkill
- rails-philosophySkill
- rails-project-setupSkill
- rails-securitySkill
- rails-stimulusSkill
- rails-styleSkill
- rails-testingSkill
- rails-turboSkill
- rails-upgradeSkill
- rails-viewcomponentsSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 82
Use default: on belongs_to with a lambda to derive the value from other associations or Current attributes:
- Distinctive: line 157
Use the hash-from-array pattern for string storage — no magic integers, readable database values:
- Very specific: line 330
Only the FK *referencing a legacy integer PK* needs type: :integer.
- Very specific: line 358
Add use: %i[ slugged history ] when URLs must keep working after a rename (it tracks old slugs and redirects).
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Rails Model Patterns
1. Concern Architecture
Extract cohesive behaviors into concerns. Class-level declarations (has_many, callbacks, scopes) go in the included block; instance methods outside it.
# app/models/concerns/eventable.rb
module Eventable
extend ActiveSupport::Concern
included do
has_many :events, as: :eventable, dependent: :destroy
end
def track_event(action, creator: Current.user, board: self.board, **particulars)
if should_track_event?
board.events.create!(action: "#{eventable_prefix}_#{action}", creator:, board:, eventable: self, particulars:)
end
end
def event_was_created(event)
end
private
def should_track_event?
true
end
def eventable_prefix
self.class.name.demodulize.underscore
end
end# app/models/card.rb — a model is mostly a composition of concerns
class Card < ApplicationRecord
include Assignable, Broadcastable, Closeable, Eventable, Mentions,
Pinnable, Postponable, Searchable, Statuses, Taggable, Watchable
end2. Template Method Pattern in Concerns
The empty/default methods above (event_was_created, should_track_event?, eventable_prefix) are hooks. Models customize the generic concern by overriding them in a namespaced concern:
# app/models/card/eventable.rb
module Card::Eventable
extend ActiveSupport::Concern
include ::Eventable # :: prefix avoids namespace conflicts
def event_was_created(event)
transaction do
create_system_comment_for(event)
touch_last_active_at unless was_just_published?
end
end
private
def should_track_event?
published? # only track events for published cards
end
endBase concern lives in app/models/concerns/; model-specific concerns in app/models/model_name/.
3. Default Associations
Use default: on belongs_to with a lambda to derive the value from other associations or Current attributes:
class Card < ApplicationRecord
belongs_to :account, default: -> { board.account }
belongs_to :creator, class_name: "User", default: -> { Current.user }
endDerive from the closest parent association when possible; use Current only at the top of the hierarchy. Full multi-tenant scoping rules (including framework models): [[rails-multi-tenancy]].
4. Scope Composition
Build small, focused scopes that compose. Use case statements in parameterized scopes for index/filter patterns.
class Card < ApplicationRecord
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
scope :chronologically, -> { order created_at: :asc, id: :asc }
scope :latest, -> { order last_active_at: :desc, id: :desc }
scope :indexed_by, ->(index) do
case index
when "stalled" then stalled
when "closed" then closed
when "golden" then golden
else all
end
end
end# Scopes from concerns compose with each other
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) }
end
endFor preloaded scopes (N+1 prevention), see [[rails-performance]].
5. Normalizes Pattern
Rails 7.1+ normalizes cleans data declaratively before validation and save:
class Identity < ApplicationRecord
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
end
# Works on arrays too — filter and clean values
class Webhook < ApplicationRecord
normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS }
endpresence converts blank strings to nil; normalizes runs before validation, so validated values are already clean.
6. Enum with Scopes
Use the hash-from-array pattern for string storage — no magic integers, readable database values:
module Card::Statuses
extend ActiveSupport::Concern
included do
enum :status, %w[ drafted published ].index_by(&:itself)
before_save :mark_if_just_published
after_create -> { track_event :published }, if: :published?
end
def publish
transaction do
self.created_at = Time.current
published!
track_event :published
end
end
end7. Transaction Safety
Wrap multi-step operations in transaction blocks:
module Card::Closeable
def close(user: Current.user)
unless closed?
transaction do
create_closure! user: user
track_event :closed, creator: user
end
end
end
def reopen(user: Current.user)
if closed?
transaction do
closure&.destroy
track_event :reopened, creator: user
end
end
end
end8. Polymorphic Associations
Name the polymorphic association after what it represents (eventable, source); the -able suffix is conventional. Create a matching concern for models on the "source" side (see Eventable in Pattern 1), and use delegate to traverse the polymorphic chain.
class Event < ApplicationRecord
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :creator, class_name: "User"
belongs_to :eventable, polymorphic: true
after_create -> { eventable.event_was_created(self) }
after_create_commit :dispatch_webhooks
delegate :card, to: :eventable
end9. Callbacks Best Practices
# Use after_*_commit for anything external: jobs, webhooks, broadcasts
module Searchable
extend ActiveSupport::Concern
included do
after_create_commit :create_in_search_index
after_update_commit :update_in_search_index
after_destroy_commit :remove_from_search_index
end
end
# Conditional callbacks narrow when they run
class Card < ApplicationRecord
before_save :set_default_title, if: :published?
after_save -> { board.touch }, if: :published?
after_update :handle_board_change, if: :saved_change_to_board_id?
endKey Points:
- External side effects (jobs, webhooks, broadcasts) belong in
after_*_commitcallbacks, never plainafter_save— the transaction may still roll back. saved_change_to_X?inafter_save/after_update;X_changed?inbefore_save.touch: trueonbelongs_topropagates timestamp changes up the association chain.
10. Association Extensions
Pass a block to has_many to define methods directly on the association; proxy_association.owner accesses the parent record.
module Board::Accessible
extend ActiveSupport::Concern
included do
has_many :accesses, dependent: :delete_all do
def revise(granted: [], revoked: [])
transaction do
grant_to granted
revoke_from revoked
end
end
def grant_to(users)
Access.insert_all Array(users).collect { |user|
{ id: ActiveRecord::Type::Uuid.generate, board_id: proxy_association.owner.id, user_id: user.id, account_id: proxy_association.owner.account.id }
}
end
def revoke_from(users)
destroy_by user: users unless proxy_association.owner.all_access?
end
end
end
end
# board.accesses.revise(granted: new_users, revoked: removed_users)11. Inquiry on Enums
.inquiry converts string values to ActiveSupport::StringInquirer for expressive conditionals:
class Event < ApplicationRecord
def action
super.inquiry
end
end
# event.action.card_closed? / event.action.comment_created?12. Migrations & Foreign Keys (legacy integer PKs)
Older tables may have id: :integer rather than bigint. A foreign key pointing to such a table must declare the matching type, or the FK migration aborts. Check the parent's id: in db/schema.rb before writing the reference.
# db/schema.rb shows: create_table "opportunities", id: :integer do |t| ...
class CreateOpportunityRoles < ActiveRecord::Migration[8.1]
def change
create_table :opportunity_roles do |t| # new table → bigint id, fine
# FK to a legacy integer-PK parent MUST match its type:
t.references :opportunity, type: :integer, foreign_key: true
t.string :name, null: false
t.timestamps
end
end
endKey Points:
- Only the FK referencing a legacy integer PK needs
type: :integer; FKs to new bigint tables need notype:. - For populated tables, adding non-null/unique columns is still multi-step — see [[rails-core]] rule 5.
- In a multi-database app, roll back with the namespaced task:
bin/rails db:rollback:primary STEP=n([[rails-core]] rule 6).
13. Slugged URLs with FriendlyId (optional)
37signals apps often just use ids (or signed_id for unguessable references — [[rails-security]]). When you genuinely want human/SEO-friendly URLs (/teams/acme, not /teams/42), friendly_id slugs the record without giving up find:
# Gemfile → gem "friendly_id"
# bin/rails generate migration AddSlugToTeams slug:string:uniq
class Team < ApplicationRecord
extend FriendlyId
friendly_id :name, use: :slugged # fills `slug` from `name` on create
end# friendly.find accepts a slug OR an id, so old numeric links keep resolving
@team = Current.account.teams.friendly.find(params[:id])Key Points:
- Scope the lookup through the tenant/owner (
Current.account.teams, notTeam). A bareTeam.friendly.findis the same cross-tenant leak asTeam.find— see [[rails-security]] and [[rails-multi-tenancy]]. - Slugs are not secrets. For unguessable references use
model.signed_id, not a slug. - Add
use: %i[ slugged history ]when URLs must keep working after a rename (it tracks old slugs and redirects).
14. Anti-Patterns to Avoid
A few ActiveRecord defaults bite later. Prefer the right column on the right:
default_scope→ use explicit named scopes (Pattern 4). Adefault_scopesilently filters every query, leaks intonew/createattribute defaults and through associations, and is awkward to escape (unscopeddrops all conditions, not just the default).has_and_belongs_to_many→ usehas_many :throughwith a real join model. The join table is a record you'll inevitably want to give a column, validation, or callback; HABTM can't.- Boolean
presencevalidation →validates :active, presence: truerejectsfalse, sincefalse.blank?is true. Useinclusion: { in: [ true, false ] }, or rely on anull: falsecolumn with a default. delete/delete_allwhen callbacks ordependent:matter → usedestroy/destroy_all.deleteissues a raw SQLDELETE, skipping callbacks ([[rails-models]] §9),dependent:cleanup, andafter_*_commitside effects. Only reach fordelete_allon a scope you've confirmed has no dependents to cascade.
Quick Reference
| Pattern | When to Use |
Concern with included block | Extracting reusable model behaviors |
| Template methods | Allowing models to customize concern behavior |
default: on associations | Deriving values from parent records or Current |
| Composable scopes | Building complex queries from simple parts |
normalizes | Cleaning user input before storage |
enum with index_by | Status/type fields with readable database values |
transaction blocks | Multi-step operations needing atomicity |
| Polymorphic associations | Multiple models relating to the same record type |
after_*_commit callbacks | External side effects (jobs, broadcasts) |
| Association extensions | Custom collection methods |
t.references :x, type: :integer | FK pointing to a legacy id: :integer parent table |
has_many :through over has_and_belongs_to_many | Join needs (or may need) its own columns/validations |
inclusion: { in: [true, false] } | Validating a boolean is set (presence rejects false) |
destroy over delete | Removal must fire callbacks and dependent: cleanup |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

