MVC in Rails
Model–View–Controller is the architectural split Rails canonised: the Model owns data and business rules, the View renders state, the Controller routes requests to actions. Done well, you can swap views (HTML, JSON, Turbo Streams) without touching models, and refactor models without breaking templates.
A worked MVC slice in Rails 7
EXAMPLE
# Routes — the entry into the controller
# config/routes.rb
Rails.application.routes.draw do
resources :orders, only: %i[index show new create] do
member { post :pay, :ship }
end
end
# Model — owns persistence, validation, and business rules
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :customer
has_many :items, dependent: :destroy
enum status: { new: "new", paid: "paid", shipped: "shipped", cancelled: "cancelled" }
validates :total_cents, numericality: { greater_than_or_equal_to: 0 }
validates :customer, presence: true
scope :recent, ->(d = 30.days.ago) { where("created_at > ?", d) }
scope :open, -> { where(status: %w[new paid]) }
def pay!
update!(status: "paid", paid_at: Time.current)
OrderMailer.with(order: self).paid.deliver_later
end
def ship!(tracking)
update!(status: "shipped", tracking: tracking, shipped_at: Time.current)
OrderMailer.with(order: self).shipped.deliver_later
end
def total_aud = (total_cents / 100.0).round(2)
end
# Controller — orchestrates a request: load, mutate, render
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
before_action :authenticate_user!
before_action :set_order, only: %i[show pay ship]
def index
@orders = current_user.orders.recent.includes(:items).page(params[:page])
end
def show; end
def new; @order = current_user.orders.build end
def create
@order = current_user.orders.build(order_params)
if @order.save
respond_to do |fmt|
fmt.html { redirect_to @order, notice: "Order placed" }
fmt.json { render json: @order, status: :created }
fmt.turbo_stream # renders create.turbo_stream.erb
end
else
render :new, status: :unprocessable_entity
end
end
def pay
@order.pay!
redirect_to @order, notice: "Paid"
end
def ship
@order.ship!(params.require(:tracking))
redirect_to @order, notice: "Shipped"
end
private
def set_order; @order = current_user.orders.find(params[:id]) end
def order_params; params.require(:order).permit(:customer_id, :total_cents, :notes) end
end
# View — formatting, layout, no business logic
# app/views/orders/index.html.erb
<h1>My Orders</h1>
<ul>
<% @orders.each do |o| %>
<li>
<%= link_to "##{o.id}", o %> — $<%= number_with_precision(o.total_aud, precision: 2) %>
— <%= o.status %>
<% if o.new? %>
<%= button_to "Pay", pay_order_path(o), method: :post %>
<% elsif o.paid? %>
<%= form_with(url: ship_order_path(o)) do |f| %>
<%= f.text_field :tracking, placeholder: "Tracking #" %>
<%= f.submit "Ship" %>
<% end %>
<% end %>
</li>
<% end %>
</ul>
# When the controller grows — extract a Service Object
# app/services/place_order.rb
class PlaceOrder
def self.call(customer:, items:)
Order.transaction do
o = customer.orders.create!(total_cents: items.sum { |i| i[:price_cents] * i[:qty] })
items.each { |i| o.items.create!(i) }
Inventory.reserve(items)
o
end
end
end
# Then the controller is one line:
# def create; @order = PlaceOrder.call(customer: current_user, items: params[:items]); end
Why it matters
When a controller action grows past about 10 lines, extract a service / interactor object. The MVC split is conceptual; in real Rails apps the boundary that pays its rent over time is "thin controller, thin model, fat service" — each piece does one thing, and tests can exercise the service without booting a full request cycle.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# Models — db tables / business rules (app/models) # Views — ERB templates (app/views) # Controllers — accept request, call model, render view (app/controllers)Try it Yourself »
Discussion
Loading…