Rails controllers: Ruby on Rails lessons from production
Seven days in, you've built models and migrations. Now comes the layer that sits between your routes and your business logic: the controller. We've seen controllers grow from tight, focused handlers into bloated catch-alls that become impossible to test and maintain. This article distills what we've learned about structuring controllers in production systems handling thousands of requests per day.
One action per route
The simplest rule is often the most violated. Each HTTP route should map to exactly one controller action, and that action should do one thing. When we say "one thing," we mean: accept input, coordinate between the model and view layers, and return a response. Nothing more.
In our early projects, we'd see controllers like this:
class OrdersController < ApplicationController
def process
@order = Order.find(params[:id])
if params[:action_type] == 'cancel'
@order.cancel
send_cancellation_email
redirect_to orders_path
elsif params[:action_type] == 'refund'
@order.refund
send_refund_email
update_accounting_system
redirect_to orders_path
elsif params[:action_type] == 'ship'
@order.ship
notify_warehouse
redirect_to orders_path
end
end
endThis is a code smell. The process action conflates three distinct operations. When something breaks, which operation caused it? When you need to change cancellation logic, you're touching the same method that handles refunds. Testing becomes a nightmare because you must set up the entire controller context just to verify one behavior.
Instead, use separate actions:
class OrdersController < ApplicationController
def cancel
@order = Order.find(params[:id])
@order.cancel
send_cancellation_email
redirect_to orders_path, notice: 'Order cancelled'
end
def refund
@order = Order.find(params[:id])
@order.refund
send_refund_email
update_accounting_system
redirect_to orders_path, notice: 'Order refunded'
end
def ship
@order = Order.find(params[:id])
@order.ship
notify_warehouse
redirect_to orders_path, notice: 'Order shipped'
end
endWith separate actions, each route is responsible for one flow. If the refund logic fails, it's isolated. If you need to add a second email notification for cancellations, you know exactly where to add it. This follows the single responsibility principle and makes your code far easier to reason about.
Strong parameters for input validation
Raw params are dangerous. Users can send anything, and if you're not careful, you'll allow mass-assignment of fields you never intended to expose. We learned this lesson the hard way in an early SaaS project when a user discovered they could escalate their own account role by adding a hidden field to a form.
Strong parameters ensure that only the fields you explicitly whitelist make it into your model updates. Here's what not to do:
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
@user.update(params[:user]) # Dangerous!
redirect_to @user
end
endAn attacker can send params[:user][:role] = 'admin' and bypass your authorization entirely. Use permit to whitelist only the fields you expect:
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
@user.update(user_params)
redirect_to @user
end
private
def user_params
params.require(:user).permit(:email, :first_name, :last_name, :timezone)
end
endNow only those four fields can be updated. If someone tries to send role or admin_flag, Rails silently ignores it. For nested attributes (like addresses within an order), use nested_attributes_for and permit the association with an array of allowed nested fields:
private
def order_params
params.require(:order).permit(:status, :notes, line_items_attributes: [:id, :product_id, :quantity, :_destroy])
endStrong parameters are not a substitute for model validation, but they're your first line of defense against mass-assignment vulnerabilities.
before_action for shared setup
Many actions need the same preliminary work: finding a resource, checking permissions, loading related data. Repeating this logic in every action is error-prone and verbose.
Consider a typical resource controller:
class PostsController < ApplicationController
def show
@post = Post.find(params[:id])
authorize_user!(@post)
end
def edit
@post = Post.find(params[:id])
authorize_user!(@post)
end
def update
@post = Post.find(params[:id])
authorize_user!(@post)
@post.update(post_params)
redirect_to @post
end
def destroy
@post = Post.find(params[:id])
authorize_user!(@post)
@post.destroy
redirect_to posts_path
end
endThis duplication invites bugs. If you forget the authorization check in one action, you've introduced a security hole. Use before_action callbacks to extract common setup:
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :authorize_user!, only: [:edit, :update, :destroy]
def show
end
def edit
end
def update
@post.update(post_params)
redirect_to @post
end
def destroy
@post.destroy
redirect_to posts_path
end
private
def set_post
@post = Post.find(params[:id])
end
def authorize_user!
redirect_to posts_path, alert: 'Not authorized' unless current_user.owns?(@post)
end
def post_params
params.require(:post).permit(:title, :body)
end
endNow the setup is declared once and applied consistently. Actions become cleaner, focusing on the business logic rather than boilerplate. The only and except options let you control exactly which actions the callback applies to, preventing accidental side effects.
respond_to for HTML and JSON
Modern applications often serve both HTML and JSON from the same controller. A single action might render a view for browsers and return structured data for mobile apps or JavaScript frontends. The respond_to method handles this elegantly.
Without it, you end up with conditionals scattered throughout:
class ArticlesController < ApplicationController
def index
@articles = Article.all
if request.format == :json
render json: @articles
else
render :index
end
end
endThis gets unwieldy fast, especially when you add error cases. Use respond_to instead:
class ArticlesController < ApplicationController
def index
@articles = Article.all
respond_to do |format|
format.html
format.json { render json: @articles }
end
end
def show
@article = Article.find(params[:id])
respond_to do |format|
format.html
format.json { render json: @article }
end
end
def create
@article = Article.new(article_params)
respond_to do |format|
if @article.save
format.html { redirect_to @article, notice: 'Article created' }
format.json { render json: @article, status: :created, location: @article }
else
format.html { render :new }
format.json { render json: @article.errors, status: :unprocessable_entity }
end
end
end
endWhen a request comes in with Accept: application/json, the format.json block runs. For regular browser requests, format.html handles it. This keeps the logic organized and makes it clear what each format returns. If your JSON responses need more control, pass serialization options or use a serializer library to keep formatting logic out of the controller.
Common mistakes
- Fat controllers with business logic. Controllers should orchestrate, not implement. Move calculations, validations, and complex queries into models or service objects. Controllers are for routing requests to the right place, not for doing the work.
- Forgetting to authorize before acting. A
before_actionthat checks permissions is only useful if it actually runs. Always verify that authorization happens before you modify data. Many security bugs come from actions that forgot the auth callback. - Inconsistent parameter handling. Some actions use strong parameters, others don't. Some validate in the controller, others rely on the model. Pick a pattern and stick with it across your codebase. Consistency prevents mistakes when someone else (or you in six months) edits the code.
- Overcomplicating respond_to. If you only ever serve HTML, don't add JSON support just in case. But once you start serving both, commit to it fully. Half-done multi-format support is confusing.
- Mixing responsibility levels. A controller action should not call external APIs, send emails, or update third-party systems directly. Extract those into jobs or service objects. Controllers are the entry point, not the workhorse.
Key takeaways
- One action per route keeps your code focused and testable. Avoid bundling multiple operations into a single action.
- Always use strong parameters to whitelist input fields. Never permit raw
paramsinto your models. - Use
before_actioncallbacks for common setup like resource lookup and authorization. This eliminates duplication and ensures consistency. - Use
respond_toto cleanly handle both HTML and JSON responses in the same action, avoiding ugly conditional logic. - Keep controllers thin. Move business logic, validations, and complex queries into models or service objects. Controllers are for routing and coordination, not implementation.






Comments
Loading comments…