Controllers
Rails controllers following REST conventions with 7 standard actions, nested resources, skinny controller architecture, reusable concerns, and strong parameters for mass assignment protection.
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
RESTful Actions
Controller:
app/controllers/feedbacks_controller.rb
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 # @feedback set by before_action
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 # @feedback set by before_action
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:
config/routes.rb
resources :feedbacks
Generates all 7 RESTful routes: index, show, new, create, edit, update, destroy
Why: Follows Rails conventions, predictable patterns, automatic route helpers.
Controller:
app/controllers/api/v1/feedbacks_controller.rb
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.
Bad Example:
❌ BAD - Custom action
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:
✅ GOOD - Use nested resource
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
Routes:
config/routes.rb
resources :feedbacks do resource :sending, only: [:create], module: :feedbacks # Singular for single action resources :responses, only: [:index, :create, :destroy], module: :feedbacks # Plural for CRUD end
Generates:
POST /feedbacks/:feedback_id/sending feedbacks/sendings#create
GET /feedbacks/:feedback_id/responses feedbacks/responses#index
POST /feedbacks/:feedback_id/responses feedbacks/responses#create
DELETE /feedbacks/:feedback_id/responses/:id feedbacks/responses#destroy
Controller:
app/controllers/feedbacks/responses_controller.rb
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]) # Scoped to parent
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.
Routes:
resources :projects do resources :tasks, shallow: true, module: :projects end
Generates:
GET /projects/:project_id/tasks projects/tasks#index
POST /projects/:project_id/tasks projects/tasks#create
GET /tasks/:id projects/tasks#show
PATCH /tasks/:id projects/tasks#update
DELETE /tasks/:id projects/tasks#destroy
Controller:
app/controllers/projects/tasks_controller.rb
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.
Bad Example:
❌ BAD - Too deeply nested
resources :organizations do resources :projects do resources :tasks do resources :comments end end end
Results in: /organizations/:org_id/projects/:proj_id/tasks/:task_id/comments
Good Example:
✅ GOOD - Use shallow nesting
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
Bad Example:
❌ BAD - 50+ lines with business logic, validations, API calls
class FeedbacksController < ApplicationController def create @feedback = Feedback.new(feedback_params) @feedback.status = :pending # Business logic @feedback.submitted_at = Time.current
# Manual validation
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
# External API call
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.
Model (validations and defaults):
✅ GOOD - Model handles 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):
✅ GOOD - Service object isolates external dependencies
app/services/feedback_ai_processor.rb
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):
✅ GOOD - 10 lines, only HTTP concerns
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
Concern:
app/controllers/concerns/authentication.rb
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:
app/controllers/feedbacks_controller.rb
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.
Concern:
app/controllers/concerns/api/response_handler.rb
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:
app/controllers/api/feedbacks_controller.rb
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.
Bad Example:
❌ BAD - Manual self.included
module Authentication def self.included(base) base.before_action :require_authentication end end
Good Example:
✅ GOOD - Use ActiveSupport::Concern
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
Basic Usage:
✅ SECURE - Raises if :feedback key missing or wrong structure
class FeedbacksController < ApplicationController def create @feedback = Feedback.new(feedback_params) # ... save and respond ... end
private
def feedback_params params.expect(feedback: [:content, :recipient_email, :sender_name, :ai_enabled]) end end
Nested Attributes:
✅ SECURE - Permit nested attributes
def person_params params.expect( person: [ :name, :age, addresses_attributes: [:id, :street, :city, :state, :_destroy] ] ) end
Model: accepts_nested_attributes_for :addresses, allow_destroy: true
Array of Scalars:
✅ SECURE - Allow array of strings
def tag_params params.expect(post: [:title, :body, tags: []]) end
Accepts: { post: { title: "...", body: "...", tags: ["rails", "ruby"] } }
Why: Strict validation, raises ActionController::ParameterMissing if required key missing, better for APIs.
Basic Usage:
✅ SECURE - Returns empty hash if :feedback missing
def feedback_params params.require(:feedback).permit(:content, :recipient_email, :sender_name, :ai_enabled) end
Nested with permit():
✅ SECURE
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.
Different Permissions by Role:
✅ SECURE - Different permissions by role
class UsersController < ApplicationController def create @user = User.new(user_params) # ... save and respond ... end
def admin_update authorize_admin! @user = User.find(params[:id]) @user.update(admin_user_params) # ... respond ... end
private
def user_params # Regular users can only set basic attributes params.expect(user: [:name, :email, :password, :password_confirmation]) end
def admin_user_params # Admins can set additional privileged attributes 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.
Bad Example:
❌ CRITICAL - Raises ForbiddenAttributesError
def create @feedback = Feedback.create(params[:feedback]) end
Attack: POST /feedbacks
params[:feedback] = {
content: "Great job!",
admin: true, # Attacker sets admin flag
user_id: other_user_id # Attacker changes ownership
}
Good Example:
✅ SECURE - Use strong parameters
def create @feedback = Feedback.new(feedback_params)
... save and respond ...
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.
Bad Example:
❌ CRITICAL - Allows EVERYTHING
def user_params params.require(:user).permit! end
Attack: Attacker can set ANY attribute
params[:user][:admin] = true
params[:user][:confirmed_at] = Time.now
Good Example:
✅ SECURE - Explicitly permit attributes
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.
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 } # admin filtered } assert_nil Feedback.last.admin # Strong parameters blocked this 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
Official Documentation:
-
Rails Guides - Action Controller Overview
-
Rails Guides - Routing
-
Rails Guides - Strong Parameters
-
Rails API - ActiveSupport::Concern
-
Rails Edge Guides - Parameters expect() - Rails 8.1+