Ruby on Rails
Project context
This is a Rails 8 project. We follow Rails conventions ruthlessly — fighting them costs more than it's worth. Hotwire-first UI (Turbo + Stimulus); avoid SPA frameworks unless the product genuinely demands one.
Stack
- Ruby 3.3+
- Rails 8+
- Postgres 15+
- Hotwire (Turbo, Stimulus)
- Tailwind CSS via
tailwindcss-rails - Sidekiq for background jobs (or Solid Queue, the new Rails default)
- RSpec or Minitest (consistent across the project)
- Standard or RuboCop for lint
- Bun or import maps for JS
Folder structure (Rails defaults)
app/
controllers/
models/
views/
helpers/
jobs/
mailers/
components/ — view components (if using ViewComponent gem)
javascript/
controllers/ — Stimulus controllers
config/
db/
migrate/
schema.rb
test/ or spec/
Don't reinvent. If your code doesn't fit Rails defaults, it's usually wrong.
Models
- One model per database table
- Validations on the model — not just at the form level
- Scopes for reusable queries:
scope :published, -> { where.not(published_at: nil) } - Associations declared explicitly:
has_many :posts, dependent: :destroy - Use
enumfor state-like fields with a fixed set of values - Use
ActiveRecord::Encryptionfor sensitive fields
class Post < ApplicationRecord
belongs_to :author, class_name: "User"
has_many :comments, dependent: :destroy
validates :title, presence: true, length: { maximum: 200 }
scope :published, -> { where.not(published_at: nil) }
scope :recent, ->(n = 20) { order(created_at: :desc).limit(n) }
enum :status, { draft: 0, published: 1, archived: 2 }
end
Controllers
- Skinny — call models / services, render
- One CRUD action per public method (
index,show,new,create,edit,update,destroy) - Use strong parameters:
params.expect(post: [:title, :body])(Rails 8) orparams.require(:post).permit(:title, :body) - Handle
respond_toblocks for HTML and Turbo Stream responses
class PostsController < ApplicationController
def create
@post = Current.user.posts.build(post_params)
if @post.save
respond_to do |f|
f.html { redirect_to @post }
f.turbo_stream
end
else
render :new, status: :unprocessable_entity
end
end
private
def post_params = params.expect(post: [:title, :body])
end
Hotwire / Turbo / Stimulus
- Use Turbo Frames to scope partial updates (
<turbo-frame id="...">) - Use Turbo Streams to broadcast updates over WebSocket (
Turbo::StreamsChannel.broadcast_*) - Use Stimulus for behavior; don't hand-roll vanilla JS
- One Stimulus controller per behavior; small, focused
- Keep state in the DOM where possible
Migrations
bin/rails generate migration AddTitleToPosts title:string- Always set
null: falseand a default for new columns on existing tables - Add indexes (
add_index) for any column used inwherefilters - Reversible migrations: provide
change, or bothupanddown - Never edit a committed migration; create a new one
Background jobs
- ActiveJob with Solid Queue (Rails 8 default) or Sidekiq
- Idempotent jobs — they will be retried; design for it
- Don't pass complex objects into jobs; pass IDs and re-fetch
class NotifyJob < ApplicationJob
queue_as :default
retry_on ApiError, wait: :polynomially_longer, attempts: 5
def perform(user_id, post_id)
user = User.find(user_id)
post = Post.find(post_id)
# ...
end
end
Service objects (when controllers grow)
- Plain Ruby classes in
app/services/ - One public method (
call); maybe a class-level.call(...) - Return rich result objects, not just booleans, when callers need details
Testing
- RSpec or Minitest — pick one and stick with it
- Model specs for validations, scopes, business logic
- Request specs for controllers (over view specs)
- System specs (Capybara) for critical user flows
- Use FactoryBot for fixtures
rails test:systemruns system tests; slow but real
Patterns to follow
- Use
Current.user(ActiveSupport::CurrentAttributes) instead of threadingcurrent_userthrough everywhere - Use
dependent: :destroydeliberately — sometimes you want:nullifyor nothing - Use
with_lockfor SELECT FOR UPDATE;transactionfor multi-step writes - Prefer database-level constraints (
null: false, foreign keys) over app-only validations
Patterns to avoid
- Skinny models, fat controllers — Rails wants the inverse
before_filter/before_actionfor too much logic — extract a service- Editing a committed migration — create a new one
- Skipping callbacks (
save(validate: false),update_columns) without good reason - Long-running work in controllers — push to a job
- N+1 queries — use
includes/preload/eager_load; thebulletgem will flag them in dev
Tooling
bin/dev— Procfile-based dev (Rails + Tailwind watcher + JS watcher)bin/rails console— REPLbin/rails db:migratebundle exec rspec(orrails test)bundle exec standardrb(orrubocop)
AI behavioral rules
- Always run
bin/rails generate migrationfor schema changes — never hand-editschema.rb - Validations belong on the model, not the form
- Use Hotwire defaults (Turbo Frame / Stream) for UI updates before suggesting JS or React
- Never modify a committed migration; always create a new one
- Don't
update_columns/save(validate: false)without justifying why - Default to RSpec system tests for user flows; controller tests for API endpoints
- Run the full test suite and the linter before declaring a task done