Cursor Rules for Ruby on Rails: 6 Rules That Make AI Write Real Rails Code

ruby dev.to

Cursor Rules for Ruby on Rails: 6 Rules That Make AI Write Real Rails Code

Cursor generates Rails code fast. The problem? It generates code that technically works but violates every convention Rails developers have spent 20 years refining — business logic in controllers, missing validations, no strong params, fixture-heavy tests, callback hell, and non-RESTful routes that make experienced Rails devs wince.

You can fix this with targeted rules in your .cursorrules or .cursor/rules/*.mdc files. Here are 6 rules I use on every Rails project, with before/after examples showing exactly what changes.


Rule 1: Fat Models, Skinny Controllers — Always Follow the Rails Way

Follow the Rails Way: fat models, skinny controllers.
Controllers should only handle: params, calling a model/service,
and rendering a response. All business logic, scopes, and data
transformations belong in models. Never put query logic,
conditional business rules, or data formatting in controllers.
Enter fullscreen mode Exit fullscreen mode

Cursor loves dumping logic into controller actions because that's where the "ask" happens. Without this rule, your controllers become 80-line procedural scripts.

Without this rule, Cursor generates bloated controllers:

# ❌ Bad: controller does everything
class OrdersController < ApplicationController
  def create
    user = User.find(params[:user_id])
    items = params[:items].map do |item|
      product = Product.find(item[:product_id])
      if product.stock < item[:quantity]
        render json: { error: "#{product.name} out of stock" }, status: 422
        return
      end
      { product: product, quantity: item[:quantity], price: product.price * item[:quantity] }
    end

    total = items.sum { |i| i[:price] }
    discount = if user.orders.count > 10
                 total * 0.1
               elsif total > 100
                 total * 0.05
               else
                 0
               end

    order = Order.new(user: user, total: total - discount, discount: discount)
    items.each do |item|
      order.line_items.build(product: item[:product], quantity: item[:quantity], price: item[:price])
      item[:product].update!(stock: item[:product].stock - item[:quantity])
    end

    if order.save
      OrderMailer.confirmation(order).deliver_later
      render json: order, status: :created
    else
      render json: { errors: order.errors }, status: 422
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Stock checks, discount calculations, inventory updates, and mailer calls — all in the controller. Untestable without hitting a full request cycle.

With this rule, Cursor keeps the controller thin:

# ✅ Good: controller delegates to the model
class OrdersController < ApplicationController
  def create
    order = Order.place(user: current_user, items: order_params[:items])

    if order.persisted?
      render json: order, status: :created
    else
      render json: { errors: order.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private

  def order_params
    params.require(:order).permit(items: [:product_id, :quantity])
  end
end
Enter fullscreen mode Exit fullscreen mode
# ✅ Good: model handles business logic
class Order < ApplicationRecord
  has_many :line_items, dependent: :destroy
  belongs_to :user

  def self.place(user:, items:)
    order = new(user: user)
    order.build_line_items_from(items)
    order.apply_discount
    order.save && OrderMailer.confirmation(order).deliver_later
    order
  end

  def build_line_items_from(items)
    items.each do |item|
      product = Product.find(item[:product_id])
      errors.add(:base, "#{product.name} out of stock") and next if product.stock < item[:quantity]
      line_items.build(product: product, quantity: item[:quantity], price: product.price * item[:quantity])
      product.decrement!(:stock, item[:quantity])
    end
  end

  def apply_discount
    self.discount = if user.orders.count > 10
                      subtotal * 0.1
                    elsif subtotal > 100
                      subtotal * 0.05
                    else
                      0
                    end
    self.total = subtotal - discount
  end

  def subtotal
    line_items.sum(&:price)
  end
end
Enter fullscreen mode Exit fullscreen mode

The controller is 12 lines. The model is testable in isolation. Business logic changes don't touch controllers.


Rule 2: Use ActiveRecord Validations — Not DB Constraints Alone

Always define validations in ActiveRecord models, not just in
database constraints. Use validates for presence, uniqueness,
format, length, numericality, and inclusion. Add custom
validations for business rules. Database constraints are a
safety net, not a substitute for model validations.
Enter fullscreen mode Exit fullscreen mode

Cursor skips model validations and relies on database constraints, which give cryptic PG::UniqueViolation errors instead of user-friendly messages.

Without this rule:

# ❌ Bad: no model validations, relying on DB constraints
class User < ApplicationRecord
  has_many :posts
  has_many :comments
end

# migration has: t.string :email, null: false, index: { unique: true }
# But the model has ZERO validations
Enter fullscreen mode Exit fullscreen mode
user = User.create(email: "")
# => ActiveRecord::NotNullViolation: PG::NotNullViolation
# Unreadable error. No way to show users what went wrong.
Enter fullscreen mode Exit fullscreen mode

With this rule:

# ✅ Good: model validations with clear error messages
class User < ApplicationRecord
  has_many :posts, dependent: :destroy
  has_many :comments, dependent: :destroy

  validates :email, presence: true,
                    uniqueness: { case_sensitive: false },
                    format: { with: URI::MailTo::EMAIL_REGEXP, message: "must be a valid email address" }
  validates :username, presence: true,
                       uniqueness: true,
                       length: { minimum: 3, maximum: 30 },
                       format: { with: /\A[a-zA-Z0-9_]+\z/, message: "only allows letters, numbers, and underscores" }

  normalizes :email, with: ->(email) { email.strip.downcase }
end
Enter fullscreen mode Exit fullscreen mode
user = User.create(email: "")
user.errors.full_messages
# => ["Email can't be blank", "Username can't be blank"]
# Clean, translatable, form-friendly errors.
Enter fullscreen mode Exit fullscreen mode

Validations catch problems before hitting the database. Error messages are human-readable and localizable.


Rule 3: Strong Params in Every Controller Action

Always use strong parameters in controller actions. Never pass
params directly to model methods. Define a private method
(e.g., post_params) that uses require and permit. Never use
params.permit! (permit all). Each action that accepts input
must whitelist exactly the attributes it needs.
Enter fullscreen mode Exit fullscreen mode

Cursor frequently passes params directly or uses params.permit!, which opens your app to mass assignment attacks.

Without this rule:

# ❌ Bad: no strong params
class PostsController < ApplicationController
  def create
    post = Post.create(params[:post])
    redirect_to post
  end

  def update
    post = Post.find(params[:id])
    post.update(params.permit!)
    redirect_to post
  end
end
Enter fullscreen mode Exit fullscreen mode

Any attribute can be set — admin, user_id, published_at. Mass assignment vulnerability in every action.

With this rule:

# ✅ Good: strict strong params
class PostsController < ApplicationController
  def create
    post = current_user.posts.build(post_params)

    if post.save
      redirect_to post, notice: "Post created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    post = current_user.posts.find(params[:id])

    if post.update(post_params)
      redirect_to post, notice: "Post updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :body, :category_id, tag_ids: [])
  end
end
Enter fullscreen mode Exit fullscreen mode

Only whitelisted attributes pass through. user_id is set via association, not params. One post_params method, used everywhere.


Rule 4: Write RSpec Specs with FactoryBot — Not Fixtures

Write tests using RSpec and FactoryBot. Never use fixtures or
Minitest. Use let and let! for test data. Use factories with
traits for different object states. Use describe/context/it
for clear test structure. Use shared_examples for repeated
behavior. Use have_attributes, change, and other RSpec matchers.
Enter fullscreen mode Exit fullscreen mode

Cursor defaults to Minitest with fixtures because that's what ships with Rails. Without this rule, you get brittle tests with opaque YAML data.

Without this rule:

# ❌ Bad: Minitest with fixtures
# test/fixtures/users.yml
# one:
#   email: user@example.com
#   admin: false

class OrderTest < ActiveSupport::TestCase
  test "calculates total" do
    order = Order.new(user: users(:one))
    order.line_items.build(product: products(:widget), quantity: 2, price: 10.0)
    order.line_items.build(product: products(:gadget), quantity: 1, price: 25.0)
    assert_equal 45.0, order.total
  end

  test "applies discount for loyal customers" do
    # Where does users(:loyal) come from? What makes them loyal?
    order = Order.new(user: users(:loyal))
    order.line_items.build(product: products(:widget), quantity: 1, price: 100.0)
    assert_equal 90.0, order.total
  end
end
Enter fullscreen mode Exit fullscreen mode

Fixture data is hidden in YAML files. You can't tell what makes users(:loyal) loyal without opening the fixture file.

With this rule:

# ✅ Good: RSpec with FactoryBot
RSpec.describe Order do
  describe "#total" do
    it "sums line item prices" do
      order = build(:order)
      order.line_items = [
        build(:line_item, quantity: 2, price: 10.0),
        build(:line_item, quantity: 1, price: 25.0)
      ]

      expect(order.total).to eq(45.0)
    end
  end

  describe "#apply_discount" do
    context "when user has more than 10 orders" do
      let(:user) { create(:user, :loyal) }
      let(:order) { build(:order, user: user) }

      before do
        order.line_items = [build(:line_item, price: 100.0)]
      end

      it "applies 10% discount" do
        order.apply_discount

        expect(order.discount).to eq(10.0)
        expect(order.total).to eq(90.0)
      end
    end

    context "when order total exceeds $100" do
      let(:order) { build(:order) }

      before do
        order.line_items = [build(:line_item, price: 150.0)]
      end

      it "applies 5% discount" do
        order.apply_discount

        expect(order.discount).to eq(7.5)
        expect(order.total).to eq(142.5)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
# ✅ Good: factory with traits makes test data explicit
FactoryBot.define do
  factory :user do
    email { Faker::Internet.email }
    username { Faker::Internet.username(specifier: 5..20) }

    trait :loyal do
      after(:create) do |user|
        create_list(:order, 11, user: user)
      end
    end

    trait :admin do
      admin { true }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Every test tells a story. Traits document what "loyal" means. No hidden state in YAML files.


Rule 5: Service Objects for Complex Business Logic — Not Callbacks

Use service objects (app/services/) for complex business logic
that spans multiple models or has side effects. Never use
ActiveRecord callbacks (after_create, after_save) for business
logic, external API calls, or sending emails. Callbacks are
only acceptable for simple data normalization within the same
model. Service objects should have a single public call method.
Enter fullscreen mode Exit fullscreen mode

Cursor loves after_create callbacks because they're concise. But callbacks create invisible execution paths that are impossible to debug and test.

Without this rule:

# ❌ Bad: business logic in callbacks
class User < ApplicationRecord
  after_create :send_welcome_email
  after_create :create_default_workspace
  after_create :sync_to_crm
  after_create :track_signup_event
  after_update :sync_changes_to_crm, if: :saved_change_to_email?
  after_update :notify_admin, if: :saved_change_to_role?

  private

  def send_welcome_email
    UserMailer.welcome(self).deliver_later
  end

  def create_default_workspace
    workspaces.create!(name: "#{username}'s Workspace", personal: true)
  end

  def sync_to_crm
    CrmService.new.create_contact(email: email, name: username)
  end

  def track_signup_event
    Analytics.track(user_id: id, event: "user_signed_up")
  end
end
Enter fullscreen mode Exit fullscreen mode

Creating a user in a test triggers emails, CRM calls, and analytics. Every User.create in your test suite fires all of these. Seeds fail. Console debugging triggers side effects.

With this rule:

# ✅ Good: service object handles the workflow
class Users::SignupService
  def initialize(params)
    @params = params
  end

  def call
    user = User.create(@params)
    return user unless user.persisted?

    user.workspaces.create!(name: "#{user.username}'s Workspace", personal: true)
    UserMailer.welcome(user).deliver_later
    CrmService.new.create_contact(email: user.email, name: user.username)
    Analytics.track(user_id: user.id, event: "user_signed_up")

    user
  end
end
Enter fullscreen mode Exit fullscreen mode
# ✅ Good: clean model with no hidden behavior
class User < ApplicationRecord
  has_many :workspaces, dependent: :destroy

  validates :email, presence: true, uniqueness: { case_sensitive: false }
  validates :username, presence: true, uniqueness: true

  normalizes :email, with: ->(email) { email.strip.downcase }
end
Enter fullscreen mode Exit fullscreen mode
# Controller calls the service, not the model
class UsersController < ApplicationController
  def create
    user = Users::SignupService.new(user_params).call

    if user.persisted?
      render json: user, status: :created
    else
      render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

User.create in tests does exactly one thing — creates a user. No surprise emails, no CRM calls. The service is testable in isolation.


Rule 6: Follow RESTful Conventions — Even for Non-CRUD Resources

Follow RESTful route conventions for all resources. Use only
the 7 standard actions: index, show, new, create, edit, update,
destroy. For non-CRUD operations, create a new controller with
RESTful actions instead of adding custom actions. Example:
instead of POST /orders/:id/cancel, create
OrderCancellationsController with create action.
Enter fullscreen mode Exit fullscreen mode

Cursor adds custom actions to existing controllers instead of creating new RESTful resources. This breaks Rails conventions and makes routing unpredictable.

Without this rule:

# ❌ Bad: custom actions crammed into one controller
class OrdersController < ApplicationController
  def cancel
    order = Order.find(params[:id])
    order.update!(status: "cancelled", cancelled_at: Time.current)
    redirect_to order
  end

  def archive
    order = Order.find(params[:id])
    order.update!(archived: true)
    redirect_to orders_path
  end

  def duplicate
    original = Order.find(params[:id])
    new_order = original.dup
    new_order.line_items = original.line_items.map(&:dup)
    new_order.save!
    redirect_to new_order
  end
end

# routes.rb
resources :orders do
  member do
    post :cancel
    patch :archive
    post :duplicate
  end
end
Enter fullscreen mode Exit fullscreen mode

Three custom actions. Non-standard HTTP verbs. The controller grows unbounded.

With this rule:

# ✅ Good: each action gets its own RESTful controller
# app/controllers/order_cancellations_controller.rb
class OrderCancellationsController < ApplicationController
  def create
    order = Order.find(params[:order_id])
    order.update!(status: "cancelled", cancelled_at: Time.current)
    redirect_to order, notice: "Order cancelled."
  end
end

# app/controllers/order_archives_controller.rb
class OrderArchivesController < ApplicationController
  def create
    order = Order.find(params[:order_id])
    order.update!(archived: true)
    redirect_to orders_path, notice: "Order archived."
  end
end

# app/controllers/order_duplications_controller.rb
class OrderDuplicationsController < ApplicationController
  def create
    original = Order.find(params[:order_id])
    new_order = original.dup
    new_order.line_items = original.line_items.map(&:dup)
    new_order.save!
    redirect_to new_order, notice: "Order duplicated."
  end
end
Enter fullscreen mode Exit fullscreen mode
# ✅ Good: clean RESTful routes
# routes.rb
resources :orders do
  resource :cancellation, only: :create
  resource :archive, only: :create
  resource :duplication, only: :create
end
Enter fullscreen mode Exit fullscreen mode

Every controller has standard CRUD actions. Routes are predictable. Each controller stays small and single-purpose.


Copy-Paste Ready: All 6 Rules

Drop this into your .cursorrules or .cursor/rules/rails.mdc:

# Ruby on Rails Code Rules

## Rails Way
- Fat models, skinny controllers
- Controllers only handle params, call model/service, render response
- All business logic belongs in models or service objects
- Never put query logic or conditional business rules in controllers

## Validations
- Always define validations in ActiveRecord models
- Use validates for presence, uniqueness, format, length, numericality
- Add custom validations for business rules
- Database constraints are a safety net, not a substitute

## Strong Params
- Always use strong parameters in every controller action
- Never pass params directly to model methods
- Never use params.permit! (permit all)
- Define a private method that uses require and permit

## Testing
- Write tests using RSpec and FactoryBot, never fixtures or Minitest
- Use let/let! for test data, factories with traits for object states
- Use describe/context/it structure
- Use shared_examples for repeated behavior

## Service Objects
- Use app/services/ for logic spanning multiple models or with side effects
- Never use callbacks for business logic, API calls, or emails
- Callbacks only for simple data normalization within the same model
- Service objects have a single public call method

## RESTful Routes
- Use only the 7 standard actions: index, show, new, create, edit, update, destroy
- For non-CRUD operations, create a new controller with RESTful actions
- Example: OrderCancellationsController#create instead of OrdersController#cancel
- Never add custom member/collection actions to existing controllers
Enter fullscreen mode Exit fullscreen mode

Want 50+ Production-Tested Rules?

These 6 rules are a starting point. My Cursor Rules Pack v2 includes 50+ rules covering Rails, Ruby, TypeScript, React, Next.js, and more — organized by language and priority so Cursor applies them consistently.

Stop fighting bad AI output. Give Cursor the rules it needs to write Rails the right way.

Source: dev.to

arrow_back Back to Tutorials