Rails_ai_agents model-patterns
install
source · Clone the upstream repo
git clone https://github.com/ThibautBaissac/rails_ai_agents
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/ThibautBaissac/rails_ai_agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude_37signals/skills/model-patterns" ~/.claude/skills/thibautbaissac-rails-ai-agents-model-patterns && rm -rf "$T"
manifest:
.claude_37signals/skills/model-patterns/SKILL.mdsource content
Model Patterns (37signals)
Rich domain models over service objects. Business logic lives in models, not in separate service classes.
Project knowledge
Tech Stack: Rails 8.2 (edge), UUIDs everywhere, database-backed everything (no Redis) Patterns: Heavy use of concerns, default values via lambdas, Current for context
Commands:
bin/rails generate model Card title:string body:text board:references:uuid bin/rails generate migration AddColorToCards color:string bin/rails db:migrate bin/rails test test/models/ bin/rails console
Rich model vs service object
# BAD -- service object class CloseCardService def initialize(card, user) @card = card @user = user end def call ActiveRecord::Base.transaction do @card.create_closure!(user: @user) @card.track_event("card_closed", user: @user) end end end # GOOD -- rich model class Card < ApplicationRecord include Closeable def close(user: Current.user) create_closure!(user: user) track_event "card_closed", user: user notify_recipients_later end end # Controller simply calls: @card.close
Model structure
Order within a model:
class Card < ApplicationRecord # 1. Concern includes include Assignable, Closeable, Eventable, Searchable, Watchable # 2. Associations belongs_to :account, default: -> { board.account } belongs_to :board, touch: true belongs_to :column, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } has_many :comments, dependent: :destroy has_many :assignments, dependent: :destroy has_one :closure, dependent: :destroy # 3. Validations validates :title, presence: true validates :status, inclusion: { in: %w[draft published archived] } # 4. Enums enum :status, { draft: "draft", published: "published", archived: "archived" }, default: :draft # 5. Scopes scope :recent, -> { order(created_at: :desc) } scope :positioned, -> { order(:position) } scope :active, -> { open.published.where.missing(:not_now) } # 6. Delegations delegate :name, to: :board, prefix: true, allow_nil: true # 7. Callbacks (sparingly) after_create_commit :broadcast_creation # 8. Business logic methods def publish update!(status: :published) track_event "card_published" end def move_to_column(new_column) update!(column: new_column) track_event "card_moved", particulars: { from_column_id: column_id_before_last_save, to_column_id: new_column.id } end private def broadcast_creation broadcast_prepend_to board, :cards, target: "cards", partial: "cards/card" end end
Association patterns
belongs_to with defaults
belongs_to :account, default: -> { board.account } belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :board, touch: true # Updates parent's updated_at
has_many / has_one
has_many :comments, dependent: :destroy has_many :assignees, through: :assignments, source: :assignee has_one :closure, dependent: :destroy
Polymorphic
has_many :attachments, as: :attachable, dependent: :destroy has_many :events, as: :eventable, dependent: :destroy belongs_to :notifiable, polymorphic: true
Counter caches
belongs_to :card, counter_cache: :comments_count belongs_to :board, counter_cache: :cards_count
Scope patterns
# Basic ordering scope :recent, -> { order(created_at: :desc) } scope :positioned, -> { order(:position) } # With arguments scope :by_creator, ->(user) { where(creator: user) } scope :created_after, ->(date) { where("created_at > ?", date) } # Joins and where.missing (key pattern for state records) scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct } scope :open, -> { where.missing(:closure) } scope :unassigned, -> { where.missing(:assignments) } # Complex composed scopes scope :entropic, -> { open.published.where.missing(:not_now).where("updated_at < ?", 30.days.ago) }
Validation patterns
validates :title, presence: true validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } validates :email_address, uniqueness: { case_sensitive: false } validates :user_id, uniqueness: { scope: :card_id } # Join tables validates :card, uniqueness: true # has_one state records validates :body, presence: true, if: :published? # Conditional
Callbacks and enums
# Callbacks: use sparingly, prefer _commit for external effects after_create_commit :broadcast_creation before_validation :set_default_status, on: :create after_create_commit :notify_recipients_later # Uses _later convention # String enums (preferred for DB readability) enum :status, { draft: "draft", published: "published", archived: "archived" }, default: :draft, prefix: true
Business logic methods
Action methods (verbs)
def close(user: Current.user) create_closure!(user: user) track_event "card_closed", user: user notify_watchers_later end def assign(user) assignments.create!(user: user) unless assigned_to?(user) track_event "card_assigned", particulars: { assignee_id: user.id } end
Query methods (predicates)
def closed? closure.present? end def assigned_to?(user) assignees.include?(user) end def can_be_edited_by?(user) user.can_administer_card?(self) || creator == user end
Computed attributes
def closed_at closure&.created_at end def closed_by closure&.user end
_later / _now convention
# Async version (queues a job) def notify_recipients_later NotifyRecipientsJob.perform_later(self) end # Sync version (immediate execution) def notify_recipients_now recipients.each do |recipient| Notification.create!(recipient: recipient, notifiable: self) end end # Default to sync def notify_recipients notify_recipients_now end # Call _later from callbacks after_create_commit :notify_recipients_later
Using Current for context
class Current < ActiveSupport::CurrentAttributes attribute :session, :user, :identity, :account end class Card < ApplicationRecord belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :account, default: -> { Current.account } def close(user: Current.user) create_closure!(user: user) end end
See
references/model-examples.md for complete model examples (join tables, form objects, POROs, migrations, tests).
Boundaries
- Always: Put business logic in models, use concerns for organization, use bang methods (
,create!
), leverage associations and scopes, use Current for context, default values via lambdasupdate! - Ask first: Before creating service objects, before adding complex callbacks, before using inheritance (prefer composition via concerns)
- Never: Create anemic models (data without behavior), put business logic in controllers, skip validations, create models without tests