$npx -y skills add ThibautBaissac/rails_ai_agents --skill model-patternsBuilds rich domain models with business logic, concerns, and proper associations following the fat-models-over-service-objects philosophy. Use when creating models, adding validations, scopes, callbacks, business logic methods, or associations. WHEN NOT: Controller/routing work (
| 1 | # Model Patterns (37signals) |
| 2 | |
| 3 | Rich domain models over service objects. Business logic lives in models, not in separate service classes. |
| 4 | |
| 5 | ## Project knowledge |
| 6 | |
| 7 | **Tech Stack:** Rails 8.2 (edge), UUIDs everywhere, database-backed everything (no Redis) |
| 8 | **Patterns:** Heavy use of concerns, default values via lambdas, Current for context |
| 9 | |
| 10 | **Commands:** |
| 11 | ```bash |
| 12 | bin/rails generate model Card title:string body:text board:references:uuid |
| 13 | bin/rails generate migration AddColorToCards color:string |
| 14 | bin/rails db:migrate |
| 15 | bin/rails test test/models/ |
| 16 | bin/rails console |
| 17 | ``` |
| 18 | |
| 19 | ## Rich model vs service object |
| 20 | |
| 21 | ```ruby |
| 22 | # BAD -- service object |
| 23 | class CloseCardService |
| 24 | def initialize(card, user) |
| 25 | @card = card |
| 26 | @user = user |
| 27 | end |
| 28 | |
| 29 | def call |
| 30 | ActiveRecord::Base.transaction do |
| 31 | @card.create_closure!(user: @user) |
| 32 | @card.track_event("card_closed", user: @user) |
| 33 | end |
| 34 | end |
| 35 | end |
| 36 | |
| 37 | # GOOD -- rich model |
| 38 | class Card < ApplicationRecord |
| 39 | include Closeable |
| 40 | |
| 41 | def close(user: Current.user) |
| 42 | create_closure!(user: user) |
| 43 | track_event "card_closed", user: user |
| 44 | notify_recipients_later |
| 45 | end |
| 46 | end |
| 47 | |
| 48 | # Controller simply calls: |
| 49 | @card.close |
| 50 | ``` |
| 51 | |
| 52 | ## Model structure |
| 53 | |
| 54 | Order within a model: |
| 55 | |
| 56 | ```ruby |
| 57 | class Card < ApplicationRecord |
| 58 | # 1. Concern includes |
| 59 | include Assignable, Closeable, Eventable, Searchable, Watchable |
| 60 | |
| 61 | # 2. Associations |
| 62 | belongs_to :account, default: -> { board.account } |
| 63 | belongs_to :board, touch: true |
| 64 | belongs_to :column, touch: true |
| 65 | belongs_to :creator, class_name: "User", default: -> { Current.user } |
| 66 | |
| 67 | has_many :comments, dependent: :destroy |
| 68 | has_many :assignments, dependent: :destroy |
| 69 | has_one :closure, dependent: :destroy |
| 70 | |
| 71 | # 3. Validations |
| 72 | validates :title, presence: true |
| 73 | validates :status, inclusion: { in: %w[draft published archived] } |
| 74 | |
| 75 | # 4. Enums |
| 76 | enum :status, { draft: "draft", published: "published", archived: "archived" }, default: :draft |
| 77 | |
| 78 | # 5. Scopes |
| 79 | scope :recent, -> { order(created_at: :desc) } |
| 80 | scope :positioned, -> { order(:position) } |
| 81 | scope :active, -> { open.published.where.missing(:not_now) } |
| 82 | |
| 83 | # 6. Delegations |
| 84 | delegate :name, to: :board, prefix: true, allow_nil: true |
| 85 | |
| 86 | # 7. Callbacks (sparingly) |
| 87 | after_create_commit :broadcast_creation |
| 88 | |
| 89 | # 8. Business logic methods |
| 90 | def publish |
| 91 | update!(status: :published) |
| 92 | track_event "card_published" |
| 93 | end |
| 94 | |
| 95 | def move_to_column(new_column) |
| 96 | update!(column: new_column) |
| 97 | track_event "card_moved", particulars: { |
| 98 | from_column_id: column_id_before_last_save, |
| 99 | to_column_id: new_column.id |
| 100 | } |
| 101 | end |
| 102 | |
| 103 | private |
| 104 | |
| 105 | def broadcast_creation |
| 106 | broadcast_prepend_to board, :cards, target: "cards", partial: "cards/card" |
| 107 | end |
| 108 | end |
| 109 | ``` |
| 110 | |
| 111 | ## Association patterns |
| 112 | |
| 113 | ### belongs_to with defaults |
| 114 | |
| 115 | ```ruby |
| 116 | belongs_to :account, default: -> { board.account } |
| 117 | belongs_to :creator, class_name: "User", default: -> { Current.user } |
| 118 | belongs_to :board, touch: true # Updates parent's updated_at |
| 119 | ``` |
| 120 | |
| 121 | ### has_many / has_one |
| 122 | |
| 123 | ```ruby |
| 124 | has_many :comments, dependent: :destroy |
| 125 | has_many :assignees, through: :assignments, source: :assignee |
| 126 | has_one :closure, dependent: :destroy |
| 127 | ``` |
| 128 | |
| 129 | ### Polymorphic |
| 130 | |
| 131 | ```ruby |
| 132 | has_many :attachments, as: :attachable, dependent: :destroy |
| 133 | has_many :events, as: :eventable, dependent: :destroy |
| 134 | belongs_to :notifiable, polymorphic: true |
| 135 | ``` |
| 136 | |
| 137 | ### Counter caches |
| 138 | |
| 139 | ```ruby |
| 140 | belongs_to :card, counter_cache: :comments_count |
| 141 | belongs_to :board, counter_cache: :cards_count |
| 142 | ``` |
| 143 | |
| 144 | ## Scope patterns |
| 145 | |
| 146 | ```ruby |
| 147 | # Basic ordering |
| 148 | scope :recent, -> { order(created_at: :desc) } |
| 149 | scope :positioned, -> { order(:position) } |
| 150 | |
| 151 | # With arguments |
| 152 | scope :by_creator, ->(user) { where(creator: user) } |
| 153 | scope :created_after, ->(date) { where("created_at > ?", date) } |
| 154 | |
| 155 | # Joins and where.missing (key pattern for state records) |
| 156 | scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct } |
| 157 | scope :open, -> { where.missing(:closure) } |
| 158 | scope :unassigned, -> { where.missing(:assignments) } |
| 159 | |
| 160 | # Complex composed scopes |
| 161 | scope :entropic, -> { |
| 162 | open.published.where.missing(:not_now).where("updated_at < ?", 30.days.ago) |
| 163 | } |
| 164 | ``` |
| 165 | |
| 166 | ## Validation patterns |
| 167 | |
| 168 | ```ruby |
| 169 | validates :title, presence: true |
| 170 | validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } |
| 171 | validates :email_address, uniqueness: { case_sensitive: false } |
| 172 | validates :user_id, uniqueness: { scope: :card_id } # Join tables |
| 173 | validates :card, uniqueness: true # has_one state records |
| 174 | validates :body, presence: true, if: :published? # Conditional |
| 175 | ``` |
| 176 | |
| 177 | ## Call |