MVCパターン — Railsの心臓部
「3つの部屋」の話
「MVCって聞いたことある?」
翌朝、アヤカがホワイトボードに3つの四角を描いた。
「ありますけど、なんとなくしか……」
「じゃあ、レストランで考えてみよう」アヤカはマーカーを走らせた。「お客さんがウェイターに注文する。ウェイターがキッチンに伝える。シェフが料理を作る。ウェイターが料理を運ぶ。——この3つの役割、わかる?」
「ウェイターがコントローラー、シェフがモデル……ビューは?」
「お客さんのテーブルに届く料理の見た目、つまりプレゼンテーション層がビュー」
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
endINFO
モデルは「頭脳」。ビジネスロジック、バリデーション、データの永続化を担当する。コントローラーやビューはモデルを通じてデータを操作する。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
endWARNING
コントローラーは「薄く」保つ。モデルの呼び出し、パラメータの整理、ビューへの受け渡しだけを行う。ビジネスロジックをコントローラーに書くと、プロローグで見たようなファットコントローラーになる。目安は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
endGET /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
endOrderCreationService というクラスが、複雑な処理を引き受ける。コントローラーは「誰に仕事を頼むか」「結果をどう画面に返すか」だけを担当する。
# 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; endAWS でのMVCアーキテクチャ
| AWSサービス | MVC対応 | 役割 |
|---|---|---|
| CloudFront | View | 静的アセット配信・HTML キャッシュ |
| ALB | Controller前段 | リクエスト振り分け |
| ECS Fargate | Controller + View | Railsアプリ本体 |
| RDS | Model | データ永続化 |
| ElastiCache | Model(キャッシュ) | 高速データアクセス |
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
endRailsが自動で守る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
endINFO
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をさらに発展させたレイヤードアーキテクチャを学びます。サービス層・リポジトリ層を加えることで、より大規模なアプリケーションに対応します。