DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Ruby on Rails 8: AI Dev Guide 2026

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<% @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 %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<% @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) %>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
def create
  @comment = @post.comments.create!(comment_params)
  respond_to do |format|
    format.js { render js: "$('#comments').append('#{render_to_string(@comment)}')" }
  end
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<%= 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 %>
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
// 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() }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<% if current_user&.admin? || @post.author == current_user %>
  <%= link_to "Edit", edit_post_path(@post) %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<% if policy(@post).update? %>
  <%= link_to "Edit", edit_post_path(@post) %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)