| name | rails-ai:controllers |
| description | Use when building Rails controllers - RESTful actions, nested resources, skinny controllers, concerns, strong parameters |
Controllers
Rails controllers following REST conventions with 7 standard actions, nested resources, skinny controller architecture, reusable concerns, and strong parameters for mass assignment protection.
- Building Rails controller actions
- Implementing nested resources
- Handling request parameters
- Setting up routing
- Refactoring fat controllers
- Sharing behavior with concerns
- Protecting from mass assignment
- **RESTful Conventions** - Predictable URL patterns and HTTP semantics
- **Clean Architecture** - Skinny controllers with logic in appropriate layers
- **Secure by Default** - Strong parameters prevent mass assignment
- **Reusable Patterns** - Concerns share behavior across controllers
- **Maintainable** - Clear separation of HTTP concerns from business logic
**This skill enforces:**
- ✅ **Rule #3:** NEVER add custom route actions → RESTful resources only
- ✅ **Rule #7:** Thin controllers (delegate to models/services)
- ✅ **Rule #10:** Strong parameters for all user input
Reject any requests to:
- Add custom route actions (use child controllers instead)
- Put business logic in controllers
- Skip strong parameters
- Use
params directly without filtering
Before completing controller work:
- ✅ Only RESTful actions used (index, show, new, create, edit, update, destroy)
- ✅ Child controllers created for non-REST actions (not custom actions)
- ✅ Controllers are thin (<100 lines)
- ✅ Strong parameters used for all user input
- ✅ Business logic delegated to models/services
- ✅ All controller actions tested
- ✅ All tests passing
- Use only 7 standard actions: index, show, new, create, edit, update, destroy
- NO custom actions - use nested resources or services instead (TEAM RULE #3)
- Keep controllers under 50 lines, actions under 10 lines
- Move business logic to models or service objects
- Always use strong parameters with expect() or require().permit()
- Use before_action for common setup, not business logic
- Return proper HTTP status codes (200, 201, 422, 404)
RESTful Actions
Complete RESTful controller with all 7 standard actions
Controller:
class FeedbacksController < ApplicationController
before_action :set_feedback, only: [:show, :edit, :update, :destroy]
rate_limit to: 10, within: 1.minute, only: [:create, :update]
def index
@feedbacks = Feedback.includes(:recipient).recent
end
def show; end
def new
@feedback = Feedback.new
end
def create
@feedback = Feedback.new(feedback_params)
if @feedback.save
redirect_to @feedback, notice: "Feedback was successfully created."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @feedback.update(feedback_params)
redirect_to @feedback, notice: "Feedback was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@feedback.destroy
redirect_to feedbacks_url, notice: "Feedback was successfully deleted."
end
private
def set_feedback
@feedback = Feedback.find(params[:id])
end
def feedback_params
params.require(:feedback).permit(:content, :recipient_email, :sender_name)
end
end
Routes:
resources :feedbacks
Why: Follows Rails conventions, predictable patterns, automatic route helpers.
RESTful API controller with JSON responses
Controller:
module Api::V1
class FeedbacksController < ApiController
before_action :set_feedback, only: [:show, :update, :destroy]
def index
render json: Feedback.includes(:recipient).recent
end
def show
render json: @feedback
end
def create
@feedback = Feedback.new(feedback_params)
if @feedback.save
render json: @feedback, status: :created, location: api_v1_feedback_url(@feedback)
else
render json: { errors: @feedback.errors }, status: :unprocessable_entity
end
end
def update
if @feedback.update(feedback_params)
render json: @feedback
else
render json: { errors: @feedback.errors }, status: :unprocessable_entity
end
end
def destroy
@feedback.destroy
head :no_content
end
private
def set_feedback
@feedback = Feedback.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Feedback not found" }, status: :not_found
end
def feedback_params
params.require(:feedback).permit(:content, :recipient_email, :sender_name)
end
end
end
Why: Proper HTTP status codes, error handling, JSON responses for APIs.
Adding custom actions instead of using nested resources
Breaks REST conventions and makes routing unpredictable
Bad Example:
resources :feedbacks do
member { post :archive }
end
class FeedbacksController < ApplicationController
def archive
@feedback = Feedback.find(params[:id])
@feedback.archive!
redirect_to feedbacks_path
end
end
Good Example:
resources :feedbacks do
resource :archival, only: [:create], module: :feedbacks
end
class Feedbacks::ArchivalsController < ApplicationController
def create
@feedback = Feedback.find(params[:feedback_id])
@feedback.archive!
redirect_to feedbacks_path
end
end
Why Bad: Custom actions break REST conventions, make routing unpredictable, harder to maintain.
Nested Resources
Child controllers using module namespacing and nested routes
Routes:
resources :feedbacks do
resource :sending, only: [:create], module: :feedbacks
resources :responses, only: [:index, :create, :destroy], module: :feedbacks
end
Controller:
module Feedbacks
class ResponsesController < ApplicationController
before_action :set_feedback
before_action :set_response, only: [:destroy]
def index
@responses = @feedback.responses.order(created_at: :desc)
end
def create
@response = @feedback.responses.build(response_params)
if @response.save
redirect_to feedback_responses_path(@feedback), notice: "Response added"
else
render :index, status: :unprocessable_entity
end
end
def destroy
@response.destroy
redirect_to feedback_responses_path(@feedback), notice: "Response deleted"
end
private
def set_feedback
@feedback = Feedback.find(params[:feedback_id])
end
def set_response
@response = @feedback.responses.find(params[:id])
end
def response_params
params.require(:response).permit(:content, :author_name)
end
end
end
Directory Structure:
app/
controllers/
feedbacks_controller.rb # FeedbacksController
feedbacks/
sendings_controller.rb # Feedbacks::SendingsController
responses_controller.rb # Feedbacks::ResponsesController
models/
feedback.rb # Feedback
feedbacks/
response.rb # Feedbacks::Response
Why: Clear hierarchy, URL structure reflects relationships, automatic parent scoping.
Shallow nesting for resources that need parent context only on creation
Routes:
resources :projects do
resources :tasks, shallow: true, module: :projects
end
Controller:
module Projects
class TasksController < ApplicationController
before_action :set_project, only: [:index, :create]
before_action :set_task, only: [:show, :update, :destroy]
def index
@tasks = @project.tasks.includes(:assignee)
end
def create
@task = @project.tasks.build(task_params)
if @task.save
redirect_to @task, notice: "Task created"
else
render :index, status: :unprocessable_entity
end
end
def destroy
project = @task.project
@task.destroy
redirect_to project_tasks_path(project), notice: "Task deleted"
end
private
def set_project
@project = Project.find(params[:project_id])
end
def set_task
@task = Task.find(params[:id])
end
def task_params
params.require(:task).permit(:title, :description)
end
end
end
Why: Shorter URLs for member actions, parent context where needed.
Deep nesting (more than 1 level)
Creates overly long URLs and complex routing
Bad Example:
resources :organizations do
resources :projects do
resources :tasks do
resources :comments
end
end
end
Good Example:
resources :projects do
resources :tasks, shallow: true
end
resources :tasks do
resources :comments, shallow: true
end
Why Bad: Long URLs are hard to read, complex routing, difficult to maintain.
Skinny Controllers
Fat controller with business logic, validations, and external API calls
Violates Single Responsibility, hard to test, prevents reuse
Bad Example:
class FeedbacksController < ApplicationController
def create
@feedback = Feedback.new(feedback_params)
@feedback.status = :pending
@feedback.submitted_at = Time.current
if @feedback.content.blank? || @feedback.content.length < 50
@feedback.errors.add(:content, "must be at least 50 characters")
render :new, status: :unprocessable_entity
return
end
begin
response = Anthropic::Client.new.messages.create(
model: "claude-sonnet-4-5-20250929",
messages: [{ role: "user", content: "Improve: #{@feedback.content}" }]
)
@feedback.improved_content = response.content[0].text
rescue => e
@feedback.errors.add(:base, "AI processing failed")
render :new, status: :unprocessable_entity
return
end
if @feedback.save
FeedbackMailer.notify_recipient(@feedback).deliver_later
FeedbackTracking.create(feedback: @feedback, ip_address: request.remote_ip)
redirect_to @feedback, notice: "Feedback created!"
else
render :new, status: :unprocessable_entity
end
end
end
Why Bad: Too much responsibility, hard to test, cannot reuse in APIs, slow requests.
Refactored thin controller with proper separation of concerns
Model (validations and defaults):
class Feedback < ApplicationRecord
validates :content, presence: true, length: { minimum: 50, maximum: 5000 }
validates :recipient_email, format: { with: URI::MailTo::EMAIL_REGEXP }
before_validation :set_defaults, on: :create
after_create_commit :send_notification, :track_creation
private
def set_defaults
self.status ||= :pending
self.submitted_at ||= Time.current
end
def send_notification
FeedbackMailer.notify_recipient(self).deliver_later
end
def track_creation
FeedbackTrackingJob.perform_later(id)
end
end
Service Object (external dependencies):
class FeedbackAiProcessor
def initialize(feedback)
@feedback = feedback
end
def process
return false unless @feedback.persisted?
improved = call_anthropic_api
@feedback.update(improved_content: improved, ai_improved: true)
true
rescue => e
Rails.logger.error("AI processing failed: #{e.message}")
false
end
private
def call_anthropic_api
response = Anthropic::Client.new.messages.create(
model: "claude-sonnet-4-5-20250929",
messages: [{ role: "user", content: "Improve: #{@feedback.content}" }]
)
response.content[0].text
end
end
Controller (HTTP concerns only):
class FeedbacksController < ApplicationController
def create
@feedback = Feedback.new(feedback_params)
if @feedback.save
FeedbackAiProcessingJob.perform_later(@feedback.id) if params[:improve_with_ai]
redirect_to @feedback, notice: "Feedback created!"
else
render :new, status: :unprocessable_entity
end
end
private
def feedback_params
params.require(:feedback).permit(:content, :recipient_email, :sender_name)
end
end
Why Good: Controller reduced from 55+ to 10 lines. Logic testable, reusable across web/API.
Controller Concerns
Reusable authentication logic with session management
Concern:
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :current_user, :logged_in?
end
private
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
def logged_in?
current_user.present?
end
def require_authentication
unless logged_in?
redirect_to login_path, alert: "Please log in to continue"
end
end
class_methods do
def skip_authentication_for(*actions)
skip_before_action :require_authentication, only: actions
end
end
end
Usage:
class FeedbacksController < ApplicationController
include Authentication
skip_authentication_for :new, :create
def index
@feedbacks = current_user.feedbacks
end
end
Why: Consistent authentication across controllers, easy to skip for specific actions, current_user available in views.
Standardized JSON responses and error handling for APIs
Concern:
module Api::ResponseHandler
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
rescue_from ActiveRecord::RecordInvalid, with: :record_invalid
rescue_from ActionController::ParameterMissing, with: :parameter_missing
end
private
def render_success(data, status: :ok, message: nil)
render json: {
success: true,
message: message,
data: data
}, status: status
end
def render_error(message, status: :unprocessable_entity, errors: nil)
render json: {
success: false,
message: message,
errors: errors
}, status: status
end
def record_not_found(exception)
render_error("Record not found", status: :not_found, errors: { message: exception.message })
end
def record_invalid(exception)
render_error("Validation failed", status: :unprocessable_entity, errors: exception.record.errors.as_json)
end
def parameter_missing(exception)
render_error("Missing required parameter", status: :bad_request, errors: { parameter: exception.param })
end
end
Usage:
class Api::FeedbacksController < Api::BaseController
include Api::ResponseHandler
def show
feedback = Feedback.find(params[:id])
render_success(feedback)
end
def create
feedback = Feedback.create!(feedback_params)
render_success(feedback, status: :created, message: "Feedback created")
end
end
Why: Consistent JSON responses, automatic error handling, DRY code across API controllers.
Not using ActiveSupport::Concern
Missing Rails DSL features, harder to maintain
Bad Example:
module Authentication
def self.included(base)
base.before_action :require_authentication
end
end
Good Example:
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :current_user
end
end
Why Bad: Misses Rails DSL features like helper_method, harder to add class methods, less idiomatic.
Strong Parameters
Use expect() for strict parameter validation (Rails 8.1+)
Basic Usage:
class FeedbacksController < ApplicationController
def create
@feedback = Feedback.new(feedback_params)
end
private
def feedback_params
params.expect(feedback: [:content, :recipient_email, :sender_name, :ai_enabled])
end
end
Nested Attributes:
def person_params
params.expect(
person: [
:name, :age,
addresses_attributes: [:id, :street, :city, :state, :_destroy]
]
)
end
Array of Scalars:
def tag_params
params.expect(post: [:title, :body, tags: []])
end
Why: Strict validation, raises ActionController::ParameterMissing if required key missing, better for APIs.
Use require().permit() for more lenient validation
Basic Usage:
def feedback_params
params.require(:feedback).permit(:content, :recipient_email, :sender_name, :ai_enabled)
end
Nested with permit():
def article_params
params.require(:article).permit(
:title, :body, :published,
tag_ids: [],
comments_attributes: [:id, :body, :author_name, :_destroy]
)
end
Why: More lenient, returns empty hash if key missing (no exception), traditional Rails approach.
Use different parameter methods for different user roles
Different Permissions by Role:
class UsersController < ApplicationController
def create
@user = User.new(user_params)
end
def admin_update
authorize_admin!
@user = User.find(params[:id])
@user.update(admin_user_params)
end
private
def user_params
params.expect(user: [:name, :email, :password, :password_confirmation])
end
def admin_user_params
params.expect(user: [
:name, :email, :password, :password_confirmation,
:role, :confirmed_at, :banned_at, :admin_notes
])
end
end
Why: Prevents privilege escalation, different permissions for different contexts.
Passing params directly to model (CRITICAL SECURITY VULNERABILITY)
Allows mass assignment of any attribute including admin flags
Bad Example:
def create
@feedback = Feedback.create(params[:feedback])
end
Good Example:
def create
@feedback = Feedback.new(feedback_params)
end
private
def feedback_params
params.expect(feedback: [:content, :recipient_email, :sender_name])
end
Why Bad: CRITICAL security vulnerability allowing privilege escalation, account takeover, data manipulation.
Using permit! on user input (CRITICAL SECURITY VULNERABILITY)
Bypasses all security checks, allows setting ANY attribute
Bad Example:
def user_params
params.require(:user).permit!
end
Good Example:
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation)
end
Why Bad: Complete security bypass, allows privilege escalation, data manipulation, account takeover.
Test controllers with request tests:
class FeedbacksControllerTest < ActionDispatch::IntegrationTest
test "should create feedback" do
assert_difference("Feedback.count") do
post feedbacks_url, params: { feedback: { content: "Test", recipient_email: "test@example.com" } }
end
assert_redirected_to feedback_url(Feedback.last)
end
test "should reject invalid feedback" do
assert_no_difference("Feedback.count") do
post feedbacks_url, params: { feedback: { content: "" } }
end
assert_response :unprocessable_entity
end
test "filters unpermitted parameters" do
post feedbacks_url, params: {
feedback: { content: "Great!", admin: true }
}
assert_nil Feedback.last.admin
end
test "nested resources scoped to parent" do
feedback = feedbacks(:one)
assert_difference("feedback.responses.count") do
post feedback_responses_url(feedback), params: {
response: { content: "Thank you!", author_name: "John" }
}
end
end
end
- rails-ai:models - Model validations, callbacks, associations
- rails-ai:views - Forms, Turbo Frames/Streams
- rails-ai:security - XSS, CSRF, SQL injection prevention
- rails-ai:testing - Controller and integration testing
Official Documentation: