Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 007 / 100

Rails controllers: Ruby on Rails lessons from production

Actions, params and responses.

Expona Admin7 min read

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:

ruby
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
end

This 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:

ruby
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
end

With 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:

ruby
class UsersController < ApplicationController
  def update
    @user = User.find(params[:id])
    @user.update(params[:user])  # Dangerous!
    redirect_to @user
  end
end

An attacker can send params[:user][:role] = 'admin' and bypass your authorization entirely. Use permit to whitelist only the fields you expect:

ruby
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
end

Now 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:

ruby
private

def order_params
  params.require(:order).permit(:status, :notes, line_items_attributes: [:id, :product_id, :quantity, :_destroy])
end

Strong 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:

ruby
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
end

This 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:

ruby
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
end

Now 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:

ruby
class ArticlesController < ApplicationController
  def index
    @articles = Article.all
    
    if request.format == :json
      render json: @articles
    else
      render :index
    end
  end
end

This gets unwieldy fast, especially when you add error cases. Use respond_to instead:

ruby
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
end

When 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_action that 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 params into your models.
  • Use before_action callbacks for common setup like resource lookup and authorization. This eliminates duplication and ensures consistency.
  • Use respond_to to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles