Skip to content

Instantly share code, notes, and snippets.

@adham90
Last active January 8, 2026 10:18
Show Gist options
  • Select an option

  • Save adham90/e2a67df83dd00e644984a75f1847787c to your computer and use it in GitHub Desktop.

Select an option

Save adham90/e2a67df83dd00e644984a75f1847787c to your computer and use it in GitHub Desktop.
Compact guide for AI agents working on Rails applications.

AI Coding Agent Guide: Rails Best Practices

Compact guide for AI agents working on Rails applications. Focus on existing patterns, keep changes minimal, and preserve security/performance.

Core Principles

DO: Follow existing architecture (concerns, scopes, jobs, Turbo Streams, Stimulus). Keep diffs minimal. Optimize for clarity. Write tests for all new code. Preserve security/performance.

DON'T: Introduce new frameworks. Mix refactors with behavior changes. Over-abstract. Bypass security checks. Log secrets.


Change Log

When making significant changes to architecture, database schema, or models, document the decision in changelog/.

What to Log

  • Database schema changes
  • Model restructuring or renaming
  • Major refactors affecting multiple files
  • New architectural patterns introduced

ADR Format

Create a new file: changelog/YYYY-MM-DD-short-title.md

# Title

## Context

What is the background? What problem are we solving?

## Decision

What change was made?

## Consequences

- What are the implications?
- What migrations or follow-up work is needed?

Project Organization

✅ DO: Use Clear Directory Structure

app/
├── models/
│   ├── user.rb              # Main model
│   └── user/                # Concerns
│       ├── role.rb
│       └── avatar.rb
├── controllers/
│   ├── concerns/            # Shared logic
│   └── application_controller.rb
└── javascript/
    └── controllers/         # Stimulus

❌ DON'T: Mix Concerns in One File

# BAD: 500+ lines in user.rb
class User < ApplicationRecord
  # Everything here
end

# GOOD: Split into concerns
class User < ApplicationRecord
  include Avatar, Bot, Mentionable, Role
end

Code Architecture

✅ DO: Use Concerns for Shared Behavior

# app/models/message.rb
class Message < ApplicationRecord
  include Attachment, Broadcasts, Mentionee, Searchable
  belongs_to :creator, class_name: "User", default: -> { Current.user }
end

# app/models/message/broadcasts.rb
module Message::Broadcasts
  def broadcast_create
    broadcast_append_to room, :messages, target: [ room, :messages ]
  end
end

✅ DO: Use Current for Thread-Safe Context

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :user, :account
end

# Usage
belongs_to :creator, class_name: "User", default: -> { Current.user }

✅ DO: Extend Associations

has_many :memberships do
  def revise(granted: [], revoked: [])
    transaction do
      revoke_from(revoked) if revoked.any?
      grant_to(granted) if granted.any?
    end
  end
end

✅ DO: Use STI When Appropriate

class Room < ApplicationRecord; end
class Rooms::Open < Room; end
class Rooms::Closed < Room; end

Use STI when: Types share 90%+ behavior, similar schema, need polymorphic queries.


Console-First Testability

Core Principle: All business logic should be executable directly from rails console. Background jobs, service objects, and controllers are thin wrappers that delegate to model/concern methods.

Why This Matters

  • Fast feedback — Test logic instantly without triggering jobs or HTTP requests
  • Debuggable — Reproduce issues with User.find(123).sync_competitors
  • Composable — Combine operations in console for data fixes or exploration
  • Testable — Unit test the logic, integration test the wrapper

The _async Pattern

Every async operation should have a synchronous counterpart on the model:

# app/models/competitor.rb
class Competitor < ApplicationRecord
  # ✅ Synchronous method - contains ALL the logic
  def scrape_data
    response = CompetitorScraper.fetch(url)
    update!(
      name: response.name,
      pricing: response.pricing,
      last_scraped_at: Time.current
    )
  end

  # ✅ Async wrapper - just enqueues the job
  def scrape_data_async
    Competitor::ScrapeDataJob.perform_later(self)
  end
end

# app/jobs/competitor/scrape_data_job.rb
class Competitor::ScrapeDataJob < ApplicationJob
  # ✅ Job is a thin wrapper - NO business logic here
  def perform(competitor)
    competitor.scrape_data
  end
end

Usage:

# Console/tests - immediate execution
competitor.scrape_data

# Production code - background execution
competitor.scrape_data_async

# Bulk operations in console
Competitor.stale.find_each(&:scrape_data)

Pattern Comparison

❌ BAD: Logic in Job ✅ GOOD: Logic in Model
Can't test without job infrastructure Test directly: model.do_thing
Can't run from console easily Run anytime: Model.find(1).do_thing
Hard to debug production issues Reproduce instantly in console
Logic scattered across layers Single source of truth

Apply to All Async Operations

Background Jobs:

class User < ApplicationRecord
  def send_welcome_email
    WelcomeMailer.welcome(self).deliver_now
  end

  def send_welcome_email_async
    User::SendWelcomeEmailJob.perform_later(self)
  end
end

Scheduled Tasks:

class Account < ApplicationRecord
  # The actual work
  def generate_weekly_report
    Report.create!(
      account: self,
      data: calculate_metrics,
      period: 1.week.ago..Time.current
    )
  end

  # Class method for scheduler to call
  def self.generate_all_weekly_reports
    find_each(&:generate_weekly_report)
  end

  def self.generate_all_weekly_reports_async
    Account::GenerateWeeklyReportsJob.perform_later
  end
end

External API Syncs:

class Integration < ApplicationRecord
  def sync
    client = build_client
    data = client.fetch_all
    process_sync_data(data)
    update!(last_synced_at: Time.current)
  end

  def sync_async
    Integration::SyncJob.perform_later(self)
  end

  private

  def process_sync_data(data)
    # All the complex logic lives here, testable directly
  end
end

Service Objects (When Needed)

If logic spans multiple models, create a service object that's still console-callable:

# app/services/onboarding/complete.rb
class Onboarding::Complete
  def initialize(user)
    @user = user
  end

  # ✅ Main logic - callable directly
  def call
    ActiveRecord::Base.transaction do
      create_default_workspace
      setup_initial_competitors
      send_welcome_sequence
    end
    @user.update!(onboarded_at: Time.current)
  end

  # ✅ Async wrapper
  def call_async
    Onboarding::CompleteJob.perform_later(@user)
  end

  # ✅ Convenience class methods
  def self.call(user) = new(user).call
  def self.call_async(user) = new(user).call_async

  private

  def create_default_workspace
    @user.workspaces.create!(name: "My Workspace")
  end

  # ... other private methods
end

# Usage from anywhere:
Onboarding::Complete.call(user)           # Sync
Onboarding::Complete.call_async(user)     # Async
Onboarding::Complete.new(user).call       # Instance style

Concerns for Shared Async Patterns

# app/models/concerns/async_scraping.rb
module AsyncScraping
  extend ActiveSupport::Concern

  # Override in model to define scraping logic
  def scrape
    raise NotImplementedError, "#{self.class} must implement #scrape"
  end

  def scrape_async
    ScrapeJob.perform_later(self.class.name, id)
  end

  def scrape_if_stale
    scrape if stale?
  end

  def stale?
    last_scraped_at.nil? || last_scraped_at < 24.hours.ago
  end
end

# app/models/competitor.rb
class Competitor < ApplicationRecord
  include AsyncScraping

  def scrape
    # Competitor-specific scraping logic
  end
end

Testing Async Operations

# Test the logic directly (fast, no job infrastructure)
test "scraping updates competitor data" do
  competitor = competitors(:acme)
  
  competitor.scrape_data
  
  assert_not_nil competitor.reload.last_scraped_at
  assert_equal "Acme Corp", competitor.name
end

# Test that async version enqueues correctly (integration)
test "scrape_data_async enqueues job" do
  competitor = competitors(:acme)
  
  assert_enqueued_with(job: Competitor::ScrapeDataJob, args: [competitor]) do
    competitor.scrape_data_async
  end
end

Console Debugging Checklist

Before shipping, verify you can:

# ✅ Execute the core logic
user.send_welcome_email
competitor.scrape_data
account.generate_weekly_report

# ✅ Check state without side effects
user.onboarded?
competitor.stale?
account.reports.last

# ✅ Bulk operations
Competitor.stale.find_each(&:scrape_data)
User.where(onboarded_at: nil).find_each { |u| Onboarding::Complete.call(u) }

Naming Conventions

Type Pattern Example
Models Singular noun User, Message
Namespaced Rooms::Open, Push::Subscription
Concerns Message::Broadcasts, User::Role
Controllers Plural resource MessagesController
Nested accounts/bots_controller.rb
Stimulus Kebab-case auto_submit_controller.js
Tests _test.rb suffix message_test.rb

Testing

✅ DO: Descriptive Test Names

test "creating a message enqueues push job" do
  assert_enqueued_jobs 1, only: [ Room::PushMessageJob ] do
    create_new_message_in rooms(:designers)
  end
end

test "non-admin can't update another user's message" do
  sign_in :jz
  put room_message_url(room, message), params: { message: { body: "Updated" } }
  assert_response :forbidden
end

✅ DO: Use Fixtures

# test/fixtures/users.yml
david:
  email_address: david@example.test
  password_digest: <%= BCrypt::Password.create("secret123456") %>
  role: administrator

# Usage
test "admin can delete message" do
  sign_in :david
  delete room_message_url(room, message)
  assert_response :success
end

✅ DO: Test Security & Jobs

# Test broadcasts
test "creating message broadcasts unread room" do
  assert_broadcasts "unread_rooms", 1 do
    post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New" } }
  end
end

# Test jobs
test "mentioning bot triggers webhook" do
  assert_enqueued_jobs 1, only: Bot::WebhookJob do
    post room_messages_url(@room), params: { message: { body: mention } }
  end
end

✅ REQUIRED: Test All New Code

Every new feature or change MUST include tests:

Change Type Required Tests
New model Model test with validations, associations, scopes
New controller action Controller/integration test
New UI/view System test with Capybara/Playwright
Bug fix Regression test proving the fix
New job Job test with assertions

Commands:

bin/rails test                          # Run unit/integration tests
bin/rails test:system                   # Run system tests (Playwright)
bin/rails test test/models/user_test.rb # Run specific test file

System test example (Capybara + Playwright):

# test/system/homepage_test.rb
require "application_system_test_case"

class HomepageTest < ApplicationSystemTestCase
  test "visiting the homepage" do
    visit root_path
    assert_text "Welcome"
  end
end

Security

✅ DO: Authentication & Authorization

# app/controllers/concerns/authorization.rb
module Authorization
  def ensure_owns_resource(resource)
    head :forbidden unless resource.creator == Current.user || Current.user&.administrator?
  end
end

# Usage
before_action -> { ensure_owns_resource(@message) }, only: [:update, :destroy]

✅ DO: Strong Parameters

def message_params
  params.require(:message).permit(:body, :client_message_id)
end
# NEVER: params[:message]  # Mass assignment vulnerability

✅ DO: Filter Sensitive Data

# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :otp, :ssn
]

✅ DO: Validate Input

validates :email_address, presence: true, uniqueness: true,
                          format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, inclusion: { in: %w[member administrator bot] }

Do's and Don'ts

Models

✅ DO ❌ DON'T
Use concerns for cross-cutting behavior Put everything in one file
Define scopes for common queries Write raw SQL everywhere
Use callbacks sparingly Abuse callbacks
Validate at model level Assume data is valid
Use default: for automatic values Set defaults in migrations only
# ✅ GOOD
class Message < ApplicationRecord
  include Broadcasts, Mentionee, Searchable
  belongs_to :creator, class_name: "User", default: -> { Current.user }
  scope :ordered, -> { order(:created_at) }
  scope :recent, -> { ordered.limit(50) }
end

Controllers

✅ DO ❌ DON'T
Keep controllers thin Put business logic in controllers
Use before_action for setup Repeat setup in every action
Return appropriate status codes Always return 200 OK
Use concerns for shared logic Copy/paste between controllers
# ✅ GOOD
class MessagesController < ApplicationController
  before_action :set_room
  before_action :set_message, only: [:show, :update, :destroy]

  def create
    @message = @room.messages.create!(message_params)
    respond_to { |format| format.turbo_stream }
  end

  private
    def message_params
      params.require(:message).permit(:body, :client_message_id)
    end
end

JavaScript/Stimulus

✅ DO ❌ DON'T
Keep controllers single-purpose God controllers
Use data attributes for config Hardcode values
Clean up on disconnect Cause memory leaks
// ✅ GOOD
export default class extends Controller {
  static values = { url: String, interval: { type: Number, default: 5000 } };
  static targets = ["input", "output"];

  connect() {
    this.intervalId = setInterval(() => this.refresh(), this.intervalValue);
  }

  disconnect() {
    clearInterval(this.intervalId);
  }
}

Rails-Specific Tips

✅ DO: RESTful Routes

resources :rooms do
  resources :messages, only: [:index, :create, :show, :update, :destroy]
end

✅ DO: Enums with Prefix

enum :role, %i[ member administrator bot ], prefix: :role
# Generates: role_member?, role_administrator?, role_bot?

✅ DO: Preload to Avoid N+1

# ✅ GOOD
scope :with_creator, -> { preload(creator: :avatar_attachment) }
messages = room.messages.with_creator

# ❌ BAD
messages.each { |m| puts m.creator.name }  # N queries!

✅ DO: Background Jobs

# app/jobs/room/push_message_job.rb
class Room::PushMessageJob < ApplicationJob
  def perform(message)
    message.room.push_notification_for(message)
  end
end

# Trigger
after_create_commit -> { Room::PushMessageJob.perform_later(self) }

✅ DO: Turbo Streams

def create
  @message = @room.messages.create!(message_params)
  respond_to { |format| format.turbo_stream }
end

def broadcast_create
  broadcast_append_to room, :messages, target: [ room, :messages ]
end

CSS & Tailwind Guidelines

File Organization

Before creating CSS, check the existing structure:

  1. Find the main stylesheet entry point (e.g., application.css)
  2. Look for existing component files in a components/ directory
  3. Check for theme/variable definitions

Rules:

  • Import new component files in the main entry point
  • One component per file in components/
  • Keep theme variables in a dedicated file (e.g., themes.css)

Theme Variables

Always use existing CSS custom properties instead of hardcoded values:

/* ✅ GOOD - uses project's semantic variables */
@apply bg-primary text-primary-foreground;
@apply border-destructive;

/* ❌ BAD - hardcoded colors bypass theming */
@apply bg-blue-500 text-white;
@apply border-red-500;

Before writing CSS:

  1. Check existing theme variables in the project
  2. Use semantic names (primary, secondary, accent, destructive, muted, etc.)
  3. Follow the existing color naming convention

Component File Structure

Follow this pattern for new component stylesheets:

/* Component Name
 * Usage: <element class="component" data-variant="x" data-size="y">
 *
 * Variants: list, available, options
 * Sizes: sm, md, lg
 * States: error, success, loading
 */

/* ==================== BASE ==================== */
.component {
    @apply /* base styles */;
    @apply transition-all duration-150 ease-in-out;
    @apply focus-visible:ring-2 outline-none;
    @apply disabled:pointer-events-none disabled:opacity-50;
}

/* ==================== SIZES ==================== */
.component[data-size="sm"] { @apply /* small */; }
.component[data-size="md"],
.component:not([data-size]) { @apply /* medium - default */; }
.component[data-size="lg"] { @apply /* large */; }

/* ==================== VARIANTS ==================== */
.component[data-variant="primary"],
.component:not([data-variant]) { @apply /* primary - default */; }
.component[data-variant="secondary"] { @apply /* secondary */; }

/* ==================== STATES ==================== */
.component[data-state="error"] { @apply /* error state */; }
.component[data-state="loading"] { @apply cursor-wait opacity-75; }

/* ==================== RESPONSIVE ==================== */
@media (max-width: 640px) {
    .component { @apply /* mobile adjustments */; }
}

Data Attributes for Configuration

Use data attributes instead of modifier classes:

<!-- ✅ GOOD - data attributes -->
<button class="btn" data-variant="accent" data-size="lg">Submit</button>
<input class="input" data-size="sm" data-state="error" />

<!-- ❌ BAD - BEM-style modifier classes -->
<button class="btn btn-accent btn-lg">Submit</button>
<input class="input input-sm input-error" />

Standard data attributes:

Attribute Purpose Examples
data-variant Visual style primary, secondary, accent, destructive, outline, ghost
data-size Dimensions sm, md, lg, xl
data-state Current state error, success, loading, disabled
data-type Special type icon (for icon-only buttons)

Default Values with :not() Selectors

Always provide sensible defaults so the base class works without attributes:

/* Default size when data-size is not specified */
.component[data-size="md"],
.component:not([data-size]) {
    @apply h-10 px-4 py-2;
}

/* Default variant when data-variant is not specified */
.component[data-variant="primary"],
.component:not([data-variant]) {
    @apply bg-primary text-primary-foreground;
}

Required Accessibility States

Every interactive component MUST include these states:

.component {
    /* Focus visible for keyboard navigation */
    @apply focus-visible:ring-2 focus-visible:outline-none;

    /* Disabled state */
    @apply disabled:pointer-events-none disabled:opacity-50;

    /* Smooth transitions */
    @apply transition-all duration-150 ease-in-out;
}

CSS Do's and Don'ts

✅ DO ❌ DON'T
Use existing theme variables Use raw color values (bg-blue-500)
Use data attributes for variants Create BEM modifier classes (.btn--large)
Check existing components first Duplicate similar styles
Include focus/disabled states Skip accessibility states
Put styles in component files Inline styles in views
Use @apply for Tailwind utilities Mix raw CSS with Tailwind inconsistently
Add usage comments at file top Leave components undocumented

Before Writing Any CSS

  1. Search for existing components - Check if a similar component exists
  2. Review theme variables - Use semantic colors from the theme
  3. Follow existing patterns - Match the style of existing component files
  4. Document usage - Add a comment block showing how to use the component

Configuration

✅ DO: Environment Variables

# config/database.yml
production:
  database: storage/<%= ENV.fetch("RAILS_ENV") %>.sqlite3

# config/environments/production.rb
config.log_level = ENV.fetch("LOG_LEVEL", "info")

Never hardcode: API keys, passwords, hosts/URLs, feature flags.

✅ DO: Linters & Security

# .rubocop.yml
inherit_gem: { rubocop-rails-omakase: rubocop.yml }

Run: bin/brakeman for security scanning.


Key Takeaways

  1. Organize by concern - Use modules/concerns
  2. Keep files focused - Single responsibility
  3. Test security - Authorization, edge cases
  4. Security by default - Auth, validation, CSRF
  5. Follow conventions - Use framework patterns
  6. Avoid N+1 - Preload associations, use background jobs
  7. Clean up resources - Timers, listeners, subscriptions
  8. Console-first design - All logic testable from rails console
  9. Thin wrappers - Jobs/services delegate to model methods
  10. _async pattern - Sync method + async wrapper for every background operation
  11. CSS: Theme variables - Use semantic CSS custom properties, not hardcoded colors
  12. CSS: Data attributes - Configure components via data-* attributes, not modifier classes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment