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

MVC in Rails: concepts, trade-offs and production lessons

How a request flows through Rails.

Expona Admin7 min read

MVC in Rails: concepts, trade-offs and production lessons

After your first Rails app runs, the real work begins: understanding how to organize code so it scales without becoming a tangled mess. A request doesn't magically become a response—it flows through a predictable, intentional architecture that either works for you or against you, depending on how well you respect it. Today we'll map that journey and cover the trade-offs that separate maintainable codebases from the ones that make you cringe six months later.

Request flow: how Rails stitches it together

Every HTTP request follows the same path in Rails. Understanding each step is non-negotiable if you're going to build anything beyond toy projects.

  1. Router matches the URL — Your config/routes.rb file defines patterns. When a request arrives, Rails checks each route in order until one matches. The router extracts parameters from the URL and decides which controller and action to invoke.
  2. Controller action runs — The controller receives the request, orchestrates what needs to happen, and prepares data for the view. It does not compute business logic itself.
  3. Model reads or writes data — The controller asks the model for data. The model handles all persistence logic, validation, and queries.
  4. View renders HTML or JSON — Once the controller has data, it hands it to a view template. The template generates the response body (HTML, JSON, or other formats).
  5. Response returns to the browser — Rails sends headers and the rendered body back to the client.

Let's trace a concrete example. A user visits /posts/42:

ruby
GET /posts/42

Rails router matches this to the posts#show action:

ruby
# config/routes.rb
resources :posts

The router calls PostsController#show with params[:id] = 42:

ruby
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
  end
end

The controller asks the model for a post:

ruby
# app/models/post.rb
class Post < ApplicationRecord
  has_many :comments
  validates :title, presence: true
end

The model queries the database and returns a Post instance. The controller stashes it in an instance variable @post, which is automatically available to the view:

ruby
<!-- app/views/posts/show.html.erb -->
<h1><%= @post.title %></h1>
<p><%= @post.body %></p>

Rails renders the template and sends HTML back to the browser. This flow is not arbitrary—it's baked into Rails' conventions, and respecting it matters.

Fat models vs. thin models: where does logic live?

A persistent argument in Rails teams is where business logic belongs. Many teams start by putting everything in the model, then regret it later.

The intuition behind "fat models" is reasonable: models are closer to data, so logic that touches data should live there. But "fat" quickly becomes "bloated." A User model that handles password hashing, email validation, authentication tokens, permissions, and notification logic is doing too much and becomes hard to test, hard to change, and hard to reason about.

In production systems we've worked on, the sweet spot is this: keep models focused on domain logic and data integrity. Push side effects and orchestration elsewhere.

Consider an e-commerce system where a payment is processed:

ruby
# Bad: controller does too much
def create_order
  @order = Order.new(order_params)
  @order.status = "pending"
  
  charge_result = Stripe.charge(
    amount: @order.total,
    token: params[:stripe_token]
  )
  
  if charge_result.success?
    @order.save
    send_confirmation_email(@order.customer)
    log_analytics(@order)
  else
    @order.errors.add(:base, "Payment failed")
  end
  
  render :show
end

This controller is a soup: payment logic, email, analytics, all mixed with Rails semantics. Testing it requires mocking Stripe and email services. Changing the email service means changing the controller.

Instead, extract the workflow:

ruby
# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :customer
  has_many :line_items
  
  validates :customer_id, presence: true
  validates :total, presence: true, numericality: { greater_than: 0 }
end

# app/services/process_order.rb
class ProcessOrder
  def initialize(order, stripe_token)
    @order = order
    @stripe_token = stripe_token
  end
  
  def call
    charge = charge_payment
    return false unless charge.success?
    
    @order.update(status: "confirmed", stripe_id: charge.id)
    notify_customer
    log_metrics
    true
  end
  
  private
  
  def charge_payment
    Stripe::Charge.create(
      amount: @order.total,
      currency: "usd",
      source: @stripe_token
    )
  end
  
  def notify_customer
    OrderMailer.confirmation(@order).deliver_later
  end
  
  def log_metrics
    Analytics.event("order_created", order_id: @order.id)
  end
end

# app/controllers/orders_controller.rb
def create
  @order = Order.new(order_params)
  
  if ProcessOrder.new(@order, params[:stripe_token]).call
    redirect_to @order, notice: "Order placed"
  else
    render :new, status: :unprocessable_entity
  end
end

Now the model is clean—it knows about orders, validation, and relationships. The service orchestrates the workflow. The controller coordinates. Tests are simpler because each class has one job. This isn't dogma; it's pragmatism.

Thin controllers: coordinate, do not compute

Controllers should be boring. Their job is to:

  • Receive the request and extract parameters.
  • Call models or services to do work.
  • Handle the response: render a view or redirect.

Controllers should not:

  • Do complex calculations.
  • Query the database multiple times with custom logic.
  • Format data for output (let the view do that).
  • Know about external services like payment gateways.

A thin controller for a report might look like:

ruby
class ReportsController < ApplicationController
  def monthly_sales
    @report = SalesReport.generate(
      start_date: params[:start_date],
      end_date: params[:end_date]
    )
    render :monthly_sales
  end
end

The controller doesn't know how the report is calculated. It asks a service, gets a result, renders it. If the calculation logic changes, the controller doesn't care.

In production, we've seen controllers shrink from 200 lines to 20 by extracting logic properly. Tests also run faster because you're not starting a Rails app to test a calculation.

Views: templates and partials

Views are for presentation. They should be thin too—display data, not fetch it, not compute it.

ERB templates are convenient but easy to overuse. Keep them readable:

ruby
<!-- app/views/posts/show.html.erb -->
<article>
  <h1><%= @post.title %></h1>
  <time><%= @post.created_at.strftime("%B %d, %Y") %></time>
  
  <div class="content">
    <%= simple_format(@post.body) %>
  </div>
  
  <%= render "comments/list", comments: @post.comments %>
</article>

Use partials to avoid duplication. Partials are views that render inside other views:

ruby
<!-- app/views/comments/_list.html.erb -->
<section class="comments">
  <h2>Comments (<%= comments.count %>)</h2>
  <ul>
    <% comments.each do |comment| %>
      <li>
        <strong><%= comment.author %></strong>
        <p><%= comment.body %></p>
      </li>
    <% end %>
  </ul>
</section>

Partials also work with collections, which Rails optimizes automatically:

ruby
<%= render "comments/comment", collection: @post.comments, as: :comment %>

This renders the comment partial once for each comment, passing each as a local variable. It's cleaner and faster than looping in the view.

Common mistakes

  • N+1 queries in views: Looping through a collection and accessing a related model without eager loading. Use includes or preload in the controller. If you find yourself writing <% @posts.each do |post| %> <%= post.author.name %> without eager loading, you're querying the database once per post.
  • Logic in templates: Controllers and models should provide the data in the shape the view needs. Don't calculate or filter in ERB. If you're using complex conditionals or loops in a template, move that logic to a helper or presenter.
  • Controllers that don't redirect: After a POST, PUT, or DELETE, redirect. Rendering directly means the user can refresh and resubmit. The pattern is: handle the request, redirect to a GET route, render the result.
  • Models that do everything: A model that saves data, sends emails, logs events, and validates against external APIs is a liability. Extract services for workflows that involve multiple concerns.
  • Forgetting the MVC boundary: Rails makes it easy to blur lines. You can query in a view, put logic in a controller, or have a model that knows about HTTP. It will work until it doesn't, usually in production under load.

Key takeaways

  • Respect the request flow: router → controller → model → view → response. Conventions connect everything; breaking them has a cost.
  • Keep models focused on domain logic and data integrity. Treat them as the source of truth about what an order, user, or post is and how it behaves.
  • Controllers coordinate, they don't compute. Receive a request, call services or models, handle the response.
  • Extract complex workflows into service objects. A single action that touches payment gateways, sends emails, and logs events should be a service, not a controller method.
  • Views display data; they don't fetch or calculate it. Use partials to reduce duplication and helpers for formatting logic.
  • Test each layer independently. Controllers test at the HTTP level, models test behavior, services test workflows. This is faster and more reliable than testing everything end-to-end.
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