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.
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
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
# ✅ 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
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.
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
user = User.create(email: "")
# => ActiveRecord::NotNullViolation: PG::NotNullViolation
# Unreadable error. No way to show users what went wrong.
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
user = User.create(email: "")
user.errors.full_messages
# => ["Email can't be blank", "Username can't be blank"]
# Clean, translatable, form-friendly errors.
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.
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
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
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.
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
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
# ✅ 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
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.
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
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
# ✅ 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
# 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
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.
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
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
# ✅ Good: clean RESTful routes
# routes.rb
resources :orders do
resource :cancellation, only: :create
resource :archive, only: :create
resource :duplication, only: :create
end
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
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.