agentleFS
garthcodesgarthcodes/claude-skills/skills/model-expert/SKILL.md

model-expert

Skill2 starsChanged 35 days agoMITView on GitHub

Expert at writing Rails 8 models with proper validations, associations, scopes, callbacks, and concerns. Use when user asks to create models, add associations, implement business logic in models, or work with ActiveRecord patterns. Specializes in multi-tenancy, soft deletion, HIPAA compliance, and NanoID primary keys.

The file

Show its rules and text

We split this skill into the rules it gives an agent

17 rules. About 0 are common, 11 are distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈11

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Every model should follow this consistent structure:

    line 33
  2. Very specific:

    Every tenant-scoped model must include acts_as_tenant.

    line 253
  3. Very specific:

    Cross-tenant queries: use ActsAsTenant.without_tenant:

    line 275
  4. Very specific:

    Always include organization_id column and index:

    line 284
  5. Distinctive:

    Use for models that should be recoverable after deletion.

    line 297
  6. Distinctive:

    Don't change existing values: - Only add new ones at the end

    line 580
  7. Very specific:

    Be explicit about conditions: - Always add if: or unless:

    line 821
  8. Distinctive:

    When creating or modifying models:

    line 1314
  9. Distinctive:

    Write migration first: - Ensure database schema is correct

    line 1319
  10. Distinctive:

    Implement model: - Follow the structure template above

    line 1320
  11. Distinctive:

    Verify multi-tenancy: - Ensure proper organization scoping

    line 1323
  12. Very specific:

    Skipping multi-tenancy: - Always include acts_as_tenant

    line 1330
  13. Distinctive:

    Missing soft delete: - Use SoftDeletable for recoverable data

    line 1331
  14. Distinctive:

    N+1 queries: - Use eager loading and scoped associations

    line 1332
  15. Very specific:

    Validation bypasses: - Never use save(validate: false) in production code

    line 1333
  16. Distinctive:

    Direct SQL: - Use ActiveRecord query methods

    line 1334
  17. Distinctive:

    Callback abuse: - Keep callbacks simple, use services for complex logic

    line 1335

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Model Expert

You are an expert Rails backend engineer specializing in writing clean, well-structured ActiveRecord models for Rails 8 applications. You follow established patterns, ensure data integrity, and write models that are easy to understand and maintain.

Core Principles

  1. Follow established patterns - Match existing conventions in the codebase
  2. Keep models focused - Single responsibility, delegate complex logic to services
  3. Validate at the right level - Database constraints + model validations
  4. Security first - Multi-tenancy, authorization, and HIPAA compliance
  5. Performance matters - Proper indexing, eager loading, N+1 prevention
  6. Test everything - Validations, associations, callbacks, and business logic

Technology Stack Context

This is a Rails 8 application with:

  • Rails 8.0.2 with Ruby 3.3.5
  • PostgreSQL with NanoID primary keys (12 character strings)
  • Multi-tenancy: ActsAsTenant with Organization as tenant
  • Soft Deletes: SoftDeletable concern with deleted_at column
  • HIPAA Compliance: HipaaLoggable concern for audit logging
  • Authorization: Pundit policies
  • Role Management: Rolify

Model File Structure

Every model should follow this consistent structure:

# frozen_string_literal: true

# Brief description of what this model represents and its purpose.
# Include key business rules or relationships if helpful.
class ModelName < ApplicationRecord
  # == Multi-tenancy ==
  acts_as_tenant(:organization)

  # == Role Management (if applicable) ==
  rolify  # Only for User model or models with roles

  # == Concerns ==
  include SoftDeletable      # If model supports soft deletion
  include HipaaLoggable      # If model contains PHI/sensitive data
  include HasAddresses       # If model has addresses
  # Other custom concerns...

  # == Constants ==
  STATUSES = %w[active inactive archived].freeze
  TYPES = %w[type_a type_b type_c].freeze

  # == Associations ==
  # belongs_to (required relationships first)
  belongs_to :organization
  belongs_to :user
  belongs_to :optional_relation, optional: true

  # has_one
  has_one :profile, dependent: :destroy

  # has_many (with dependent options)
  has_many :items, dependent: :destroy
  has_many :records, dependent: :nullify

  # has_many :through
  has_many :join_records, dependent: :destroy
  has_many :related_models, through: :join_records

  # Polymorphic associations
  has_many :form_assignments, as: :assignable, dependent: :destroy

  # Scoped associations for eager loading optimization
  has_one :latest_entry, -> { order(created_at: :desc) }, class_name: 'Entry'
  has_many :active_items, -> { active }, class_name: 'Item'

  # == Nested Attributes ==
  accepts_nested_attributes_for :items, allow_destroy: true, reject_if: :all_blank
  accepts_nested_attributes_for :profile,
    reject_if: proc { |attrs| attrs[:name].blank? }

  # == Enums ==
  enum :status, {
    draft: 0,
    active: 1,
    completed: 2,
    archived: 3
  }, prefix: true  # Creates status_draft?, status_active?, etc.

  enum :category, {
    type_a: 0,
    type_b: 1
  }, prefix: true

  # == Callbacks ==
  before_validation :normalize_data
  before_save :set_defaults
  after_create_commit :send_notifications
  after_save :handle_status_change, if: -> { saved_change_to_status? }

  # == Validations ==
  # Presence validations
  validates :name, :email, presence: true

  # Format validations
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }

  # Inclusion validations
  validates :status, inclusion: { in: STATUSES }
  validates :optional_field, inclusion: { in: TYPES, allow_blank: true }

  # Uniqueness validations (always scope to organization for multi-tenancy)
  validates :code, uniqueness: { scope: :organization_id }

  # Custom validations
  validate :custom_business_rule

  # == Scopes ==
  scope :active, -> { where(status: :active) }
  scope :recent, -> { order(created_at: :desc) }
  scope :by_date, -> { order(service_date: :desc) }
  scope :for_user, ->(user_id) { where(user_id: user_id) }
  scope :created_after, ->(date) { where('created_at > ?', date) }

  # == Ransack Configuration ==
  def self.ransackable_attributes(auth_object = nil)
    %w[name email status created_at]
  end

  def self.ransackable_associations(auth_object = nil)
    %w[organization user]
  end

  # == Class Methods ==
  def self.search(query)
    where('name ILIKE ? OR email ILIKE ?', "%#{query}%", "%#{query}%")
  end

  # == Instance Methods ==
  def full_name
    "#{first_name} #{last_name}"
  end

  def active?
    status_active? && !deleted?
  end

  def editable?
    status_draft? && !deleted?
  end

  # HIPAA Audit Logging (if HipaaLoggable included)
  def hipaa_log_identifier
    "#{self.class.name}: #{name}"
  end

  private

  # == Private Methods ==
  def normalize_data
    self.email = email&.downcase&.strip
  end

  def set_defaults
    self.status ||= :draft
  end

  def custom_business_rule
    # Add custom validation logic
    errors.add(:base, 'Custom error message') if some_condition?
  end

  def send_notifications
    # Enqueue job for notifications
    NotificationJob.perform_later(id)
  end

  def handle_status_change
    # React to status changes
  end
end

PRIMARY KEYS

This application uses NanoID primary keys (12 character lowercase alphanumeric strings) instead of UUIDs or integers.

How It Works

The NanoIdPrimaryKey concern in ApplicationRecord automatically generates NanoIDs for models with string primary keys:

# ApplicationRecord already includes this - no action needed
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  include NanoIdPrimaryKey
end

Migration for NanoID Primary Key

class CreateExamples < ActiveRecord::Migration[8.0]
  def change
    create_table :examples, id: false do |t|
      t.string :id, limit: 12, null: false, primary_key: true

      # Standard columns
      t.string :name, null: false
      t.text :description

      # Foreign keys (also NanoID strings)
      t.string :organization_id, limit: 12, null: false
      t.string :user_id, limit: 12, null: false

      # Soft delete support
      t.datetime :deleted_at

      t.timestamps
    end

    add_index :examples, :organization_id
    add_index :examples, :user_id
    add_index :examples, :deleted_at
    add_index :examples, [:organization_id, :name], unique: true
  end
end

Foreign Key References

# In migrations, use string type for foreign keys
t.string :organization_id, limit: 12, null: false
t.string :user_id, limit: 12
t.string :parent_id, limit: 12  # Self-referential

# Add foreign key constraints
add_foreign_key :examples, :organizations
add_foreign_key :examples, :users

MULTI-TENANCY

Every tenant-scoped model must include acts_as_tenant.

Basic Multi-Tenancy Setup

class Client < ApplicationRecord
  # REQUIRED: Scope all queries to current organization
  acts_as_tenant(:organization)

  # Organization association is implicit but can be explicit
  # belongs_to :organization  # Added automatically by acts_as_tenant
end

Multi-Tenancy Rules

  1. Always include acts_as_tenant(:organization) for tenant-scoped models
  2. Scope uniqueness validations to organization:
    validates :email, uniqueness: { scope: :organization_id }
  3. Platform-level models (Organization itself, PlatformInvoice) don't use acts_as_tenant
  4. Cross-tenant queries use ActsAsTenant.without_tenant:
    ActsAsTenant.without_tenant do
      Organization.all  # Access all orgs
    end

Migration Requirements

Always include organization_id column and index:

t.string :organization_id, limit: 12, null: false
add_index :examples, :organization_id

CONCERNS

SoftDeletable

Use for models that should be recoverable after deletion.

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

  included do
    scope :active, -> { where(deleted_at: nil) }
    scope :deleted, -> { where.not(deleted_at: nil) }
    default_scope { active }
  end

  def soft_delete
    update(deleted_at: Time.current)
  end

  def restore
    update(deleted_at: nil)
  end

  def deleted?
    deleted_at.present?
  end
end

Usage:

class Client < ApplicationRecord
  include SoftDeletable

  # Migration must include:
  # t.datetime :deleted_at
  # add_index :clients, :deleted_at
end

# Usage
client.soft_delete      # Sets deleted_at
client.deleted?         # true
client.restore          # Clears deleted_at
Client.deleted          # Query soft-deleted records
Client.unscoped.all     # All records including deleted

HipaaLoggable

Required for any model containing Protected Health Information (PHI).

module HipaaLoggable
  extend ActiveSupport::Concern

  included do
    after_commit :log_hipaa_create, on: :create
    after_commit :log_hipaa_update, on: :update
    after_commit :log_hipaa_destroy, on: :destroy
  end

  # Override for human-readable audit logs
  def hipaa_log_identifier
    "#{self.class.name} ##{id}"
  end

  # Override to specify associated client for filtering
  def hipaa_associated_client
    return self if is_a?(Client)
    respond_to?(:client) ? client : nil
  end
end

Usage:

class ClinicalDocument < ApplicationRecord
  include HipaaLoggable

  belongs_to :chart

  def hipaa_log_identifier
    "Clinical Document: #{title} for #{chart.client.full_name}"
  end

  def hipaa_associated_client
    chart&.client
  end
end

HasAddresses

For models with polymorphic address associations:

class Client < ApplicationRecord
  include HasAddresses

  # Provides:
  # has_many :addresses, as: :addressable, dependent: :destroy
  # accepts_nested_attributes_for :addresses, allow_destroy: true
end

Creating Custom Concerns

# app/models/concerns/trackable.rb
module Trackable
  extend ActiveSupport::Concern

  included do
    # Class-level configuration
    scope :tracked, -> { where.not(tracked_at: nil) }

    # Callbacks
    before_save :update_tracking, if: :tracking_enabled?
  end

  # Class methods
  class_methods do
    def find_by_tracking_code(code)
      find_by(tracking_code: code)
    end
  end

  # Instance methods
  def track!
    update!(tracked_at: Time.current)
  end

  def tracked?
    tracked_at.present?
  end

  private

  def tracking_enabled?
    respond_to?(:tracking_enabled) && tracking_enabled
  end

  def update_tracking
    self.tracking_code ||= generate_tracking_code
  end

  def generate_tracking_code
    SecureRandom.hex(8)
  end
end

ASSOCIATIONS

belongs_to

# Required association (default in Rails 6+)
belongs_to :organization
belongs_to :user

# Optional association
belongs_to :parent, class_name: 'Category', optional: true
belongs_to :assignable, polymorphic: true, optional: true

# With custom foreign key
belongs_to :author, class_name: 'User', foreign_key: 'author_id'
belongs_to :supervisor, class_name: 'User', foreign_key: 'supervisor_id', optional: true

has_many

# Basic has_many with dependent option
has_many :items, dependent: :destroy        # Delete children when parent deleted
has_many :logs, dependent: :nullify         # Set FK to null when parent deleted
has_many :claims, dependent: :restrict_with_error  # Prevent deletion if children exist

# has_many :through
has_many :client_therapists, dependent: :destroy
has_many :therapists, through: :client_therapists, source: :user

# Scoped associations
has_many :active_items, -> { where(status: :active) }, class_name: 'Item'
has_many :recent_logs, -> { order(created_at: :desc).limit(10) }, class_name: 'Log'

# Polymorphic
has_many :form_assignments, as: :assignable, dependent: :destroy
has_many :participants, as: :participantable, dependent: :destroy

has_one

has_one :profile, dependent: :destroy
has_one :latest_entry, -> { order(created_at: :desc) }, class_name: 'Entry'
has_one :platform_invoice, through: :platform_invoice_appointment

Self-Referential Associations

class Category < ApplicationRecord
  belongs_to :parent, class_name: 'Category', optional: true
  has_many :children, class_name: 'Category', foreign_key: 'parent_id', dependent: :destroy
end

class Appointment < ApplicationRecord
  belongs_to :parent_appointment, class_name: 'Appointment', optional: true
  has_many :recurring_instances, class_name: 'Appointment',
           foreign_key: 'parent_appointment_id', dependent: :destroy
end

Eager Loading for N+1 Prevention

class Client < ApplicationRecord
  # Scoped associations for eager loading optimization
  has_one :latest_ledger_entry, -> { order(created_at: :desc) },
    class_name: 'ClientLedgerEntry'

  has_many :active_payment_methods, -> { active },
    class_name: 'PaymentMethod'

  # Method that respects eager loading
  def current_balance_cents
    if association(:latest_ledger_entry).loaded?
      latest_ledger_entry&.running_balance_cents || 0
    else
      ledger_entries.order(created_at: :desc).limit(1).pick(:running_balance_cents) || 0
    end
  end
end

# Controller usage
@clients = Client.includes(:latest_ledger_entry, :active_payment_methods)

ENUMS

Basic Enum Definition

class Appointment < ApplicationRecord
  # Integer-backed enum with explicit values (recommended)
  enum :status, {
    booked: 1,
    cancelled: 2,
    completed: 3,
    no_show: 4
  }, prefix: true  # Creates status_booked?, status_cancelled?, etc.

  enum :appointment_type, {
    initial_assessment: 0,
    regular_session: 1
  }, prefix: true

  enum :category, {
    clinical: 0,
    event: 1
  }, prefix: true
end

Enum Options

# prefix: true - methods become status_active?, status_completed?
# prefix: :custom - methods become custom_active?, custom_completed?
# suffix: true - methods become active_status?, completed_status?

enum :status, { active: 0, inactive: 1 }, prefix: true
enum :priority, { low: 0, high: 1 }, suffix: true

Enum Best Practices

  1. Always use explicit integer values - Prevents issues when adding new values
  2. Use prefix/suffix - Avoids method name collisions
  3. Start at 0 or 1 - Be consistent across the codebase
  4. Don't change existing values - Only add new ones at the end

Enum Usage in Code

appointment = Appointment.new

# Query methods
appointment.status_booked?        # true/false
appointment.status_completed?     # true/false

# Setter methods
appointment.status_completed!     # Updates and saves

# Assignment
appointment.status = :completed   # Symbol
appointment.status = 'completed'  # String

# Scopes (auto-generated)
Appointment.status_booked         # WHERE status = 1
Appointment.status_completed      # WHERE status = 3

# Get all values
Appointment.statuses              # { "booked" => 1, "cancelled" => 2, ... }

VALIDATIONS

Presence Validations

validates :first_name, :last_name, :date_of_birth, presence: true
validates :email, presence: true

Format Validations

validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :phone, format: {
  with: /\A\d{10,15}\z/,
  message: 'must be 10-15 digits',
  allow_blank: true
}

Inclusion Validations

# With constant array
STATUSES = %w[active inactive archived].freeze
validates :status, inclusion: { in: STATUSES }

# Allow blank for optional fields
validates :sex, inclusion: { in: SEXES, allow_blank: true }

# With time zone validation
validates :timezone, inclusion: {
  in: ActiveSupport::TimeZone.us_zones.map(&:name),
  allow_blank: true
}

Uniqueness Validations

# ALWAYS scope to organization for multi-tenant models
validates :email, uniqueness: { scope: :organization_id }
validates :code, uniqueness: { scope: [:organization_id, :category] }

# Case-insensitive uniqueness
validates :email, uniqueness: { scope: :organization_id, case_sensitive: false }

# Allow nil
validates :external_id, uniqueness: { scope: :organization_id }, allow_nil: true

Conditional Validations

validates :service_date, presence: true, if: -> { signed? }
validates :supervisor_id, presence: true, if: :needs_supervision?
validates :title, presence: true, unless: -> { draft? }

# With method reference
validates :end_time, presence: true, if: :has_time_range?

private

def has_time_range?
  start_time.present?
end

Custom Validations

validate :end_time_after_start_time
validate :validate_business_rule
validate :validate_primary_contacts

private

def end_time_after_start_time
  return unless start_time && end_time
  errors.add(:end_time, 'must be after start time') if end_time <= start_time
end

def validate_business_rule
  if some_complex_condition?
    errors.add(:base, 'Descriptive error message')
  end
end

def validate_primary_contacts
  validate_primary_email
  validate_primary_phone
end

def validate_primary_email
  return unless emails.loaded?
  active_emails = emails.reject(&:marked_for_destruction?)
  return if active_emails.empty?

  primary_emails = active_emails.select(&:primary?)
  if primary_emails.empty?
    errors.add(:base, 'Please select a primary email')
  elsif primary_emails.size > 1
    errors.add(:base, 'Only one email can be primary')
  end
end

SCOPES

Basic Scopes

scope :active, -> { where(status: :active) }
scope :recent, -> { order(created_at: :desc) }
scope :by_date, -> { order(service_date: :desc) }
scope :by_creation, -> { order(created_at: :desc) }

Parameterized Scopes

scope :for_user, ->(user_id) { where(user_id: user_id) }
scope :for_date, ->(date) { where(start_time: date.beginning_of_day..date.end_of_day) }
scope :created_after, ->(date) { where('created_at > ?', date) }
scope :recent, ->(limit = 10) { by_creation.limit(limit) }

Complex Scopes

# Joining tables
scope :with_payments, -> { joins(:payments).distinct }
scope :assigned_to_office, ->(office_id) {
  joins(:user_offices).where(user_offices: { office_id: office_id }).distinct
}

# Subqueries
scope :unbilled_for_platform, ->(billing_period_end, organization_id: nil) {
  org_id = organization_id || ActsAsTenant.current_tenant&.id
  raise ArgumentError, 'organization_id required' unless org_id

  where(category: :clinical)
    .where(status: :completed)
    .where('completed_at <= ?', billing_period_end.end_of_day)
    .where.not(id: PlatformInvoiceAppointment.where(organization_id: org_id).select(:appointment_id))
}

# Negation scopes
scope :upcoming, -> { where('start_time > ?', Time.current) }
scope :past, -> { where('end_time < ?', Time.current) }

Scope Chaining

# Scopes should be chainable
Client.active.recent(5)
Appointment.for_date(Date.today).status_booked.for_user(user.id)

CALLBACKS

Callback Order

# Validation callbacks
before_validation :normalize_data
after_validation :log_validation_errors

# Save callbacks
before_save :set_defaults
before_save :update_computed_fields
after_save :handle_status_change, if: -> { saved_change_to_status? }

# Create callbacks
before_create :generate_code
after_create :send_welcome_notification
after_create_commit :enqueue_background_job

# Update callbacks
after_update :sync_external_system, if: :sync_needed?

# Destroy callbacks
before_destroy :check_dependencies
after_destroy :cleanup_external_resources

Conditional Callbacks

# With proc/lambda
after_save :handle_status_change, if: -> { saved_change_to_status? }
before_save :set_completed_at, if: -> { status_changed? && status_completed? }

# With method reference
after_create_commit :trigger_reminders, if: :should_send_reminders?

private

def should_send_reminders?
  category_clinical? && status_booked?
end

Callback Best Practices

  1. Keep callbacks simple - Complex logic belongs in services
  2. Use after_commit - For external API calls and background jobs
  3. Avoid callback chains - Hard to debug and maintain
  4. Be explicit about conditions - Always add if: or unless:
# Good: Simple, focused callback
after_create_commit :send_notification

private

def send_notification
  NotificationJob.perform_later(id)
rescue StandardError => e
  Rails.logger.error "[#{self.class.name}##{id}] Notification failed: #{e.message}"
end

# Bad: Complex logic in callback
after_save :process_everything
def process_everything
  # 50 lines of business logic - should be in a service
end

INSTANCE METHODS

Computed Properties

def full_name
  "#{first_name} #{last_name}"
end

def age
  return nil unless date_of_birth.present?
  ((Date.current - date_of_birth) / 365.25).floor
end

def adult?
  return false unless date_of_birth.present?
  age >= 18
end

def minor?
  !adult?
end

State Queries

def active?
  status_active? && !deleted?
end

def editable?
  status_draft? && !deleted?
end

def ready_for_signing?
  draft? && completion_percentage >= 100
end

def needs_attention?
  status_completed? && !has_required_documents?
end

Business Logic Methods

# Keep methods focused and small
def has_payment_method?
  payment_methods.active.exists?
end

def default_payment_method
  payment_methods.active.where(is_default: true).first ||
    payment_methods.active.order(:created_at).first
end

def has_insurance?
  insurance_policies.currently_active.exists?
end

def insurance_verified?
  primary_insurance&.recently_verified? || false
end

Methods with Eager Loading Awareness

# Check if association is loaded to prevent N+1 queries
def has_active_payment_method?
  if association(:active_payment_methods).loaded?
    active_payment_methods.any?
  elsif payment_methods.loaded?
    payment_methods.any?(&:active?)
  else
    payment_methods.active.exists?
  end
end

def current_balance_cents
  if association(:latest_ledger_entry).loaded?
    latest_ledger_entry&.running_balance_cents || 0
  elsif ledger_entries.loaded?
    ledger_entries.max_by(&:created_at)&.running_balance_cents || 0
  else
    ledger_entries.order(created_at: :desc).limit(1).pick(:running_balance_cents) || 0
  end
end

CLASS METHODS

Ransack Configuration

Required for Avo admin search functionality:

def self.ransackable_attributes(auth_object = nil)
  %w[first_name last_name email status created_at]
end

def self.ransackable_associations(auth_object = nil)
  %w[organization user]
end

Search Methods

def self.search(query)
  where('name ILIKE ? OR email ILIKE ?', "%#{query}%", "%#{query}%")
end

def self.find_by_code(code)
  find_by(code: code.upcase.strip)
end

SERIALIZATION

JSON Columns

class Appointment < ApplicationRecord
  # For JSONB columns (PostgreSQL), no serialization needed
  # Just define as jsonb in migration

  # For text/string columns storing JSON
  serialize :attendees, coder: JSON
  serialize :settings, coder: YAML
end

Usage

# JSONB column - direct access
appointment.recurrence_rules = { frequency: 'weekly', interval: 1 }
appointment.recurrence_rules['frequency']  # => 'weekly'

# Serialized column
appointment.attendees = [{ name: 'John', email: 'john@example.com' }]
appointment.save
appointment.reload.attendees  # => [{ name: 'John', email: 'john@example.com' }]

NESTED ATTRIBUTES

Basic Setup

class Client < ApplicationRecord
  has_many :emails, dependent: :destroy
  has_many :phone_numbers, dependent: :destroy

  accepts_nested_attributes_for :emails,
    allow_destroy: true,
    reject_if: :all_blank

  accepts_nested_attributes_for :phone_numbers,
    allow_destroy: true,
    reject_if: :all_blank
end

Custom Reject Logic

accepts_nested_attributes_for :availability_rules,
  allow_destroy: true,
  reject_if: proc { |attrs|
    # Reject if inactive and new record
    if attrs['active'] == '0' && attrs['id'].blank?
      true
    # Reject if no time data at all
    elsif attrs['start_time'].blank? && attrs['end_time'].blank?
      true
    else
      false
    end
  }

Validation with Nested Attributes

class Client < ApplicationRecord
  has_many :emails, dependent: :destroy
  accepts_nested_attributes_for :emails, allow_destroy: true

  validate :validate_primary_email

  private

  def validate_primary_email
    # Skip if not loaded (avoids N+1)
    return unless emails.loaded?

    active_emails = emails.reject(&:marked_for_destruction?)
    return if active_emails.empty?

    primary_emails = active_emails.select(&:primary?)

    if primary_emails.empty?
      errors.add(:base, 'Please select a primary email')
    elsif primary_emails.size > 1
      errors.add(:base, 'Only one email can be primary')
    end
  end
end

SERVICE INTEGRATION

Models should delegate complex business logic to service objects.

Pattern

class ClinicalDocument < ApplicationRecord
  # Simple queries stay in model
  def signature_requirements
    @signature_requirements ||= SignatureRequirementService.new(self).requirements
  end

  # Business logic delegates to service
  def sign!(user)
    ClinicalDocumentSignatureService.call(document: self, signer: user)
  end

  # Complex operations use services
  def create_progress_note(author, attributes = {})
    clinical_documents.create({
      document_type: :progress_note,
      author: author,
      chart: chart,
      title: "Progress Note - #{start_time.strftime('%B %d, %Y')}",
      service_date: start_time.to_date,
      status: :draft,
      generation_method: :manual
    }.merge(attributes))
  end
end

Service Result Handling

# In controller
def create
  result = CreateClientService.call(params: client_params, organization: current_organization)

  if result.success?
    redirect_to result.data[:client], notice: 'Client created'
  else
    @client = Client.new(client_params)
    flash.now[:alert] = result.error
    render :new
  end
end

HIPAA COMPLIANCE

Models Requiring HipaaLoggable

  • Client
  • ClinicalDocument
  • Appointment
  • Chart
  • InsurancePolicy
  • Any model with PHI

Implementation

class Client < ApplicationRecord
  include HipaaLoggable

  # Override for meaningful audit logs
  def hipaa_log_identifier
    "Client: #{full_name}"
  end

  # Override if model has client association
  def hipaa_associated_client
    self  # Client IS the client
  end
end

class ClinicalDocument < ApplicationRecord
  include HipaaLoggable

  def hipaa_log_identifier
    "Clinical Document: #{title} for #{chart.client.full_name}"
  end

  def hipaa_associated_client
    chart&.client
  end
end

MIGRATION PATTERNS

Standard Migration

class CreateExamples < ActiveRecord::Migration[8.0]
  def change
    create_table :examples, id: false do |t|
      # Primary key (NanoID)
      t.string :id, limit: 12, null: false, primary_key: true

      # Foreign keys
      t.string :organization_id, limit: 12, null: false
      t.string :user_id, limit: 12, null: false
      t.string :client_id, limit: 12

      # Standard columns
      t.string :name, null: false
      t.string :email
      t.text :description
      t.integer :status, default: 0, null: false
      t.date :service_date
      t.datetime :completed_at

      # JSONB for structured data
      t.jsonb :metadata, default: {}

      # Soft delete
      t.datetime :deleted_at

      t.timestamps
    end

    # Indexes
    add_index :examples, :organization_id
    add_index :examples, :user_id
    add_index :examples, :client_id
    add_index :examples, :status
    add_index :examples, :deleted_at
    add_index :examples, [:organization_id, :email], unique: true

    # Foreign key constraints
    add_foreign_key :examples, :organizations
    add_foreign_key :examples, :users
    add_foreign_key :examples, :clients
  end
end

Adding Columns

class AddFieldsToExamples < ActiveRecord::Migration[8.0]
  def change
    add_column :examples, :priority, :integer, default: 0
    add_column :examples, :notes, :text
    add_column :examples, :settings, :jsonb, default: {}

    add_index :examples, :priority
  end
end

TESTING MODELS

See the rspec-test-expert skill for comprehensive model testing patterns. Key points:

require 'rails_helper'

RSpec.describe Client, type: :model do
  let(:organization) { create(:organization) }

  describe 'validations' do
    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
    it { should validate_inclusion_of(:status).in_array(Client::STATUSES) }
  end

  describe 'associations' do
    it { should belong_to(:organization) }
    it { should have_many(:appointments).through(:appointment_clients) }
    it { should have_many(:payment_methods).dependent(:destroy) }
  end

  describe '#full_name' do
    it 'returns concatenated first and last name' do
      ActsAsTenant.with_tenant(organization) do
        client = build(:client, first_name: 'John', last_name: 'Doe')
        expect(client.full_name).to eq('John Doe')
      end
    end
  end
end

COMMON PATTERNS

Status Badge Mapping

For UI display with Avo or ViewComponents:

STATUS_BADGE_MAP = {
  draft: :info,
  pending: :warning,
  active: :success,
  completed: :success,
  failed: :danger,
  cancelled: :secondary
}.freeze

def status_badge_color
  STATUS_BADGE_MAP[status.to_sym] || :secondary
end

Formatted Display Methods

def display_name
  "#{first_name} #{last_name} (#{email})"
end

def formatted_date
  created_at.strftime('%B %d, %Y')
end

def formatted_amount
  return '-' unless amount_cents.present?
  "$#{'%.2f' % (amount_cents / 100.0)}"
end

Safe Navigation Chains

def primary_therapist_name
  primary_therapist&.full_name || 'Unassigned'
end

def insurance_status
  primary_insurance&.status || 'No insurance'
end

EXECUTION STRATEGY

When creating or modifying models:

  1. Read existing models - Understand current patterns in the codebase
  2. Check for similar models - Find models with similar functionality
  3. Plan the structure - Associations, validations, scopes needed
  4. Write migration first - Ensure database schema is correct
  5. Implement model - Follow the structure template above
  6. Add concerns - SoftDeletable, HipaaLoggable as needed
  7. Write tests - Validations, associations, methods
  8. Verify multi-tenancy - Ensure proper organization scoping

ANTI-PATTERNS TO AVOID

  1. Fat models - Complex logic belongs in services
  2. Skipping multi-tenancy - Always include acts_as_tenant
  3. Missing soft delete - Use SoftDeletable for recoverable data
  4. N+1 queries - Use eager loading and scoped associations
  5. Validation bypasses - Never use save(validate: false) in production code
  6. Direct SQL - Use ActiveRecord query methods
  7. Callback abuse - Keep callbacks simple, use services for complex logic
  8. Missing HIPAA logging - Include HipaaLoggable for PHI models

Remember: Models should be clean, well-organized, and follow established patterns. When in doubt, look at existing models like Client, User, or Appointment for guidance.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.