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. Respect tests. 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 docs/changelog/.
- Database schema changes
- Model restructuring or renaming
- Major refactors affecting multiple files
- New architectural patterns introduced
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?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.
# ✅ 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# 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| 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
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 ]
end# 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