Rails Controller Patterns
Quick Reference
When to Use This Skill
- Generating new Rails controllers
- Reviewing existing controllers for best practices
- Implementing RESTful actions (index, show, new, create, edit, update, destroy)
- Adding authorization with Pundit
- Handling nested resources
- Implementing state transitions (submissions, approvals, activations)
- Bulk operations on resources
Core Patterns at a Glance
Standard CRUD Controller:
class ResourcesController < ApplicationController
before_action :set_resource, only: %i[show edit update destroy]
def index
@resources = policy_scope(Resource)
end
def show; end
def new
@resource = authorize Resource.new
end
def create
@resource = authorize Resource.new(resource_params)
@resource.save ? redirect_to(@resource, notice: 'Successfully Created Resource') : render('new', status: :unprocessable_content)
end
def edit; end
def update
@resource.update(resource_params) ? redirect_to(@resource, notice: 'Successfully Updated Resource') : render('edit', status: :unprocessable_content)
end
def destroy
@resource.destroy
redirect_to resources_url, notice: 'Successfully Deleted Resource'
end
private
def set_resource
@resource = authorize Resource.find(params[:id])
end
def resource_params
params.expect(resource: %i[attr1 attr2])
end
endNamespaced State Controller:
class Resources::StatesController < ApplicationController
before_action :set_resource
before_action :ensure_valid_state, only: :create
def create
@resource.activate!
redirect_to resources_path, notice: 'Resource activated.'
end
def destroy
@resource.deactivate!
redirect_to resources_path, notice: 'Resource deactivated.'
end
private
def set_resource
@resource = current_user.resources.find(params[:resource_id])
end
def ensure_valid_state
redirect_to(resources_path, alert: 'Invalid state') unless @resource.can_activate?
end
endDecision Tree
Need to add controller functionality?
│
├─ Standard CRUD operations (list, view, create, edit, delete)?
│ └─ Use: Standard RESTful Controller Pattern
│
├─ State transitions (submit, approve, activate, publish)?
│ └─ Use: Namespaced State Controller (create/destroy actions)
│
├─ Bulk operations (bulk submit, bulk delete)?
│ └─ Use: Namespaced Bulk Controller (create action only)
│
├─ Nested under parent resource?
│ └─ Use: Nested RESTful Controller Pattern
│
└─ Complex authorization rules?
└─ Add: Policy scopes and explicit authorization checksEssential Patterns
1. Authorization (Pundit)
Pattern: Authorize all resource interactions using authorize or policy_scope.
# Collections - use policy_scope
def index
@products = policy_scope(Product)
end
# New instances - authorize the class
def new
@product = authorize Product.new
end
# Existing instances - authorize in set method
def set_product
@product = authorize Product.find(params[:id])
endRules:
policy_scope()for collections (index)authorize ClassName.new()for new records (new, create)authorizeinset_*methods for existing records- Never skip authorization on resource operations
2. Before Actions
Pattern: Extract common setup logic with explicit action scoping.
# Resource loading (most common)
before_action :set_product, only: %i[show edit update destroy]
# Parent resource loading (nested)
before_action :set_company
before_action :set_employee, only: %i[show edit update destroy]
# State validation
before_action :ensure_pending, only: :create
before_action :ensure_stopped, only: :createRules:
- Always use
only:orexcept: - Name descriptively:
set_[resource],ensure_[state],require_[permission] - Order matters - execute in declaration order
- Keep methods focused on single responsibility
State Validation Example:
def ensure_pending
return if @resource.pending?
redirect_to resources_path, alert: 'Must be pending.'
end3. RESTful Action Patterns
Index - List all resources:
def index
@resources = policy_scope(Resource)
endShow - Display one resource:
def show
# Resource set via before_action
# Load scoped associations if needed
@related = policy_scope(@resource.related_items)
endNew - Form for new resource:
def new
@resource = authorize Resource.new
endCreate - Save new resource:
def create
@resource = authorize Resource.new(resource_params)
if @resource.save
redirect_to @resource, notice: 'Successfully Created Resource'
else
render :new, status: :unprocessable_content
end
endEdit - Form for existing resource:
def edit
# Resource set via before_action
endUpdate - Save changes to resource:
def update
if @resource.update(resource_params)
redirect_to @resource, notice: 'Successfully Updated Resource'
else
render :edit, status: :unprocessable_content
end
endDestroy - Delete resource:
def destroy
@resource.destroy
redirect_to resources_url, notice: 'Successfully Deleted Resource'
end4. Strong Parameters
Pattern: Define permitted attributes in private method.
def resource_params
params.expect(
resource: [
:simple_attr,
:another_attr,
nested_attrs: %i[id attr1 attr2 _destroy],
array_attrs: [],
multiple_ids: []
]
)
endRules:
- Use
params.expect(model: [...]) - Nested attributes:
{nested_attrs: %i[id attr _destroy]} - Arrays:
{array_attr: []} - Include
:idfor update,_destroyfor deletion in nested attributes
5. HTTP Status Codes
# Success - redirects (default 302, no status needed)
redirect_to @resource, notice: 'Success'
# Validation failure - render with unprocessable_content
render :new, status: :unprocessable_content # 422
render :edit, status: :unprocessable_content # 422
# Other statuses (rare in controllers)
head :no_content # 204
head :forbidden # 403
head :not_found # 404Rules:
- Redirects never need explicit status
- Failed validations:
:unprocessable_content(422) - Turbo requires proper status codes for error handling
6. Flash Messages
Pattern: Consistent, user-friendly messaging.
# Success (notice:)
redirect_to @product, notice: 'Successfully Created Product'
redirect_to @product, notice: 'Successfully Updated Product'
redirect_to products_url, notice: 'Successfully Deleted Product'
# Errors (alert:)
redirect_to products_path, alert: 'Must be pending to submit.'
redirect_to products_path, alert: 'Cannot delete active product.'Rules:
- Format:
Successfully [Action] [Resource] - Use
notice:for success - Use
alert:for errors/warnings - Keep concise and action-oriented
- Title case for resource names
7. Naming Conventions
# Controllers
ProductsController < ApplicationController
Admin::ProductsController < Admin::BaseController
Products::SubmissionsController < ApplicationController
# Instance variables
@product, @user # Singular for one resource
@products, @users # Plural for collections
# Private methods
def set_product # Resource loading
def product_params # Strong parameters
def ensure_pending # State validation
def require_admin # Authorization checkComplete Examples
Simple CRUD Controller
class ProductsController < ApplicationController
before_action :set_product, only: %i[show edit update destroy]
def index
@products = policy_scope(Product)
end
def show; end
def new
@product = authorize Product.new
end
def create
@product = authorize Product.new(product_params)
if @product.save
redirect_to @product, notice: 'Successfully Created Product'
else
render :new, status: :unprocessable_content
end
end
def edit; end
def update
if @product.update(product_params)
redirect_to @product, notice: 'Successfully Updated Product'
else
render :edit, status: :unprocessable_content
end
end
def destroy
@product.destroy
redirect_to products_url, notice: 'Successfully Deleted Product'
end
private
def set_product
@product = authorize Product.find(params[:id])
end
def product_params
params.expect(product: %i[name description price])
end
endNested Resource Controller
class OrderItemsController < ApplicationController
before_action :set_order
before_action :set_order_item, only: %i[show edit update destroy]
def index
@order_items = policy_scope(@order.order_items)
end
def new
@order_item = authorize @order.order_items.build
end
def create
@order_item = authorize @order.order_items.build(order_item_params)
if @order_item.save
redirect_to [@order, @order_item], notice: 'Successfully Created Order Item'
else
render :new, status: :unprocessable_content
end
end
def update
if @order_item.update(order_item_params)
redirect_to [@order, @order_item], notice: 'Successfully Updated Order Item'
else
render :edit, status: :unprocessable_content
end
end
def destroy
@order_item.destroy
redirect_to order_order_items_url(@order), notice: 'Successfully Deleted Order Item'
end
private
def set_order
@order = authorize Order.find(params[:order_id])
end
def set_order_item
@order_item = authorize @order.order_items.find(params[:id])
end
def order_item_params
params.expect(order_item: %i[product_id quantity price])
end
endCommon Mistakes
| ❌ Anti-Pattern | ✅ Correct Pattern |
|---|---|
@product = Product.new(product_params) | @product = authorize Product.new(product_params) |
render:new, status::unprocessable_entity | render:new, status::unprocessable_content |
render:new (on validation failure) | render:new, status::unprocessable_content |
@product = Product.new(params[:product]) | @product = Product.new(product_params) |
params.require(:product).permit(:name) | params.expect(product: %i[name]) |
redirect_to @product, notice: 'Product created!'redirect_to @product, notice: 'Success!' | redirect_to @product, notice: 'Successfully Created Product' |
before_action:set_product (no scope) | before_action:set_product, only: %i[show edit update destroy] |
| Custom action for state changes | Namespaced controller with RESTful actions |
Advanced Patterns
Namespaced State Controllers
Use When: Actions represent state transitions (submit/unsubmit, activate/deactivate, approve/reject) on a resource.
Pattern: Namespace under parent resource, use create and destroy for state changes.
# Controller: app/controllers/time_entries/submissions_controller.rb
class TimeEntries::SubmissionsController < ApplicationController
before_action :set_time_entry
before_action :ensure_valid_for_submission, only: :create
before_action :ensure_submitted, only: :destroy
def create
@time_entry.update!(status: :submitted, submitted_at: Time.current)
redirect_to time_entries_path, notice: 'Time entry submitted for approval.'
end
def destroy
@time_entry.update!(status: :pending, submitted_at: nil)
redirect_to time_entries_path, notice: 'Time entry unsubmitted.'
end
private
def set_time_entry
@time_entry = current_user.time_entries.find(params[:time_entry_id])
end
def ensure_valid_for_submission
return if @time_entry.pending? && @time_entry.stopped?
redirect_to time_entries_path, alert: 'Only stopped pending entries can be submitted.'
end
def ensure_submitted
return if @time_entry.submitted?
redirect_to time_entries_path, alert: 'Only submitted entries can be unsubmitted.'
end
end
# Routes
resources :time_entries do
resource :submission, only: [:create, :destroy], module: :time_entries
end
# Views
button_to time_entry_submission_path(@time_entry), method: :post # Submit
button_to time_entry_submission_path(@time_entry), method: :delete # UnsubmitBenefits:
- RESTful (uses standard create/destroy actions)
- Clear file organization (controllers/time_entries/submissions_controller.rb)
- Validation extracted to before_actions
- Single controller handles both transitions
- Easy to test
Bulk Operation Controllers
Use When: Operating on multiple records at once (bulk submit, bulk delete, bulk archive).
Pattern: Namespaced controller with only create action, validations in before_actions.
# Controller: app/controllers/time_entries/bulk_submissions_controller.rb
class TimeEntries::BulkSubmissionsController < ApplicationController
before_action :set_entries
before_action :ensure_entries_present
before_action :ensure_entries_valid
def create
@entries.update_all(status: TimeEntry.statuses[:submitted], submitted_at: Time.current)
redirect_to time_entries_path, notice: "#{@entries.count} #{'entry'.pluralize(@entries.count)} submitted."
end
private
def set_entries
ids = params[:time_entry_ids] || []
@entries = current_user.time_entries.where(id: ids)
end
def ensure_entries_present
return if @entries.any?
redirect_to time_entries_path, alert: 'No entries selected.'
end
def ensure_entries_valid
invalid = @entries.reject { |e| e.pending? && e.stopped? }
return if invalid.empty?
redirect_to time_entries_path, alert: 'Only stopped pending entries can be submitted.'
end
end
# Routes
resource :bulk_submissions, only: :create, module: :time_entries
# Views
form_with url: bulk_submissions_path, method: :post do |f|
# checkboxes for time_entry_ids[]
endScoped Collections in Show
Use When: Showing a resource with multiple related collections.
def show
@active_projects = policy_scope(@company.projects.active)
@archived_projects = policy_scope(@company.projects.archived)
@team_members = policy_scope(@company.users)
endComplex Nested Attributes
Use When: Forms accept nested records (has_many associations).
def product_params
params.expect(
product: [
:name,
:description,
:price,
images_attributes: %i[id url alt_text _destroy],
variants_attributes: %i[id sku price stock_count _destroy],
tags: [],
category_ids: []
]
)
endKey Points:
- Include
:idfor updating existing nested records - Include
_destroyfor deletion via nested attributes - Use
{array_attr: []}for simple arrays - Use
{nested_attrs: %i[attr1 attr2]}for nested attribute hashes
Agent Instructions
Generating New Controllers
- Identify controller type:
- Standard CRUD → Use Simple CRUD pattern - State transitions → Use Namespaced State pattern - Bulk operations → Use Bulk Operation pattern - Nested resource → Use Nested Resource pattern
- Apply patterns:
- Start with appropriate template - Add authorization (authorize, policy_scope) - Define strong parameters - Add before_actions with explicit scoping - Use correct status codes and flash messages
- Follow conventions:
- Name: ResourcesController or Resources::StatesController - Inherit from: ApplicationController - Instance variables: @resource (singular), @resources (plural) - Private methods: set_resource, resource_params
Reviewing Existing Controllers
Check for:
- Authorization on all resource operations
before_actionwithonly:/except:- Strong parameters (no direct
params[]access) - Status
:unprocessable_contenton validation failures - Consistent flash messages:
Successfully [Action] [Resource] - Proper naming conventions
- State validations in before_actions (not in main actions)
Priority order: Security (authorization, params) → RESTful patterns → Status codes → Messaging