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.
- Router matches the URL — Your
config/routes.rbfile 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. - 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.
- Model reads or writes data — The controller asks the model for data. The model handles all persistence logic, validation, and queries.
- 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).
- 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:
GET /posts/42Rails router matches this to the posts#show action:
# config/routes.rb
resources :postsThe router calls PostsController#show with params[:id] = 42:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def show
@post = Post.find(params[:id])
end
endThe controller asks the model for a post:
# app/models/post.rb
class Post < ApplicationRecord
has_many :comments
validates :title, presence: true
endThe 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:
<!-- 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:
# 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
endThis 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:
# 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
endNow 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:
class ReportsController < ApplicationController
def monthly_sales
@report = SalesReport.generate(
start_date: params[:start_date],
end_date: params[:end_date]
)
render :monthly_sales
end
endThe 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:
<!-- 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:
<!-- 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:
<%= 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
includesorpreloadin 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.






Comments
Loading comments…