iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…