Compact guide for AI agents working on Rails applications. Focus on existing patterns, keep changes minimal, and preserve security/performance.
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.
When making significant changes to architecture, database schema, or models, document the decision in changelog/.
- Database schema changes
- Model restructuring or renaming
- Major refactors affecting multiple files
- New architectural patterns introduced
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?app/
├── models/
│ ├── user.rb # Main model
│ └── user/ # Concerns
│ ├── role.rb
│ └── avatar.rb
├── controllers/
│ ├── concerns/ # Shared logic
│ └── application_controller.rb
└── javascript/
└── controllers/ # Stimulus
# 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# 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# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :account
end
# Usage
belongs_to :creator, class_name: "User", default: -> { Current.user }has_many :memberships do
def revise(granted: [], revoked: [])
transaction do
revoke_from(revoked) if revoked.any?
grant_to(granted) if granted.any?
end
end
endclass Room < ApplicationRecord; end
class Rooms::Open < Room; end
class Rooms::Closed < Room; endUse STI when: Types share 90%+ behavior, similar schema, need polymorphic queries.
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.
- 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
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
endUsage:
# 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)| ❌ 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 |
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
endScheduled 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
endExternal 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
endIf 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# 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# 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
endBefore 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) }| 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 |
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# 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# 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
endEvery 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 fileSystem 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# 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]def message_params
params.require(:message).permit(:body, :client_message_id)
end
# NEVER: params[:message] # Mass assignment vulnerability# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :otp, :ssn
]validates :email_address, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, inclusion: { in: %w[member administrator bot] }| ✅ 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| ✅ 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| ✅ 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);
}
}resources :rooms do
resources :messages, only: [:index, :create, :show, :update, :destroy]
endenum :role, %i[ member administrator bot ], prefix: :role
# Generates: role_member?, role_administrator?, role_bot?# ✅ GOOD
scope :with_creator, -> { preload(creator: :avatar_attachment) }
messages = room.messages.with_creator
# ❌ BAD
messages.each { |m| puts m.creator.name } # N queries!# 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) }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 ]
endBefore creating CSS, check the existing structure:
- Find the main stylesheet entry point (e.g.,
application.css) - Look for existing component files in a
components/directory - 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)
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:
- Check existing theme variables in the project
- Use semantic names (primary, secondary, accent, destructive, muted, etc.)
- Follow the existing color naming convention
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 */; }
}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) |
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;
}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;
}| ✅ 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 |
- Search for existing components - Check if a similar component exists
- Review theme variables - Use semantic colors from the theme
- Follow existing patterns - Match the style of existing component files
- Document usage - Add a comment block showing how to use the component
# 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.
# .rubocop.yml
inherit_gem: { rubocop-rails-omakase: rubocop.yml }Run: bin/brakeman for security scanning.
- Organize by concern - Use modules/concerns
- Keep files focused - Single responsibility
- Test security - Authorization, edge cases
- Security by default - Auth, validation, CSRF
- Follow conventions - Use framework patterns
- Avoid N+1 - Preload associations, use background jobs
- Clean up resources - Timers, listeners, subscriptions
- Console-first design - All logic testable from
rails console - Thin wrappers - Jobs/services delegate to model methods
_asyncpattern - Sync method + async wrapper for every background operation- CSS: Theme variables - Use semantic CSS custom properties, not hardcoded colors
- CSS: Data attributes - Configure components via
data-*attributes, not modifier classes