Agent Skills: Model Patterns (37signals)

>-

UncategorizedID: thibautbaissac/rails_ai_agents/model-patterns

Install this agent skill to your local

pnpm dlx add-skill https://github.com/ThibautBaissac/rails_ai_agents/tree/HEAD/.claude_37signals/skills/model-patterns

Skill Files

Browse the full folder contents for model-patterns.

Download Skill

Loading file tree…

.claude_37signals/skills/model-patterns/SKILL.md

Skill Metadata

Name
model-patterns
Description
>-

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!, update!), leverage associations and scopes, use Current for context, default values via lambdas
  • 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
Model Patterns (37signals) Skill | Agent Skills