mybook

MVCパターン — Railsの心臓部

「3つの部屋」の話

「MVCって聞いたことある?」

翌朝、アヤカがホワイトボードに3つの四角を描いた。

「ありますけど、なんとなくしか……」

「じゃあ、レストランで考えてみよう」アヤカはマーカーを走らせた。「お客さんがウェイターに注文する。ウェイターがキッチンに伝える。シェフが料理を作る。ウェイターが料理を運ぶ。——この3つの役割、わかる?」

「ウェイターがコントローラー、シェフがモデル……ビューは?」

「お客さんのテーブルに届く料理の見た目、つまりプレゼンテーション層がビュー」

Loading diagram...

MVCはMartIn Fowlerの著書「Patterns of Enterprise Application Architecture」でも取り上げられ、1970年代のSmalltalkにまで起源を持つ歴史あるパターンだ。Railsはこれを「設定より規約」の哲学でフレームワークに組み込んだ。

MVCそれぞれの責任

Model — ビジネスロジックとデータ

モデルはアプリケーションの核心部分。データの保存と取得、ビジネスルールの実装が責務。

# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
  has_many :order_items, dependent: :destroy
  has_many :products, through: :order_items
 
  validates :user, presence: true
  validates :total_amount, numericality: { greater_than: 0 }
  validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
 
  enum :status, {
    pending: "pending",
    confirmed: "confirmed",
    shipped: "shipped",
    delivered: "delivered",
    cancelled: "cancelled"
  }
 
  # ビジネスロジックはモデルに
  def confirm!
    raise "注文は pending 状態でなければなりません" unless pending?
    update!(status: :confirmed, confirmed_at: Time.current)
  end
 
  def cancel!(reason: nil)
    raise "配送済みの注文はキャンセルできません" if shipped? || delivered?
    update!(status: :cancelled, cancelled_at: Time.current, cancel_reason: reason)
  end
 
  def total_items_count
    order_items.sum(:quantity)
  end
 
  def discounted_total(coupon)
    return total_amount unless coupon&.valid_for?(self)
    coupon.apply_to(total_amount)
  end
 
  def shippable?
    confirmed? && order_items.all? { |item| item.product.in_stock? }
  end
end
# app/models/product.rb
class Product < ApplicationRecord
  validates :name, presence: true
  validates :price, numericality: { greater_than: 0 }
  validates :stock, numericality: { greater_than_or_equal_to: 0 }
 
  scope :active,      -> { where(active: true) }
  scope :in_stock,    -> { where("stock > 0") }
  scope :popular,     -> { order(order_count: :desc) }
 
  def in_stock?(quantity = 1)
    stock >= quantity
  end
 
  def reserve!(quantity)
    raise "在庫不足: #{name}(残り#{stock}個)" unless in_stock?(quantity)
    with_lock do
      decrement!(:stock, quantity)
    end
  end
 
  def price_with_tax
    (price * 1.1).ceil
  end
end
# app/models/coupon.rb
class Coupon < ApplicationRecord
  validates :code, presence: true, uniqueness: true
  validates :discount_type, inclusion: { in: %w[percentage fixed] }
 
  def active?
    !expired? && remaining_uses > 0
  end
 
  def apply_to(amount)
    case discount_type
    when "percentage"
      discounted = amount * (1 - discount_value / 100.0)
      [discounted, 0].max
    when "fixed"
      [amount - discount_value, 0].max
    else
      amount
    end
  end
 
  def valid_for?(order)
    active? && minimum_amount <= order.total_amount
  end
 
  private
 
  def expired?
    expires_at < Time.current
  end
end

INFO

モデルは「頭脳」。ビジネスロジック、バリデーション、データの永続化を担当する。コントローラーやビューはモデルを通じてデータを操作する。HTTPの概念(paramsなど)をモデルに持ち込まない。

Controller — リクエストのオーケストレーター

コントローラーはHTTPリクエストを受け取り、モデルに仕事を依頼し、ビューにデータを渡す「指揮者」。

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  before_action :authenticate_user!
  before_action :set_order, only: [:show, :confirm, :cancel]
 
  def index
    @orders = current_user.orders
                          .includes(:order_items, :products)
                          .order(created_at: :desc)
                          .page(params[:page])
                          .per(20)
  end
 
  def show
    # @order は before_action でセット済み
  end
 
  def new
    @order = Order.new
  end
 
  def create
    @order = OrderCreationService.new(
      user: current_user,
      params: order_params
    ).call
 
    if @order.persisted?
      redirect_to @order, notice: "注文が完了しました"
    else
      render :new, status: :unprocessable_entity
    end
  end
 
  def confirm
    @order.confirm!
    redirect_to @order, notice: "注文を確認しました"
  rescue => e
    redirect_to @order, alert: e.message
  end
 
  def cancel
    @order.cancel!(reason: params[:reason])
    redirect_to orders_path, notice: "注文をキャンセルしました"
  rescue => e
    redirect_to @order, alert: e.message
  end
 
  private
 
  def set_order
    @order = current_user.orders.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    redirect_to orders_path, alert: "注文が見つかりません"
  end
 
  def order_params
    params.require(:order).permit(
      :coupon_code,
      items: [:product_id, :quantity]
    )
  end
end

WARNING

コントローラーは「薄く」保つ。モデルの呼び出し、パラメータの整理、ビューへの受け渡しだけを行う。ビジネスロジックをコントローラーに書くと、プロローグで見たようなファットコントローラーになる。目安は1アクション20行以下。

View — プレゼンテーション層

ビューはユーザーに見せる画面を担当。ERBテンプレートでHTMLを生成する。

<%# app/views/orders/index.html.erb %>
<div class="orders-container">
  <header class="page-header">
    <h1>注文一覧</h1>
    <%= link_to "新規注文", new_order_path, class: "btn btn-primary" %>
  </header>
 
  <% if @orders.empty? %>
    <div class="empty-state">
      <p>まだ注文がありません</p>
      <%= link_to "商品を見る", products_path %>
    </div>
  <% else %>
    <div class="order-list">
      <% @orders.each do |order| %>
        <article class="order-card">
          <div class="order-header">
            <span class="order-id">注文 #<%= order.id %></span>
            <span class="status-badge status-<%= order.status %>">
              <%= t("orders.status.#{order.status}") %>
            </span>
          </div>
          <div class="order-summary">
            <span><%= order.total_items_count %>点</span>
            <span><%= number_to_currency(order.total_amount) %></span>
            <span><%= l(order.created_at, format: :short) %></span>
          </div>
          <%= link_to "詳細", order_path(order), class: "btn btn-outline" %>
        </article>
      <% end %>
    </div>
    <%= paginate @orders %>
  <% end %>
</div>
<%# app/views/orders/show.html.erb %>
<div class="order-detail">
  <header class="order-header">
    <h1>注文 #<%= @order.id %></h1>
    <span class="status-badge status-<%= @order.status %>">
      <%= t("orders.status.#{@order.status}") %>
    </span>
  </header>
 
  <section class="order-items">
    <h2>注文内容</h2>
    <% @order.order_items.each do |item| %>
      <div class="order-item">
        <%= image_tag item.product.thumbnail_url, class: "product-thumbnail" %>
        <div class="item-info">
          <span class="product-name"><%= item.product.name %></span>
          <span class="quantity"><%= item.quantity %>個</span>
        </div>
        <span class="price"><%= number_to_currency(item.price * item.quantity) %></span>
      </div>
    <% end %>
  </section>
 
  <footer class="order-total">
    <div class="total-row">
      <span>小計</span>
      <span><%= number_to_currency(@order.total_amount) %></span>
    </div>
    <strong class="grand-total">合計: <%= number_to_currency(@order.total_amount) %></strong>
  </footer>
 
  <div class="order-actions">
    <% if @order.pending? %>
      <%= button_to "注文を確認する", confirm_order_path(@order),
          method: :patch, class: "btn btn-primary" %>
      <%= button_to "キャンセル", cancel_order_path(@order),
          method: :patch, data: { confirm: "本当にキャンセルしますか?" },
          class: "btn btn-danger" %>
    <% end %>
  </div>
</div>

Rails ルーティングとMVC

# config/routes.rb
Rails.application.routes.draw do
  resources :orders do
    member do
      patch :confirm
      patch :cancel
    end
    collection do
      get :search
    end
  end
end
GET    /orders             → orders#index   (注文一覧)
GET    /orders/new         → orders#new     (注文フォーム)
POST   /orders             → orders#create  (注文作成)
GET    /orders/:id         → orders#show    (注文詳細)
PATCH  /orders/:id/confirm → orders#confirm (注文確認)
PATCH  /orders/:id/cancel  → orders#cancel  (注文キャンセル)
GET    /orders/search      → orders#search  (注文検索)

コントローラーを薄くする:サービスオブジェクトへの抽出

プロローグのファットコントローラーを、MVCの文脈で整理すると何が起きるか。

# Before: コントローラーが全部やる(1つのactionで50行)
def create
  # ユーザー確認、在庫チェック、合計計算、クーポン、保存、メール、ポイント、Slack
  # すべてここに書かれている...
end
 
# After: コントローラーは指揮だけ(1アクション15行以下)
def create
  @order = OrderCreationService.new(
    user: current_user,
    params: order_params
  ).call
 
  if @order.persisted?
    redirect_to @order, notice: "注文が完了しました"
  else
    render :new, status: :unprocessable_entity
  end
end

OrderCreationService というクラスが、複雑な処理を引き受ける。コントローラーは「誰に仕事を頼むか」「結果をどう画面に返すか」だけを担当する。

# app/services/order_creation_service.rb
class OrderCreationService
  def initialize(user:, params:)
    @user = user
    @items = params[:items] || []
    @coupon_code = params[:coupon_code]
  end
 
  def call
    validate_stock!
    coupon = resolve_coupon
    total = calculate_total(coupon)
 
    ActiveRecord::Base.transaction do
      order = create_order(total)
      reserve_inventory!
      notify(order)
      order
    end
  rescue ServiceError => e
    Order.new.tap { |o| o.errors.add(:base, e.message) }
  end
 
  private
 
  def validate_stock!
    @items.each do |item|
      product = Product.find(item[:product_id])
      raise ServiceError, "#{product.name}の在庫が不足しています" unless product.in_stock?(item[:quantity].to_i)
    end
  end
 
  def resolve_coupon
    return nil if @coupon_code.blank?
    coupon = Coupon.find_by(code: @coupon_code)
    raise ServiceError, "クーポンが無効です" unless coupon&.active?
    coupon
  end
 
  def calculate_total(coupon)
    subtotal = @items.sum do |item|
      product = Product.find(item[:product_id])
      product.price * item[:quantity].to_i
    end
    coupon ? coupon.apply_to(subtotal) : subtotal
  end
 
  def create_order(total)
    order = Order.create!(user: @user, total_amount: total, status: :pending)
    @items.each do |item|
      product = Product.find(item[:product_id])
      order.order_items.create!(product: product, quantity: item[:quantity], price: product.price)
    end
    order
  end
 
  def reserve_inventory!
    @items.each do |item|
      product = Product.find(item[:product_id])
      product.reserve!(item[:quantity].to_i)
    end
  end
 
  def notify(order)
    OrderMailer.confirmation(order).deliver_later
  end
end
 
class ServiceError < StandardError; end

AWS でのMVCアーキテクチャ

Loading diagram...
AWSサービスMVC対応役割
CloudFrontView静的アセット配信・HTML キャッシュ
ALBController前段リクエスト振り分け
ECS FargateController + ViewRailsアプリ本体
RDSModelデータ永続化
ElastiCacheModel(キャッシュ)高速データアクセス

ECSのコンテナ設定でRailsを動かす場合:

# config/environments/production.rb
Rails.application.configure do
  # セッションストアをElastiCacheに
  config.session_store :redis_store,
    servers: [ENV["REDIS_URL"]],
    expire_after: 90.minutes,
    key: "_myapp_session"
 
  # キャッシュもElastiCacheに
  config.cache_store = :redis_cache_store, {
    url: ENV["REDIS_URL"],
    expires_in: 1.hour
  }
 
  # ActiveJobはSidekiq経由でSQSへ
  config.active_job.queue_adapter = :sidekiq
end

Railsが自動で守るMVC

Railsは規約によってMVCを強制する。

app/
├── controllers/      ← Controller層
│   ├── application_controller.rb
│   └── orders_controller.rb
├── models/           ← Model層
│   ├── order.rb
│   ├── product.rb
│   └── coupon.rb
├── views/            ← View層
│   └── orders/
│       ├── index.html.erb
│       ├── show.html.erb
│       └── new.html.erb
└── services/         ← サービスオブジェクト(MVCの拡張)
    └── order_creation_service.rb

「Railsを使えば自然にMVCになる?」ユウキが聞いた。

なろうとする、が正確。レールは引いてあるけど、脱線する人もいる」アヤカが答えた。「コントローラーにSQLを直接書いたり、ビューでビジネスロジックを計算したり。パターンを知っていれば、そういうコードを書いた瞬間に気づける」

テスト戦略

MVCを適切に分離するとテストが書きやすくなる。

# spec/models/order_spec.rb
RSpec.describe Order, type: :model do
  describe "#confirm!" do
    context "pending 状態の場合" do
      let(:order) { create(:order, status: :pending) }
 
      it "confirmed に遷移する" do
        order.confirm!
        expect(order.reload.status).to eq("confirmed")
      end
 
      it "confirmed_at が記録される" do
        freeze_time do
          order.confirm!
          expect(order.reload.confirmed_at).to eq(Time.current)
        end
      end
    end
 
    context "pending 以外の状態の場合" do
      let(:order) { create(:order, status: :shipped) }
 
      it "例外を発生させる" do
        expect { order.confirm! }.to raise_error(RuntimeError, /pending/)
      end
    end
  end
end
# spec/controllers/orders_controller_spec.rb
RSpec.describe OrdersController, type: :controller do
  let(:user) { create(:user) }
  before { sign_in user }
 
  describe "GET #index" do
    it "ユーザーの注文一覧を返す" do
      orders = create_list(:order, 3, user: user)
      get :index
      expect(assigns(:orders)).to match_array(orders)
    end
  end
 
  describe "POST #create" do
    let(:valid_params) do
      { order: { items: [{ product_id: create(:product).id, quantity: 1 }] } }
    end
 
    it "注文を作成してリダイレクトする" do
      expect {
        post :create, params: valid_params
      }.to change(Order, :count).by(1)
 
      expect(response).to redirect_to(Order.last)
    end
  end
end

INFO

OrderCreationService はサービスオブジェクトと呼ばれる設計パターン。コントローラーに書けないビジネスロジックの置き場所として、MVCに自然にフィットする。3章のレイヤードアーキテクチャで詳しく学ぶ。

リファクタリング前後の比較

プロローグの orders_controller.rb と比べてみよう。

観点Before(ファットコントローラー)After(MVC + サービス)
createアクション行数80行15行
テストの書きやすさ困難(HTTPが必要)容易(サービスのみテスト可)
ビジネスロジックの場所コントローラーモデル+サービス
変更の影響範囲全体各レイヤー内
新人の理解しやすさ困難比較的容易

まとめ

「MVCはシンプルに見えて奥が深い」アヤカが言った。

「どういうこと?」

「Model・View・Controllerって名前は簡単。でも何がどこに属するかの判断が難しい。クーポン計算はモデル? サービス? コントローラー? ——その判断基準を持つことが、次の章で学ぶこと」

責任置かないもの
Modelデータ・ビジネスルールHTTPの概念(paramsなど)
Viewプレゼンテーションロジック・計算
Controllerリクエスト受付・調整ビジネスロジック・SQL

各層は独立して変更できる。ビューを完全にReactに書き換えても、モデルは変わらない。RailsからGraphQL APIに変えても、モデルのビジネスロジックは流用できる。それがMVCの本質的な価値だ。


次章では、MVCをさらに発展させたレイヤードアーキテクチャを学びます。サービス層・リポジトリ層を加えることで、より大規模なアプリケーションに対応します。