Skip to content

Instantly share code, notes, and snippets.

@adham90
Created December 28, 2025 22:57
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. Respect tests. 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 docs/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: docs/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.

✅ DO: Use Modules for Stateless Services

# ✅ Stateless utility - use module with module_function
module UrlNormalizer
  module_function

  def normalize(url)
    url.to_s.strip.downcase.chomp("/")
  end
end

# Usage: UrlNormalizer.normalize(url)

# ✅ Stateful workflow - class is appropriate
class OrderProcessor
  def initialize(order)
    @order = order
    @errors = []
  end

  def process
    validate && charge && fulfill
  end
end

✅ DO: Use Data.define for Value Objects

# Immutable value object with behavior
Result = Data.define(:value, :error) do
  def success? = error.nil?
  def failure? = !success?
end

# Usage
Result.new(value: user, error: nil).success? # => true

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

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

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment