Rails is the framework that lets you ship a working CRUD app in a morning and a 4,000-line OrdersController with 14 before_action filters by the end of the quarter. The first regression is almost always an N+1 in a view: @posts.each do |p| p.author.name end, no includes(:author) on the query, the request balloons from 12 ms to 2.4 s in production, and the only reason anybody notices is that NewRelic finally complains. The second is the callback that became the system: before_save :charge_credit_card on the Order model, which fires from the seeds, from the test factory, from a console update! you ran to fix a typo — and now the ops channel is full of duplicate Stripe charges nobody can explain. The third is the fat ActiveRecord model: User with 1,800 lines, 30 has_many associations, six concerns that all import each other, three class << self blocks competing for find_by_email, and a def self.process_signup_with_referral_and_invite_and_credit(email, ...) that nobody dares delete.
Then you add an AI assistant.
Cursor and Claude Code were trained on Rails code that spans nearly two decades — Rails 2 with RAILS_ROOT, Rails 3 with attr_accessible, Rails 4 with protected_attributes gem, Rails 5 with hand-rolled ActionCable controllers, Rails 6 with Webpacker, plus a long tail of legacy patterns: validates_presence_of :email instead of the modern validates :email, presence: true, before_filter instead of before_action, raw SQL string interpolation like Post.where("user_id = #{params[:id]}"), controllers with 200-line actions that build instance variables for six partials, render_to_string called inside model methods to email HTML, generic rescue Exception => e swallowing every error including Interrupt, business logic stuffed into after_create callbacks that fire from fixtures and break every test that touches the model, default_scope { where(deleted_at: nil) } invisibly altering every query in the app, jQuery sprinkled across app/assets/javascripts competing with Stimulus, Sprockets manifests fighting Propshaft, Spring boot caches that lie about which code is loaded, before_action chains six deep with :only and :except flags that shadow each other, params[:user] passed unfiltered to update!, User.find(params[:id]) with no authorization check, flash[:notice] = "Created" returned as the only signal of success, mailers called synchronously from controllers that block the response for 800 ms while SMTP negotiates. Ask for "an endpoint that creates an order and emails the user," and you get a 90-line OrdersController#create with five before_action filters, a mass_assignment of params[:order], an inline OrderMailer.confirmation(@order).deliver_now, a Stripe charge in an after_create, and zero tests. It runs. It is not the Rails you should ship in 2026.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern Rails 8 looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for Rails Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended). For Rails 8 I recommend modular rules so controller conventions don't bleed into model code, model rules don't pollute job code, and cross-cutting concerns like authorization and testing stay visible across every layer:
.cursor/rules/
rails-controllers.mdc # skinny controllers, service objects, result types
rails-models.mdc # validates, scopes, enum, no callbacks-as-logic
rails-queries.mdc # includes/preload/eager_load, pluck, find_each
rails-params.mdc # strong params, form objects, never permit!
rails-jobs.mdc # ActiveJob + Solid Queue, idempotent, retry_on
rails-hotwire.mdc # Turbo Frames + Streams, Stimulus, importmap
rails-authz.mdc # Pundit policies, authorize, policy_scope
rails-testing.mdc # RSpec, FactoryBot, system specs with Cuprite
Frontmatter controls activation: globs: ["app/**/*.rb", "config/**/*.rb", "spec/**/*.rb", "test/**/*.rb"] with alwaysApply: false. Now the rules.
Rule 1: Skinny Controllers, Fat Service Objects — Business Logic In app/services/* POROs With Result Types
The single oldest Rails anti-pattern is the fat controller: an action that authenticates, authorizes, validates, charges a card, sends an email, writes an audit log, returns JSON, and re-renders a partial — all inline. Cursor's training set is full of this; ask for "create order endpoint" and you get 80 lines of controller. The fix is the service object pattern that the Rails community converged on a decade ago and that Rails 8 finally treats as a first-class citizen: controllers orchestrate (parse params, invoke a service, render a response); services own business logic; services return a Result object with success? and either value or error. Callbacks on ActiveRecord models are reserved for state housekeeping (touching timestamps, normalizing strings) — never for sending emails, charging cards, enqueuing jobs, or writing audit logs.
The rule:
Controllers do exactly four things:
1. Authenticate (via concern / before_action :authenticate_user!).
2. Authorize (Pundit; see Rule 7).
3. Permit params (strong params; see Rule 4).
4. Invoke a service object and render the result.
A controller action is at most ~10 lines. If it grows past that, the
business logic moves to a service in app/services/.
Service objects:
- Live in app/services/<context>/<verb_noun>.rb (e.g.
app/services/orders/create_order.rb, app/services/billing/refund.rb).
- Are POROs (Plain Old Ruby Objects). No ApplicationService base class
unless it provides shared behavior — usually it doesn't need to.
- Expose ONE public method, `call`, instance or class. Pattern:
result = Orders::CreateOrder.call(user:, params:)
result = Orders::CreateOrder.new(user:, params:).call
- Take dependencies via keyword arguments (no hidden global state).
- Return a Result struct:
Result = Data.define(:success, :value, :error)
def self.success(value) = new(success: true, value:, error: nil)
def self.failure(error) = new(success: false, value: nil, error:)
- Never raise for expected failures (validation, business rule
violation). Raise only for infrastructure errors (DB unreachable).
Controllers handle the result:
case result = Orders::CreateOrder.call(user: current_user, params: order_params)
in { success: true, value: order }
redirect_to order, notice: "Order created"
in { success: false, error: }
flash.now[:alert] = error.message
render :new, status: :unprocessable_entity
end
ActiveRecord callbacks (`before_save`, `after_create`, etc.) are ONLY
for:
- Normalizing fields (downcase email, strip whitespace, generate slug).
- Setting derived columns (counter caches, denormalized totals).
- Touching associated records.
NEVER in callbacks:
- Charging payments / hitting third-party APIs.
- Sending emails / SMS.
- Enqueuing jobs that have side effects.
- Writing audit logs.
- Creating other top-level records.
All of those belong in the service object that initiated the change.
ApplicationController has only cross-cutting concerns: error rescuing
mapped to JSON/HTML responses, authentication, locale setting. NO
business helpers.
Concerns (app/controllers/concerns) extract shared controller behavior
ONLY when 2+ controllers genuinely share the behavior. Premature
concern extraction is worse than duplication.
Inline `respond_to do |format|` blocks are kept to 2 formats max.
Beyond that, split into separate controllers (Api::OrdersController,
OrdersController) so each format stays clear.
Before — fat controller with side effects, callback that charges:
class OrdersController < ApplicationController
before_action :authenticate_user!
before_action :load_cart, only: [:create]
before_action :load_address, only: [:create]
before_action :validate_inventory, only: [:create]
def create
@order = current_user.orders.build(params[:order])
@order.shipping_address = @address
@order.line_items_attributes = @cart.items.map(&:to_h)
if @order.save
Stripe::Charge.create(amount: @order.total_cents, source: params[:token])
OrderMailer.confirmation(@order).deliver_now
AuditLog.create(user: current_user, event: "order_created", payload: @order.attributes)
Slack.notify("New order ##{@order.id}")
redirect_to @order, notice: "Order placed"
else
render :new
end
rescue Stripe::CardError => e
@order.destroy
flash[:alert] = e.message
render :new
end
end
class Order < ApplicationRecord
after_create :send_confirmation
after_create :charge_card
def charge_card
Stripe::Charge.create(amount: total_cents, source: stripe_token)
end
end
Mass assignment, synchronous Stripe call inside the request, mailer delivered inline, callback duplicates the controller's work, rescue resurrects an already-saved order, Slack notification blocks the response.
After — skinny controller, service object, Result, jobs for side effects:
class OrdersController < ApplicationController
before_action :authenticate_user!
def create
authorize Order
result = Orders::PlaceOrder.call(
user: current_user,
params: order_params,
payment_token: params[:stripe_token]
)
case result
in { success: true, value: order }
redirect_to order, notice: "Order placed"
in { success: false, error: }
@order = error.order
flash.now[:alert] = error.message
render :new, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(:shipping_address_id, line_items_attributes: [:product_id, :quantity])
end
end
# app/services/orders/place_order.rb
module Orders
class PlaceOrder
Result = Data.define(:success, :value, :error)
Failure = Data.define(:message, :order)
def self.call(**) = new(**).call
def initialize(user:, params:, payment_token:)
@user = user
@params = params
@payment_token = payment_token
end
def call
order = @user.orders.build(@params)
return Result.new(success: false, value: nil, error: Failure.new(order:, message: order.errors.full_messages.to_sentence)) unless order.valid?
ApplicationRecord.transaction do
order.save!
Billing::ChargeCard.call!(order:, token: @payment_token)
end
OrderMailer.with(order:).confirmation.deliver_later
AuditLogJob.perform_later(user_id: @user.id, event: "order_created", record: order)
SlackNotifyJob.perform_later(channel: "#sales", text: "New order ##{order.id}")
Result.new(success: true, value: order, error: nil)
rescue Billing::CardError => e
Result.new(success: false, value: nil, error: Failure.new(order:, message: e.message))
end
end
end
Controller is 12 lines. Side effects live in jobs (asynchronous, retryable). Stripe call is a service that raises a typed error the wrapping service translates to a Failure. Audit and Slack run after the response is sent. Order is never saved without payment because the transaction rolls back if the charge raises.
Rule 2: ActiveRecord Models — Modern validates, Scopes, Typed enum, No default_scope, No Business Callbacks
ActiveRecord is Rails' most powerful tool and its biggest footgun. Cursor will gladly emit validates_presence_of :email, validates_uniqueness_of :slug, validates_format_of :email, with: URI::MailTo::EMAIL_REGEXP — all of which work, all of which have been deprecated in favor of the unified validates :email, presence: true, uniqueness: true, format: { with: ... } style for over a decade. It will reach for default_scope to "soft delete" models and then spend three days debugging why Post.unscoped.where(...) doesn't behave like Post.with_deleted. It will write before_save :do_thing for anything resembling business logic. The rule constrains the model to data semantics: validations, associations, scopes, enums, normalizations.
The rule:
Validations use the unified `validates` API:
validates :email, presence: true, uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
NEVER `validates_presence_of`, `validates_uniqueness_of`, etc. — those
are legacy. The modern form is one source of truth per attribute.
Custom validators (subclass ActiveModel::EachValidator) when the same
rule appears in 2+ models. One-off conditions inline via `validate
:method_name`.
`normalizes` (Rails 7.1+) for canonicalization:
normalizes :email, with: ->(e) { e.strip.downcase }
NOT a `before_validation` callback that does the same thing.
Associations declare `inverse_of` explicitly when Rails can't infer it
(custom foreign keys, polymorphic, scoped associations). Bidirectional
inverse_of avoids duplicate query loads:
belongs_to :author, class_name: "User", inverse_of: :posts
has_many :posts, inverse_of: :author
`belongs_to` is required by default since Rails 5. Do NOT add
`optional: true` unless the column is genuinely nullable in the schema;
loosening it hides bugs.
`has_many :through` for join models with attributes; `has_and_belongs_to_many`
is reserved for pure many-to-many with no join model — almost never the
right answer in a real app. Default to `has_many :through`.
`enum` for typed states with the new hash form (Rails 7+):
enum :status, { pending: 0, paid: 10, shipped: 20, refunded: 30 },
default: :pending, prefix: true
The `prefix:` keeps generated method names unambiguous
(`order.status_paid?` vs `order.paid?` colliding with another model).
NEVER `default_scope`. It silently mutates every query in the app and
breaks `Model.unscoped` callers in non-obvious ways. Use explicit named
scopes the caller chooses to apply:
scope :active, -> { where(deleted_at: nil) }
scope :recent, -> { order(created_at: :desc) }
Soft-delete pattern: `discard` gem or column + `kept` scope, not
`default_scope`.
Scopes are class methods or named scopes. They return ActiveRecord
relations, never arrays. A scope that returns an array (calls `.to_a`,
`.map`, `.select` with a block) is a query method on the wrong layer —
move to a query object in app/queries/.
Callbacks are reserved for data-shape concerns:
- `before_validation :downcase_email` (use `normalizes` if possible).
- `before_save :update_search_vector` (denormalized column).
- `after_destroy :touch_parent` (cache invalidation).
NEVER for sending mail, charging cards, enqueuing side-effect jobs,
calling third-party APIs. Those go in services (Rule 1).
Concerns in app/models/concerns extract behavior shared by 2+ models
(Slugged, SoftDeletable, AuditLogged). One concern per file. NEVER
extract a concern just to "make the model smaller" — that's a code
smell, not a refactor.
`attribute` declarations for non-column virtual attributes (Rails
typed attributes API):
attribute :unconfirmed_email, :string
attribute :referral_count, :integer, default: 0
Counter caches via `counter_cache: true` on the belongs_to side, with
the matching column on the parent. NOT hand-rolled `after_create
:increment_counter`.
`touch: true` on belongs_to to bump parent's `updated_at` for cache
invalidation, not a custom `after_save`.
ActiveRecord transaction blocks (`ApplicationRecord.transaction do
... end`) for multi-record atomic operations. NESTED transactions need
`requires_new: true` to actually nest (otherwise they are no-ops in
the inner block).
Composite primary keys (Rails 7.1+) when the schema needs them; do not
add a redundant `id` column to a junction table.
Before — legacy validators, default_scope, callback-as-logic:
class User < ApplicationRecord
default_scope { where(deleted_at: nil) }
validates_presence_of :email
validates_uniqueness_of :email
validates_format_of :email, with: /\A[^@]+@[^@]+\z/
has_many :posts
before_validation :downcase_email
after_create :send_welcome_email
after_create :enqueue_referral_credit
after_create :notify_slack
STATUSES = %w[pending active suspended].freeze
def downcase_email
self.email = email.downcase if email
end
def send_welcome_email
UserMailer.welcome(self).deliver_now
end
def enqueue_referral_credit
Referral.where(referred_email: email).each(&:apply_credit!)
end
def notify_slack
Slack.post("New user #{email}")
end
def self.active
where(status: "active")
end
end
Legacy validators, default_scope hides deleted users from every query forever, three callbacks fire from every test factory, status is a string with no type safety, mail delivered inline.
After — unified validates, normalizes, enum, scopes, no business callbacks:
class User < ApplicationRecord
has_many :posts, inverse_of: :author, dependent: :restrict_with_error
normalizes :email, with: ->(e) { e.strip.downcase }
validates :email,
presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
enum :status, { pending: 0, active: 10, suspended: 20 }, default: :pending, prefix: true
scope :kept, -> { where(deleted_at: nil) }
scope :recent, -> { order(created_at: :desc) }
scope :search, ->(q) { where("email ILIKE ?", "%#{sanitize_sql_like(q)}%") }
attribute :referral_token, :string
end
Service Users::Register calls User.create!(...), then enqueues WelcomeEmailJob, ApplyReferralCreditJob, SlackNotifyJob. Tests construct users via factories without firing side effects. Soft-delete is opt-in via User.kept. Status is a typed enum with prefixed predicate methods (user.status_active?).
Rule 3: N+1 Prevention And Query Discipline — includes / preload / eager_load, pluck, find_each, No String Interpolation
Every Rails app eventually has an N+1 in a view. Cursor will write @posts = Post.all in the controller and <%= post.author.name %> in the view, and never think about includes(:author). It will write User.where("created_at > '#{params[:date]}'") because string interpolation in where is concise — and now you have SQL injection. It will iterate User.all.each over a 50 million row table and OOM the box. The rule sets the discipline: every association used in a view is eager-loaded with the right method, large iterations use find_each, ID-only queries use pluck, all parameterized queries use the ? / hash form, never string interpolation.
The rule:
Every controller action that loads records and renders associations
calls `includes(...)` (or `preload`/`eager_load` when specifically
needed). The bullet gem is in the development Gemfile group:
gem "bullet", group: :development
Bullet.alert = true; Bullet.bullet_logger = true; Bullet.console = true
N+1 detected in dev = bug = fix before commit.
Pick the right loader:
- `includes(:assoc)` — Rails picks preload OR eager_load; defaults
to preload (separate query). Preferred for most cases.
- `preload(:assoc)` — always two queries (one for parent, one for
child via WHERE IN).
- `eager_load(:assoc)` — single LEFT OUTER JOIN. Required when you
need to filter / order by the association in the same query.
- `joins(:assoc)` — INNER JOIN, no eager loading; for filtering when
you don't need the data.
`includes(:author).references(:authors)` when filtering by an included
association in a where clause; otherwise Rails may issue an unwanted
LEFT OUTER JOIN at execution time.
Nested eager loads:
Post.includes(comments: :author, tags: [])
The empty array form preempts auto-eager-load on a tag-less post.
`select` to limit columns when you only render a few:
User.select(:id, :email, :created_at)
Especially important on tables with `text` / `jsonb` blobs.
`pluck` for ID lists or single-column extractions:
user_ids = User.where(active: true).pluck(:id)
NOT `User.where(active: true).map(&:id)` — that loads full models then
discards them.
`find_each` / `find_in_batches` / `in_batches` for any iteration over
> 1,000 rows:
User.find_each(batch_size: 500) do |user|
# ...
end
NEVER `User.all.each` over a large table — loads the full result set
into memory.
`exists?` over `present?` / `any?` for existence checks:
User.where(email:).exists? # SELECT 1 ... LIMIT 1
NOT `User.where(email:).any?` (loads records then checks) or
`.count > 0` (full COUNT scan).
Every `where` uses parameterized form:
User.where("email ILIKE ?", "%#{sanitize_sql_like(q)}%")
User.where(email: emails)
User.where(created_at: start..end)
NEVER string interpolation:
User.where("email = '#{params[:email]}'") # SQL INJECTION
`order` with a column reference, never a string of unsanitized input:
Post.order(column => direction)
# where column/direction came from a whitelist hash, NOT params.
Counter-cache columns over `count` queries that hit the DB on every
view render. `posts_count` on User, with `counter_cache: true` on
Post.belongs_to.
Aggregates use SQL, not Ruby:
Order.group(:status).sum(:total_cents)
NOT `Order.all.group_by(&:status).transform_values { |os| os.sum(&:total_cents) }`.
EXPLAIN every query that touches a hot path. Add indexes for any
WHERE / ORDER BY column that hits production traffic. Composite indexes
ordered most-selective column first.
`distinct` after a JOIN that may duplicate parent rows. Use `merge` to
combine scopes from another model.
JSON serialization uses Jbuilder or `as_json(only: [...], include: ...)`
with explicit attribute lists. NEVER `to_json` on a full model with
secrets in the schema.
Pagination via Pagy (preferred) or Kaminari. Never `Model.limit(20)
.offset(params[:page] * 20)` hand-rolled.
Before — N+1, string interpolation, all.each:
class PostsController < ApplicationController
def index
@posts = Post.where("created_at > '#{params[:since]}'").order("created_at desc")
end
end
<% @posts.each do |post| %>
<h2><%= post.author.name %></h2>
<p><%= post.comments.count %> comments</p>
<% post.tags.each do |tag| %>
<span><%= tag.name %></span>
<% end %>
<% end %>
SQL injection via params[:since]. N+1 on author (one query per post). N+1 on tags. Comment count hits DB per post with no counter cache.
After — eager load, parameterized, counter cache, pagy:
class PostsController < ApplicationController
def index
posts = Post
.where(created_at: parsed_since..)
.includes(:author, :tags)
.order(created_at: :desc)
@pagy, @posts = pagy(posts, items: 20)
end
private
def parsed_since
Time.iso8601(params[:since]) rescue 24.hours.ago
end
end
<% @posts.each do |post| %>
<h2><%= post.author.name %></h2>
<p><%= post.comments_count %> comments</p>
<% post.tags.each do |tag| %>
<span><%= tag.name %></span>
<% end %>
<% end %>
<%== pagy_nav(@pagy) %>
Two queries total (posts + authors + tags). Comment count served from comments_count counter cache column. Pagination wraps the relation. Input is parsed safely.
Rule 4: Strong Parameters And Form Objects — Per-Action Param Shape, Multi-Model Forms In app/forms/*
Mass assignment was the original Rails security disaster (the Github commit-as-anyone bug, 2012). Strong Parameters was the fix, and it works — when you use it precisely. Cursor will write params.require(:user).permit! because it's shorter, or worse, params.require(:user).permit(:email, :name, :role) exposing role to public form posts. It will build single-action params hashes that get reused across create and update even when the two have different valid attribute sets. And when the form spans multiple models it will reach for accepts_nested_attributes_for, which works but gets ugly fast — better to extract a Form Object that owns the validation, the persistence, and the error surface for the whole form.
The rule:
Strong params live in a private method per controller action OR per
resource if create/update truly accept the same shape:
def create
@user = User.new(user_params(:create))
end
def update
@user.update(user_params(:update))
end
private
def user_params(action)
permitted = case action
when :create then [:email, :password, :name]
when :update then [:name, :avatar]
end
params.require(:user).permit(*permitted)
end
NEVER `params.require(:x).permit!` — that's mass assignment by
another name. The whitelist is the security boundary.
NEVER include attributes a normal user shouldn't be able to set
(`role`, `admin`, `email_confirmed`, `stripe_customer_id`) in a
publicly-reachable controller's permit list. Admin updates go through
a separate Admin::UsersController with its own params method.
Nested attributes (accepts_nested_attributes_for) are acceptable for
SIMPLE one-level cases (an Order with line_items_attributes). When the
form has 2+ levels, conditional fields, or cross-model validations,
extract a Form Object.
Form Objects:
- Live in app/forms/<context>/<form_name>.rb
(e.g. app/forms/users/registration_form.rb).
- Include ActiveModel::Model (or Attributes for typed attrs).
- Declare attributes with types via `attribute`.
- Validate at the form level (not the underlying models).
- Expose `save` that wraps the multi-model persistence in a
transaction.
- Surface errors via `errors` (ActiveModel-compatible so form helpers
work).
Example:
class Users::RegistrationForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :name, :string
attribute :password, :string
attribute :company, :string
attribute :accept_terms, :boolean, default: false
validates :email, :name, :password, presence: true
validates :accept_terms, acceptance: true
def save
return false unless valid?
ApplicationRecord.transaction do
@user = User.create!(email:, name:, password:)
@company = Company.create!(name: company, owner: @user)
end
true
end
attr_reader :user, :company
end
Controllers use form objects identically to ActiveRecord:
@form = Users::RegistrationForm.new(form_params)
if @form.save
sign_in @form.user
redirect_to dashboard_path
else
render :new, status: :unprocessable_entity
end
Views render form errors via `form.object.errors` — works because
ActiveModel::Model adds the API.
For API endpoints, use a serializer (Jbuilder, alba, or oj-based).
NEVER `render json: @user` without an explicit attribute list.
JSON request bodies parsed by Rails into params automatically. Keys
are still subject to strong params. Validate request shape with the
same `params.require(...).permit(...)` chain.
`params.expect(...)` (Rails 8 shortcut) for the common pattern of
`require(:user).permit(:email, :name)`:
params.expect(user: [:email, :name])
Returns nil-safely if structure wrong; preferred over the older form.
Before — permit!, mass-assignment of role, nested attrs everywhere:
class UsersController < ApplicationController
def create
@user = User.new(params.require(:user).permit!)
if @user.save
sign_in @user
redirect_to root_path
else
render :new
end
end
def update
@user = User.find(params[:id])
@user.update(params[:user]) # raw mass assignment
redirect_to @user
end
end
Anyone can post user[role]=admin and become an admin. Update bypasses strong params entirely. No transaction, no error rendering on update.
After — per-action permit, form object for multi-model, params.expect:
class RegistrationsController < ApplicationController
def new
@form = Users::RegistrationForm.new
end
def create
@form = Users::RegistrationForm.new(registration_params)
if @form.save
sign_in @form.user
redirect_to dashboard_path, notice: "Welcome"
else
render :new, status: :unprocessable_entity
end
end
private
def registration_params
params.expect(registration_form: [:email, :name, :password, :company, :accept_terms])
end
end
class Admin::UsersController < AdminController
def update
@user = User.find(params[:id])
if @user.update(admin_user_params)
redirect_to admin_user_path(@user), notice: "Updated"
else
render :edit, status: :unprocessable_entity
end
end
private
def admin_user_params
params.expect(user: [:email, :name, :role, :status])
end
end
Public form goes through a Form Object with explicit fields. role exists only on the Admin controller's whitelist. params.expect is structure-validating. Transaction wraps the multi-record persistence inside the form.
Rule 5: ActiveJob With Solid Queue / Sidekiq — Idempotent, retry_on / discard_on, Always perform_later, Never Synchronous Side Effects In Controllers
Rails 8 ships Solid Queue as the default backend (DB-backed, no Redis required for new apps). Sidekiq is still the production choice for high-throughput apps. Either way, ActiveJob is the API: every side effect that isn't strictly part of the request goes through it. Cursor's defaults are bad here — it will call Mailer.deliver_now from a controller (blocking the response), define jobs with no retry policy (one transient failure = silent loss), and write jobs that aren't idempotent (retry = duplicate Stripe charge). The rule is strict: emails, SMS, third-party API calls, audit logs, push notifications, slack pings — all perform_later, all idempotent, all with explicit retry/discard rules.
The rule:
Every job inherits from ApplicationJob. ApplicationJob declares
defaults:
class ApplicationJob < ActiveJob::Base
retry_on ActiveRecord::Deadlocked, wait: :polynomially_longer, attempts: 5
retry_on Net::OpenTimeout, Net::ReadTimeout, wait: 5.seconds, attempts: 3
discard_on ActiveJob::DeserializationError
end
Per-job overrides:
class ChargeCardJob < ApplicationJob
queue_as :payments
retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 4
discard_on Stripe::CardError # don't retry a declined card
end
Every job's `perform` is IDEMPOTENT. It can run twice for the same
input without double-charging, double-emailing, double-anything:
- Check for an existing Stripe charge by idempotency key BEFORE
creating a new one (`Stripe::Charge.create({...}, {idempotency_key: ...})`).
- SELECT-then-INSERT inside a transaction with a unique constraint
on the natural key.
- Use `find_or_create_by!` or `upsert_all` for record creation.
- Mail jobs check a sent_at flag or a separate Notification record
before delivering.
Pass IDs, not records:
ChargeCardJob.perform_later(order_id: order.id)
NOT `ChargeCardJob.perform_later(order)` — serializing/deserializing
the full record across runs is fragile (record mutated between enqueue
and run, GlobalID lookup fails if record deleted).
Inside the job, refetch:
def perform(order_id:)
order = Order.find(order_id)
# ...
end
Queue priority lanes:
- `default` — most jobs.
- `mailers` — outgoing email (separate so a backed-up mail queue
doesn't block business logic).
- `payments` — billing-related, highest priority + dedicated worker
pool.
- `low` — analytics, audit logs, search reindex.
Configure worker concurrency per lane (Solid Queue config/queue.yml,
Sidekiq sidekiq.yml).
Mailers ALWAYS via `deliver_later`:
OrderMailer.with(order:).confirmation.deliver_later
NEVER `deliver_now` from a controller.
`deliver_now` is acceptable in:
- Rake tasks where you want synchronous send.
- Tests that explicitly assert delivery behavior.
- Cron jobs where the cron IS the queue.
Recurring jobs: Solid Queue `config/recurring.yml` (Rails 8) declares
schedules. NOT a custom `Clockwork`/`whenever` cron config layered on
top.
Solid Queue worker process:
bin/jobs # in development
In production: separate process via Procfile / Kamal service.
Sidekiq Pro / Enterprise patterns when on Sidekiq:
- `unique_for: 1.hour` (sidekiq-unique-jobs) on jobs that should not
queue duplicates.
- `sidekiq_throttle` for rate-limited third-party APIs.
- Batches for fan-out + collect patterns.
Job arguments are JSON-serializable: primitives, hashes/arrays of
primitives, ActiveRecord objects via GlobalID (but prefer IDs). NEVER
pass procs, large payloads, or objects with non-trivial serialization.
Long-running jobs (> 30s) split into smaller jobs that enqueue the
next chunk. Workers should not block on a single piece of work for
minutes.
Errors raised inside a job that aren't matched by retry_on/discard_on
go to ActiveJob's default handling — Solid Queue persists the failure;
Sidekiq retries with exponential backoff up to 25 attempts. Configure
a dead job handler that ships to Sentry / Honeybadger.
Background job UI: Mission Control – Jobs (Rails 8 default for Solid
Queue) at /jobs, mounted in routes.rb behind authentication.
Before — synchronous mail, no retry, non-idempotent charge:
class OrdersController < ApplicationController
def create
@order = current_user.orders.create!(order_params)
Stripe::Charge.create(amount: @order.total_cents, source: params[:token])
OrderMailer.confirmation(@order).deliver_now
redirect_to @order
end
end
class WelcomeEmailJob < ApplicationJob
def perform(user)
UserMailer.welcome(user).deliver_now
end
end
Stripe call blocks the response. No retry, no idempotency key — a network blip = duplicate charge. Mailer blocks the response. Job takes a record (GlobalID hazards), no retry config, no idempotency.
After — async charge with idempotency key, perform_later mail, retry:
class ChargeCardJob < ApplicationJob
queue_as :payments
retry_on Stripe::APIConnectionError, Net::OpenTimeout, wait: :polynomially_longer, attempts: 4
discard_on Stripe::CardError, Stripe::InvalidRequestError
def perform(order_id:)
order = Order.find(order_id)
return if order.charged?
charge = Stripe::Charge.create(
{ amount: order.total_cents, currency: "usd", source: order.stripe_token },
{ idempotency_key: "order-#{order.id}-charge" }
)
order.update!(stripe_charge_id: charge.id, charged_at: Time.current)
OrderConfirmationMailer.with(order:).receipt.deliver_later
end
end
class OrdersController < ApplicationController
def create
result = Orders::PlaceOrder.call(user: current_user, params: order_params, payment_token: params[:stripe_token])
case result
in { success: true, value: order }
ChargeCardJob.perform_later(order_id: order.id)
redirect_to order, notice: "Order placed; processing payment"
in { success: false, error: }
flash.now[:alert] = error.message
render :new, status: :unprocessable_entity
end
end
end
Controller returns immediately. Charge job is idempotent (returns early if already charged), uses a Stripe idempotency key, retries on transient network errors, discards on hard card errors. Mail is enqueued from inside the charge job after success.
Rule 6: Hotwire Over JavaScript SPAs — Turbo Frames, Turbo Streams, Stimulus For Sprinkles, Importmap
Rails 7 made Hotwire the default frontend stack and Rails 8 doubles down. Cursor's training data still skews toward 2018-era jQuery sprinkled across app/assets/javascripts, full Vue/React SPAs that double the build complexity, and format.js { render js: "..." } returning raw JavaScript strings (an XSS magnet). The modern stack: Turbo Drive intercepts navigation; Turbo Frames scope partial updates; Turbo Streams broadcast or respond with surgical DOM operations; Stimulus controllers add behavior to specific elements; importmap-rails serves ES modules without a Node build for most apps. JavaScript bundling (jsbundling-rails / esbuild) is reserved for apps that genuinely need a bundler.
The rule:
Default frontend stack: Turbo + Stimulus + importmap-rails. No
jsbundling-rails / esbuild / Webpack unless the app has a specific
need (TypeScript, JSX, large vendored dep that doesn't ship as ESM).
NO jQuery. Stimulus replaces it for DOM behavior. No `format.js`
templates returning raw JS. Use Turbo Streams (`format.turbo_stream`)
instead.
Turbo Drive is on by default. Per-link / per-form opt-out:
link_to "Download", url, data: { turbo: false }
For full-page reloads (legacy pages, third-party form widgets).
Turbo Frames scope partial updates:
<%= turbo_frame_tag "comments" do %>
<%= render @comments %>
<%= link_to "New comment", new_comment_path %>
<% end %>
A link clicked inside the frame loads the response's matching frame
into place. The rest of the page is untouched.
Turbo Streams from controller actions for surgical updates:
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.append("messages", partial: "messages/message", locals: { message: @message }) }
format.html { redirect_to messages_path }
end
Common Turbo Stream actions: append, prepend, replace, update,
remove, before, after. Each targets a DOM ID.
Server-broadcast Turbo Streams via Action Cable / Solid Cable:
class Message < ApplicationRecord
broadcasts_to ->(message) { [message.room, "messages"] }
end
The model auto-broadcasts to a stream every subscriber listens on.
View subscribes via:
<%= turbo_stream_from @room, "messages" %>
Stimulus controllers in app/javascript/controllers/<name>_controller.js:
- One controller per concern (dropdown, autocomplete, modal).
- Targets declared, never querySelector.
- Actions declared, never addEventListener.
- Values typed, never raw data attributes parsed manually.
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
static values = { open: Boolean }
toggle() { this.openValue = !this.openValue }
openValueChanged() { this.menuTarget.classList.toggle("hidden", !this.openValue) }
}
ViewComponent for component-style view extraction. Renders faster
than partials, has its own test class, accepts typed attrs:
class ButtonComponent < ViewComponent::Base
def initialize(label:, variant: :primary)
@label, @variant = label, variant
end
end
Phlex as an alternative when teams prefer Ruby DSL over ERB.
Forms: `form_with` (the modern helper), Turbo-enabled by default. On
validation failure, render :new with status: :unprocessable_entity —
Turbo replaces the form with the error-decorated version automatically.
Importmap pinning in config/importmap.rb:
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin_all_from "app/javascript/controllers", under: "controllers"
CSS: Propshaft (Rails 8 default) serves CSS. Tailwind via
tailwindcss-rails (preferred) or cssbundling-rails. NO Sprockets in
new Rails 8 apps.
Avoid:
- render_to_string in controllers (use partial rendering).
- `<%= raw user_input %>` (XSS — use `sanitize` with whitelist).
- `<%= render "shared/menu", user: current_user %>` shotgunned in
every layout (one place, app/views/layouts).
- `field_with_errors` div wrapping that breaks CSS layouts —
config.action_view.field_error_proc to a no-wrap variant.
Real-time updates without Action Cable when polling suffices:
<%= turbo_frame_tag "status", src: status_order_path(@order),
loading: :lazy,
refresh: "morph",
data: { turbo_refresh: "5s" } %>
Server returns the updated frame; Turbo morphs it in place.
Before — jQuery, format.js, full SPA temptation:
<div id="comments">
<%= render @comments %>
</div>
<%= form_with url: comments_path, remote: true do |f| %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
<script>
$(document).on("ajax:success", "form", function(e) {
var data = e.detail[0];
$("#comments").append(data);
$("form")[0].reset();
});
</script>
def create
@comment = @post.comments.create!(comment_params)
respond_to do |format|
format.js { render js: "$('#comments').append('#{render_to_string(@comment)}')" }
end
end
jQuery, manual ajax wiring, raw JS string with interpolation (XSS), no progressive enhancement, no real-time broadcast.
After — Turbo Frame, Turbo Stream, broadcast, Stimulus reset:
class Post < ApplicationRecord
has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
belongs_to :post
broadcasts_to ->(c) { [c.post, "comments"] }, inserts_by: :append
end
<%= turbo_stream_from @post, "comments" %>
<%= turbo_frame_tag "comments" do %>
<%= render @post.comments %>
<% end %>
<%= turbo_frame_tag "new_comment" do %>
<%= form_with model: [@post, Comment.new], data: { controller: "reset-form", action: "turbo:submit-end->reset-form#reset" } do |f| %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
<% end %>
class CommentsController < ApplicationController
def create
@comment = @post.comments.create!(comment_params)
respond_to do |format|
format.turbo_stream # auto-renders create.turbo_stream.erb
format.html { redirect_to @post }
end
end
end
// app/javascript/controllers/reset_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
reset(event) { if (event.detail.success) this.element.reset() }
}
Comment broadcasts to every subscriber via Solid Cable. Form posts via Turbo, resets via Stimulus only on success. No jQuery, no XSS surface, real-time across all open tabs.
Rule 7: Authorization — Pundit Policies For Every Resource, authorize In Controllers, policy_scope For Index Queries
Rails has authentication built in (Rails 8 generators ship a session-based Authentication concern). Authorization is your job. Cursor will scatter if current_user.admin? through views, controllers, and helpers; it will rely on current_user.posts.find(params[:id]) to "authorize" by scoping (which fails the moment a polymorphic relationship breaks the assumption); it will write a controller that returns 200 with empty data instead of 403 when permission is denied. The Pundit pattern is unambiguous: every resource has a Policy class; every controller action calls authorize (which raises if denied); every index query calls policy_scope (which returns the authorized subset). current_user is a controller-and-mailer concept — not a view helper accessed via <% if current_user.admin? %>.
The rule:
Pundit is the default authorization library (gem "pundit"). One
policy per resource at app/policies/<resource>_policy.rb.
Every controller action calls `authorize`:
def show
@post = Post.find(params[:id])
authorize @post
end
def create
@post = current_user.posts.build(post_params)
authorize @post # checks PostPolicy#create?
@post.save
redirect_to @post
end
`authorize` raises Pundit::NotAuthorizedError when denied. Rescue once
in ApplicationController:
rescue_from Pundit::NotAuthorizedError, with: :forbidden
def forbidden
respond_to do |format|
format.html { redirect_to root_path, alert: "Not authorized" }
format.json { head :forbidden }
end
end
`verify_authorized` and `verify_policy_scoped` after_action filters
in ApplicationController catch missed authorize calls in test/dev:
after_action :verify_authorized, except: [:index]
after_action :verify_policy_scoped, only: [:index]
Index actions use `policy_scope`:
def index
@posts = policy_scope(Post).includes(:author).page(params[:page])
end
PostPolicy::Scope returns the relation the current user is allowed
to see:
class PostPolicy < ApplicationPolicy
class Scope < ApplicationPolicy::Scope
def resolve
return scope.all if user.admin?
scope.where(author: user).or(scope.where(published: true))
end
end
def show? = record.published? || record.author == user || user.admin?
def create? = user.present?
def update? = record.author == user || user.admin?
def destroy? = update?
end
Permitted attributes via `permitted_attributes`:
class PostPolicy < ApplicationPolicy
def permitted_attributes
base = [:title, :body]
base << :featured if user.admin?
base
end
end
# in controller:
@post.update(permitted_attributes(@post))
NEVER `current_user.admin?` checks in views. Instead:
<% if policy(@post).update? %>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
NEVER scope-by-relationship as the ONLY auth check:
current_user.posts.find(params[:id]) # works, but...
# if @post is a Comment polymorphic across Post/User, scoping breaks.
Pair it with `authorize`:
@comment = Comment.find(params[:id])
authorize @comment
Multi-tenant apps: tenant resolution in middleware / a current_tenant
concern. EVERY query in the request is tenant-scoped via
ActsAsTenant or row-level security. Pundit policies layer on top.
Roles: Rolify gem for many-to-many user-role mappings; OR a simple
enum on User for fixed role lists. Whichever is used, ALL policy
checks go through Pundit, never direct `user.has_role?(...)`
sprinkled in views.
API tokens: a separate Authentication concern verifies a Bearer
token; sets `current_user` for the request; policies behave the same
in API and HTML controllers.
Audit log: app/services/auditing.rb writes immutable records of who
did what. Every authorize? call that returns true on a sensitive
action (delete, refund, role change) calls Auditing.record(...) from
the service object, NOT from the policy.
Authentication itself: Rails 8 `bin/rails generate authentication`
or Devise (mature, more features). Not hand-rolled.
Before — admin checks in views, no central policy, scoping by relationship only:
class PostsController < ApplicationController
def show
@post = Post.find(params[:id])
if @post.author != current_user && !@post.published? && !current_user&.admin?
redirect_to root_path, alert: "Not authorized" and return
end
end
def update
@post = Post.find(params[:id])
if @post.author != current_user && !current_user.admin?
head :forbidden and return
end
@post.update(params.require(:post).permit(:title, :body, :featured))
redirect_to @post
end
end
<% if current_user&.admin? || @post.author == current_user %>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
Authorization logic duplicated in every action and view. :featured is permittable by anyone. Easy to forget on a new action.
After — Pundit policy, authorize, policy_scope, permitted_attributes:
class PostsController < ApplicationController
def index
@posts = policy_scope(Post).includes(:author).page(params[:page])
end
def show
@post = Post.find(params[:id])
authorize @post
end
def update
@post = Post.find(params[:id])
authorize @post
@post.update(permitted_attributes(@post))
redirect_to @post
end
end
class PostPolicy < ApplicationPolicy
class Scope < ApplicationPolicy::Scope
def resolve
return scope.all if user&.admin?
base = scope.where(published: true)
user ? base.or(scope.where(author: user)) : base
end
end
def show? = record.published? || record.author == user || user&.admin?
def update? = record.author == user || user&.admin?
def destroy? = update?
def permitted_attributes
base = [:title, :body]
base << :featured if user&.admin?
base
end
end
<% if policy(@post).update? %>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
Authorization logic in ONE place. Index returns the right subset. Featured flag protected from public posts. Views ask the policy.
Rule 8: Testing — RSpec, FactoryBot, System Specs With Cuprite, Request Specs Over Controller Specs, VCR For HTTP
Rails 8 ships Minitest by default; RSpec remains the dominant choice in production codebases for its fixture-free factories, expressive matchers, and structured describe/context/it organization. Cursor's defaults are dated: it generates controller specs (deprecated since Rails 5), uses fixtures (the test/fixtures/*.yml files that share state across every test), reaches for Selenium WebDriver (slow, flaky, requires Chromedriver pinning), and writes let! for everything (defeating lazy evaluation). The modern stack: RSpec for specs; FactoryBot for test data; system specs with Capybara + Cuprite (Chrome DevTools Protocol, no Selenium); request specs for HTTP-level integration; VCR or webmock for third-party HTTP; RuboCop + standardrb for style.
The rule:
RSpec is the test framework. Spec files at spec/<type>/<name>_spec.rb.
Per layer:
spec/models/ — model unit specs (validations, scopes, callbacks).
spec/services/ — service object specs (call → result assertions).
spec/jobs/ — job specs (perform with stubbed externals).
spec/policies/ — Pundit policy specs (one matcher per action).
spec/requests/ — controller-level integration via real HTTP.
spec/system/ — end-to-end browser specs via Cuprite.
spec/components/ — ViewComponent specs.
spec/forms/ — Form Object specs.
NEVER write controller specs (`spec/controllers/`). Rails 5
deprecated their utility; request specs cover the same surface with
better integration semantics.
FactoryBot factories at spec/factories/<plural>.rb:
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
name { "Test User" }
password { "secret123" }
end
factory :post do
association :author, factory: :user
title { "Hello" }
body { "World" }
published { false }
trait :published do
published { true }
published_at { 1.day.ago }
end
end
end
NO fixtures (test/fixtures/*.yml). Factories are explicit per-test
data; fixtures are shared global state that breaks tests on edits.
`let` is lazy; `let!` is eager. Default to `let`. `let!` only when
the side-effect of setup is what's being tested:
let!(:other_post) { create(:post, :published) }
it "doesn't include other users' drafts" do
sign_in user
get posts_path
expect(response.body).to include(other_post.title)
end
`subject` for the system under test, named when there's more than one
implicit subject:
subject(:service) { described_class.new(user:, params:) }
System specs config (spec/rails_helper.rb):
Capybara.register_driver(:cuprite) do |app|
Capybara::Cuprite::Driver.new(app, window_size: [1400, 900], js_errors: true, headless: true)
end
Capybara.javascript_driver = :cuprite
RSpec.configure do |config|
config.before(:each, type: :system) { driven_by :cuprite }
end
Cuprite advantages: faster than Selenium, no Chromedriver pinning,
real Chrome via DevTools Protocol, easy debugging via headless: false.
System specs:
- User-flow level. ONE flow per file.
- Use semantic Capybara matchers (`have_button`, `have_link`,
`have_field`).
- Avoid `find('.css-class')` — brittle. Prefer accessible queries.
- Wait for content (`expect(page).to have_content('Saved')`), don't
sleep.
- Screenshots on failure: capybara-screenshot-diff for visual
regression.
Request specs:
describe "POST /posts" do
it "creates a post when authorized" do
sign_in user
post posts_path, params: { post: { title: "Hi", body: "yo" } }
expect(response).to have_http_status(:found)
expect(Post.last.title).to eq("Hi")
end
end
External HTTP:
- VCR for recording real responses (one cassette per spec; cassettes
in fixtures/vcr_cassettes/).
- WebMock for explicit stub-and-assert when the request shape is
what you're testing.
- NEVER hit a real third-party API in CI.
ActiveJob testing:
config.active_job.queue_adapter = :test
expect { ChargeCardJob.perform_later(order_id: o.id) }
.to have_enqueued_job(ChargeCardJob).with(order_id: o.id).on_queue("payments")
Mailer testing: ActionMailer::TestCase patterns; ActionMailer::Base
.deliveries cleared between specs; assert delivery via
`ActionMailer::Base.deliveries.last`.
Time control: `freeze_time { ... }` (ActiveSupport::Testing::TimeHelpers,
included by default in Rails 5.2+). NEVER `Timecop` in new code.
Database cleaning: transactional fixtures by default. For specs that
spin up multiple connections (system specs that run JS) use
`DatabaseCleaner` with `:truncation` strategy on those examples.
Coverage:
- Models, services, policies: aim 100% line, ~90% branch.
- Controllers (request specs): every action with happy + error path.
- System specs: critical user flows (signup, checkout, primary CRUD).
- Don't chase coverage on view templates.
Linting: RuboCop with rubocop-rails + rubocop-rspec OR Standard.
Pre-commit hook + CI step. Style violations block merge.
CI: every PR runs `bundle exec rspec` + `bundle exec rubocop` +
`bin/brakeman` (security scanner) in parallel.
Before — controller spec, fixtures, Selenium, sleep, jest pattern:
# test/controllers/posts_controller_test.rb
require "test_helper"
class PostsControllerTest < ActionDispatch::IntegrationTest
fixtures :users, :posts
test "creates a post" do
post posts_url, params: { post: { title: "X", body: "Y" } }
assert_redirected_to post_url(Post.last)
end
end
# spec/system/orders_spec.rb
RSpec.describe "Orders" do
before(:each) { Capybara.javascript_driver = :selenium_chrome_headless }
it "creates" do
visit "/orders/new"
fill_in "Address", with: "123"
click_on "Submit"
sleep 2
expect(page).to have_text("created")
end
end
Fixtures shared across tests, no auth check, Selenium pinning, sleep instead of wait, text instead of content.
After — RSpec, FactoryBot, Cuprite, request + system + service specs:
# spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
name { "Test User" }
password { "secret123" }
end
end
# spec/services/orders/place_order_spec.rb
require "rails_helper"
RSpec.describe Orders::PlaceOrder do
subject(:service) { described_class.new(user:, params:, payment_token: "tok_visa") }
let(:user) { create(:user) }
let(:params) { { shipping_address_id: create(:address, user:).id, line_items_attributes: [{ product_id: create(:product).id, quantity: 1 }] } }
context "with valid input and successful charge" do
before { allow(Billing::ChargeCard).to receive(:call!).and_return(true) }
it "creates the order, enqueues mail, returns success" do
result = nil
expect { result = service.call }.to change(Order, :count).by(1)
.and have_enqueued_mail(OrderMailer, :confirmation)
expect(result.success).to be true
expect(result.value).to be_persisted
end
end
context "when card is declined" do
before { allow(Billing::ChargeCard).to receive(:call!).and_raise(Billing::CardError, "declined") }
it "rolls back and returns failure with the message" do
expect { service.call }.not_to change(Order, :count)
result = service.call
expect(result.success).to be false
expect(result.error.message).to eq("declined")
end
end
end
# spec/requests/orders_spec.rb
require "rails_helper"
RSpec.describe "Orders" do
describe "POST /orders" do
let(:user) { create(:user) }
it "redirects to the order on success" do
sign_in user
post orders_path, params: { order: { shipping_address_id: create(:address, user:).id, line_items_attributes: [{ product_id: create(:product).id, quantity: 1 }] }, stripe_token: "tok_visa" }
expect(response).to have_http_status(:found)
expect(response).to redirect_to(Order.last)
end
it "renders new with errors on validation failure" do
sign_in user
post orders_path, params: { order: { shipping_address_id: nil, line_items_attributes: [] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include("Shipping address")
end
end
end
# spec/system/checkout_spec.rb
require "rails_helper"
RSpec.describe "Checkout", type: :system do
let(:user) { create(:user) }
let!(:product) { create(:product, name: "Widget", price_cents: 1000) }
before do
sign_in user
visit new_order_path
end
it "completes a successful checkout" do
select "Default address", from: "Shipping address"
select "Widget", from: "Products"
click_on "Place order"
expect(page).to have_content("Order placed")
expect(page).to have_current_path(%r{/orders/\d+})
end
end
Service spec covers happy and failure paths. Request spec exercises the controller surface end-to-end. System spec walks the user flow in a real browser via Cuprite. Factories build precisely the data each test needs. Mail is asserted via have_enqueued_mail, jobs via have_enqueued_job.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# Ruby on Rails 8 — Production Patterns
## Controllers & services
- Controllers do four things: authenticate, authorize (Pundit), permit
params, invoke a service.
- Action body ~10 lines max; longer = extract a service.
- Services in app/services/<context>/<verb_noun>.rb; PORO with `call`.
- Services return Result struct: success/value or success:false/error.
- Pattern-match the result in the controller.
- ApplicationController: only error rescuing, auth, locale.
- Concerns extracted only when 2+ controllers genuinely share behavior.
- ActiveRecord callbacks ONLY for normalization / derived columns /
touching parents. Never for mail, payments, jobs, audits.
## Models
- `validates :attr, presence: true, ...` — never `validates_presence_of`.
- `normalizes :email, with: ->(e) { e.strip.downcase }` for canonicalization.
- `enum :status, { ... }, default:, prefix:` for typed states.
- Associations declare `inverse_of` when needed; counter_cache via
belongs_to option, not callbacks.
- NEVER `default_scope`. Use named scopes the caller opts into.
- Scopes return relations, not arrays. Array-returning queries → query
objects in app/queries/.
- Concerns in app/models/concerns only when 2+ models share behavior.
## Queries
- Every association used in a view → `includes(...)`.
- bullet gem in development; N+1 = bug.
- `includes` default; `preload` to force two queries; `eager_load` for
filter-by-association; `joins` for filtering without loading.
- `select` to limit columns; `pluck` for ID lists; `find_each` for
> 1000 row iterations.
- `exists?` over `any?` / `count > 0` for existence checks.
- Parameterized where: `User.where("col ILIKE ?", "%#{sanitize_sql_like(q)}%")`,
never string interpolation.
- Aggregates in SQL (group/sum), not Ruby.
- Pagy for pagination.
## Strong params & forms
- `params.expect(resource: [:a, :b])` (Rails 8) over `permit(...)`.
- NEVER `permit!`. Per-action whitelists.
- Admin attributes (role, status) only on Admin::*Controller params.
- Multi-model forms via Form Objects in app/forms/<context>/<name>.rb;
ActiveModel::Model + ActiveModel::Attributes; `save` wraps in a
transaction.
- Serializers (Jbuilder/alba) for JSON responses; never `render json: model`.
## Background jobs
- Solid Queue (Rails 8 default) or Sidekiq.
- ApplicationJob declares default retry_on/discard_on; per-job overrides.
- Every job idempotent (Stripe idempotency_key, find_or_create_by!,
upsert_all, sent_at flags).
- Pass IDs not records. Refetch in `perform`.
- Queue lanes: default, mailers, payments, low — with prioritized
worker pools.
- Mailers ALWAYS `.deliver_later` from controllers.
- Recurring schedules in config/recurring.yml (Solid Queue).
- Mission Control – Jobs UI mounted behind auth.
## Hotwire (Turbo + Stimulus)
- Default stack: Turbo + Stimulus + importmap-rails. No jQuery, no
Webpacker, no jsbundling unless specifically needed.
- Turbo Drive on by default; opt out via data-turbo="false" per element.
- Turbo Frames scope partial updates with `<turbo-frame id="...">`.
- Turbo Streams from controllers (`format.turbo_stream`) for surgical
DOM ops.
- `broadcasts_to` on models for Action Cable / Solid Cable broadcast.
- Stimulus controllers in app/javascript/controllers/; static targets,
values, actions; never querySelector / addEventListener.
- ViewComponent (or Phlex) for typed view extraction.
- Propshaft + tailwindcss-rails (Rails 8 defaults).
- `form_with` (Turbo-enabled), render :new on validation fail with
status: :unprocessable_entity.
## Authorization
- Pundit. One policy per resource at app/policies/<resource>_policy.rb.
- Every action calls `authorize`; index calls `policy_scope`.
- after_action :verify_authorized + :verify_policy_scoped in
ApplicationController.
- `permitted_attributes` in policy; controller calls
`@x.update(permitted_attributes(@x))`.
- `policy(record).action?` in views — never raw `current_user.admin?`.
- Multi-tenant: ActsAsTenant or RLS at DB level; Pundit layered on top.
- Roles via Rolify or User enum; ALL checks routed through Pundit.
## Testing
- RSpec; spec/<type>/<name>_spec.rb; types: models, services, jobs,
policies, requests, system, components, forms.
- FactoryBot factories with sequences and traits; NO fixtures.
- `let` lazy by default; `let!` only when setup side effect is the
test.
- System specs via Cuprite (CDP), not Selenium; semantic matchers
(have_button/have_link/have_field/have_content).
- Request specs over controller specs.
- VCR for recorded HTTP; WebMock for stubbed assertions; never real
third-party in CI.
- ActiveJob test adapter; `have_enqueued_job` / `have_enqueued_mail`.
- `freeze_time { ... }`; never Timecop.
- RuboCop / Standard + Brakeman in CI.
End-to-End Example: A Comments Feature With Background Email, Authorization, And Hotwire
Without rules: Fat controller, mass assignment, synchronous mail, no Pundit, no Turbo, controller spec.
class CommentsController < ApplicationController
def create
@comment = Comment.new(params[:comment])
@comment.user_id = session[:user_id]
@comment.post_id = params[:post_id]
if @comment.save
CommentMailer.notify(@comment.post.author, @comment).deliver_now
AuditLog.create(user_id: session[:user_id], action: "comment_create")
redirect_to "/posts/#{params[:post_id]}", notice: "Created"
else
redirect_to "/posts/#{params[:post_id]}", alert: "Failed"
end
end
end
With rules: RSpec'd service object, idempotent job, Pundit, Turbo Stream broadcast, system spec.
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :authenticate_user!
before_action :set_post
def create
authorize Comment.new(post: @post)
result = Comments::CreateComment.call(user: current_user, post: @post, params: comment_params)
case result
in { success: true, value: comment }
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post, notice: "Comment posted" }
end
in { success: false, error: }
flash.now[:alert] = error.message
render "posts/show", status: :unprocessable_entity
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.expect(comment: [:body])
end
end
# app/services/comments/create_comment.rb
module Comments
class CreateComment
Result = Data.define(:success, :value, :error)
Failure = Data.define(:message)
def self.call(**) = new(**).call
def initialize(user:, post:, params:)
@user, @post, @params = user, post, params
end
def call
comment = @post.comments.build(@params.merge(author: @user))
if comment.save
NotifyCommentJob.perform_later(comment_id: comment.id)
Result.new(success: true, value: comment, error: nil)
else
Result.new(success: false, value: nil, error: Failure.new(message: comment.errors.full_messages.to_sentence))
end
end
end
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true, touch: true
belongs_to :author, class_name: "User", inverse_of: :comments
validates :body, presence: true, length: { minimum: 2, maximum: 5_000 }
broadcasts_to ->(c) { [c.post, "comments"] }, inserts_by: :append
end
# app/policies/comment_policy.rb
class CommentPolicy < ApplicationPolicy
def create? = user.present? && record.post.published?
def update? = record.author == user
def destroy? = update? || record.post.author == user || user&.admin?
end
# app/jobs/notify_comment_job.rb
class NotifyCommentJob < ApplicationJob
queue_as :mailers
retry_on Net::OpenTimeout, Net::ReadTimeout, wait: 10.seconds, attempts: 3
discard_on ActiveRecord::RecordNotFound
def perform(comment_id:)
comment = Comment.find(comment_id)
return if comment.notified_at?
CommentMailer.with(comment:).new_comment.deliver_later
AuditLogJob.perform_later(user_id: comment.author_id, event: "comment_create", record: comment)
comment.update_column(:notified_at, Time.current)
end
end
# app/views/posts/show.html.erb
<%= turbo_stream_from @post, "comments" %>
<%= turbo_frame_tag "comments" do %>
<%= render @post.comments.includes(:author).order(:created_at) %>
<% end %>
<% if policy(Comment.new(post: @post)).create? %>
<%= turbo_frame_tag "new_comment" do %>
<%= form_with model: [@post, Comment.new] do |f| %>
<%= f.text_area :body %>
<%= f.submit "Post comment" %>
<% end %>
<% end %>
<% end %>
# app/views/comments/create.turbo_stream.erb
<%= turbo_stream.append "comments", partial: "comments/comment", locals: { comment: @comment } %>
<%= turbo_stream.replace "new_comment" do %>
<%= turbo_frame_tag "new_comment" do %>
<%= form_with model: [@post, Comment.new] do |f| %>
<%= f.text_area :body %>
<%= f.submit "Post comment" %>
<% end %>
<% end %>
<% end %>
# spec/services/comments/create_comment_spec.rb
require "rails_helper"
RSpec.describe Comments::CreateComment do
let(:user) { create(:user) }
let(:post_record) { create(:post, :published) }
subject(:service) { described_class.new(user:, post: post_record, params: { body: "Looks good" }) }
it "creates the comment and enqueues notification" do
expect { service.call }
.to change(Comment, :count).by(1)
.and have_enqueued_job(NotifyCommentJob)
end
it "returns failure with empty body" do
failing = described_class.new(user:, post: post_record, params: { body: "" })
expect(failing.call.success).to be false
end
end
# spec/system/commenting_spec.rb
require "rails_helper"
RSpec.describe "Commenting", type: :system do
let(:user) { create(:user) }
let!(:post_record) { create(:post, :published) }
before { sign_in user; visit post_path(post_record) }
it "posts a comment and shows it via Turbo Stream" do
fill_in "Body", with: "Great post"
click_on "Post comment"
expect(page).to have_content("Great post")
end
end
Skinny controller, service object, Pundit policy, idempotent job, Turbo Stream broadcast for real-time, request and system specs covering the flow.
Get the Full Pack
These eight rules cover the Rails 8 patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — skinny controllers with service objects, modern ActiveRecord without callback-as-business-logic, N+1-free queries, strong params with form objects, idempotent ActiveJob, Hotwire over SPAs, Pundit on every action, RSpec + Cuprite test suites, without having to re-prompt.
If you want the expanded pack — these eight plus rules for Sidekiq tuning (concurrency, throttling, dead-job handling), Action Cable / Solid Cable patterns for high-fan-out broadcasts, Solid Queue and Solid Cache configuration for production scale, Kamal 2 deployment recipes (Docker, accessory services, zero-downtime deploys, secrets), Stimulus controllers for the dozen patterns every app needs (modal, dropdown, autocomplete, sortable, infinite scroll), ViewComponent + Lookbook for component-driven view layers, multi-tenant Rails with Postgres row-level security and ActsAsTenant, Phlex as a Ruby DSL alternative to ERB, Pagy tuning for huge datasets, Brakeman + bundler-audit + dependabot for security CI, and the deploy patterns I use for Rails 8 on Hetzner + Kamal + Cloudflare — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Rails you would actually merge.
Top comments (0)