Skip to content

Rails初心者のためのMVCアーキテクチャ完全理解ガイド

はじめに

MVCアーキテクチャは、Railsフレームワークの根幹を成す設計パターンです。Model、View、Controllerの3つの層に責任を分離することで、保守性が高く拡張しやすいWebアプリケーションを構築できます。本記事では、MVCの基本概念から実践的な実装まで、Rails初心者でも理解できるよう詳しく解説します。

学習目標

  • MVCアーキテクチャの基本概念を理解する
  • 各層の責任と相互作用を把握する
  • 実際のRailsアプリケーションでMVCを適切に実装する
  • よくあるアンチパターンを避ける方法を学ぶ

MVCアーキテクチャの基本概念

1. MVCとは何か

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Model     │    │ Controller  │    │    View     │
│             │    │             │    │             │
│ ・データ管理 │◄──►│ ・ユーザー入力│◄──►│ ・表示処理   │
│ ・ビジネス   │    │ ・データ変換 │    │ ・UI描画     │
│   ロジック   │    │ ・制御処理   │    │ ・ユーザー   │
│             │    │             │    │   インタラクション │
└─────────────┘    └─────────────┘    └─────────────┘

Model(モデル): データの管理とビジネスロジック
View(ビュー): ユーザーインターフェースの表示
Controller(コントローラー): ユーザー入力の処理と制御

2. 責任の分離

ruby
# 悪い例:全ての処理をControllerに書く
class PostsController < ApplicationController
  def index
    # データベースから直接データを取得(Modelの責任)
    @posts = Post.where("created_at > ?", 1.week.ago)
                 .where(status: 'published')
                 .order(created_at: :desc)
    
    # ビジネスロジックをControllerに記述(Modelの責任)
    @posts = @posts.select do |post|
      post.comments.count > 5 && 
      post.author.premium? &&
      post.content.length > 500
    end
    
    # HTML生成処理をControllerに記述(Viewの責任)
    @html = "<div class='posts'>"
    @posts.each do |post|
      @html += "<div class='post'>#{post.title}</div>"
    end
    @html += "</div>"
  end
end
ruby
# 良い例:責任を適切に分離
class PostsController < ApplicationController
  def index
    # Controllerは制御処理のみ
    @posts = Post.popular_posts
  end
end

# Model: データとビジネスロジック
class Post < ApplicationRecord
  scope :published, -> { where(status: 'published') }
  scope :recent, -> { where("created_at > ?", 1.week.ago) }
  scope :popular_posts, -> { 
    published.recent
             .joins(:comments, :author)
             .where(authors: { premium: true })
             .where("LENGTH(content) > 500")
             .group("posts.id")
             .having("COUNT(comments.id) > 5")
             .order(created_at: :desc)
  }
end
erb
<!-- View: 表示処理のみ -->
<div class="posts">
  <% @posts.each do |post| %>
    <div class="post">
      <h2><%= post.title %></h2>
      <p><%= truncate(post.content, length: 200) %></p>
    </div>
  <% end %>
</div>

Model層の詳細実装

1. Active Recordモデルの基本

ruby
# app/models/post.rb
class Post < ApplicationRecord
  # 関連付け
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy
  has_many :tags, through: :post_tags
  has_one_attached :featured_image
  
  # バリデーション
  validates :title, presence: true, length: { maximum: 100 }
  validates :content, presence: true, length: { minimum: 100 }
  validates :status, inclusion: { in: %w[draft published archived] }
  
  # スコープ(クエリの再利用)
  scope :published, -> { where(status: 'published') }
  scope :by_author, ->(author) { where(author: author) }
  scope :recent, ->(days = 7) { where("created_at > ?", days.days.ago) }
  
  # コールバック
  before_save :generate_slug
  after_create :notify_followers
  
  # インスタンスメソッド(ビジネスロジック)
  def published?
    status == 'published'
  end
  
  def reading_time
    words_per_minute = 200
    word_count = content.split.size
    (word_count / words_per_minute.to_f).ceil
  end
  
  def can_be_edited_by?(user)
    author == user || user.admin?
  end
  
  # クラスメソッド
  def self.trending
    joins(:comments)
      .where(comments: { created_at: 1.week.ago.. })
      .group('posts.id')
      .order('COUNT(comments.id) DESC')
      .limit(10)
  end
  
  private
  
  def generate_slug
    self.slug = title.parameterize if title.present?
  end
  
  def notify_followers
    NotifyFollowersJob.perform_later(self) if published?
  end
end

2. モデルの設計パターン

ruby
# Concern を使った共通機能の抽出
# app/models/concerns/commentable.rb
module Commentable
  extend ActiveSupport::Concern
  
  included do
    has_many :comments, as: :commentable, dependent: :destroy
  end
  
  def comments_count
    comments.count
  end
  
  def latest_comments(limit = 5)
    comments.recent.limit(limit)
  end
end

# app/models/concerns/publishable.rb
module Publishable
  extend ActiveSupport::Concern
  
  included do
    validates :status, inclusion: { in: %w[draft published archived] }
    scope :published, -> { where(status: 'published') }
    scope :draft, -> { where(status: 'draft') }
  end
  
  def published?
    status == 'published'
  end
  
  def publish!
    update!(status: 'published', published_at: Time.current)
  end
end

# モデルでConcernを使用
class Post < ApplicationRecord
  include Commentable
  include Publishable
  
  # その他の実装...
end

class Article < ApplicationRecord
  include Commentable
  include Publishable
  
  # その他の実装...
end

3. サービスオブジェクトパターン

ruby
# app/services/post_publishing_service.rb
class PostPublishingService
  def initialize(post, user)
    @post = post
    @user = user
  end
  
  def call
    return failure('権限がありません') unless can_publish?
    return failure('既に公開済みです') if @post.published?
    
    ActiveRecord::Base.transaction do
      @post.publish!
      create_activity
      notify_subscribers
      update_search_index
    end
    
    success('投稿を公開しました')
  rescue => e
    failure("公開に失敗しました: #{e.message}")
  end
  
  private
  
  def can_publish?
    @post.can_be_edited_by?(@user)
  end
  
  def create_activity
    Activity.create!(
      user: @user,
      action: 'publish',
      target: @post
    )
  end
  
  def notify_subscribers
    @user.followers.find_each do |follower|
      PostNotificationJob.perform_later(@post, follower)
    end
  end
  
  def update_search_index
    @post.reindex if defined?(Searchkick)
  end
  
  def success(message)
    OpenStruct.new(success?: true, message: message)
  end
  
  def failure(message)
    OpenStruct.new(success?: false, message: message)
  end
end

# Controllerでの使用
class PostsController < ApplicationController
  def publish
    @post = current_user.posts.find(params[:id])
    result = PostPublishingService.new(@post, current_user).call
    
    if result.success?
      redirect_to @post, notice: result.message
    else
      redirect_to @post, alert: result.message
    end
  end
end

View層の詳細実装

1. ERBテンプレートの基本

erb
<!-- app/views/posts/show.html.erb -->
<article class="post">
  <header class="post-header">
    <h1 class="post-title"><%= @post.title %></h1>
    
    <div class="post-meta">
      <span class="author">
        著者: <%= link_to @post.author.name, @post.author %>
      </span>
      
      <time class="published-date" datetime="<%= @post.published_at&.iso8601 %>">
        <%= @post.published_at&.strftime("%Y年%m月%d日") %>
      </time>
      
      <span class="reading-time">
        読了時間: 約<%= @post.reading_time %>分
      </span>
    </div>
    
    <% if @post.featured_image.attached? %>
      <div class="featured-image">
        <%= image_tag @post.featured_image, alt: @post.title, class: "img-fluid" %>
      </div>
    <% end %>
  </header>
  
  <div class="post-content">
    <%= simple_format(@post.content) %>
  </div>
  
  <footer class="post-footer">
    <div class="tags">
      <% @post.tags.each do |tag| %>
        <%= link_to tag.name, posts_path(tag: tag.name), class: "tag" %>
      <% end %>
    </div>
    
    <div class="actions">
      <% if @post.can_be_edited_by?(current_user) %>
        <%= link_to "編集", edit_post_path(@post), class: "btn btn-primary" %>
        <%= link_to "削除", @post, method: :delete, 
                    confirm: "本当に削除しますか?", 
                    class: "btn btn-danger" %>
      <% end %>
    </div>
  </footer>
</article>

<!-- コメント機能 -->
<section class="comments-section">
  <h3>コメント (<%= @post.comments.count %>)</h3>
  
  <% if user_signed_in? %>
    <%= render 'comments/form', post: @post, comment: Comment.new %>
  <% else %>
    <p><%= link_to "ログイン", new_user_session_path %>してコメントを投稿</p>
  <% end %>
  
  <div class="comments">
    <%= render @post.comments.includes(:user).order(created_at: :desc) %>
  </div>
</section>

2. パーシャル(部分テンプレート)の活用

erb
<!-- app/views/posts/_post.html.erb -->
<article class="post-card">
  <% if post.featured_image.attached? %>
    <div class="post-image">
      <%= link_to post do %>
        <%= image_tag post.featured_image.variant(resize_to_limit: [300, 200]), 
                      alt: post.title %>
      <% end %>
    </div>
  <% end %>
  
  <div class="post-content">
    <h2 class="post-title">
      <%= link_to post.title, post %>
    </h2>
    
    <p class="post-excerpt">
      <%= truncate(strip_tags(post.content), length: 150) %>
    </p>
    
    <div class="post-meta">
      <span class="author"><%= post.author.name %></span>
      <span class="date"><%= post.created_at.strftime("%Y/%m/%d") %></span>
      <span class="comments-count">
        <%= pluralize(post.comments.count, 'コメント') %>
      </span>
    </div>
  </div>
</article>

<!-- app/views/posts/index.html.erb -->
<div class="posts-grid">
  <%= render @posts %>
</div>

<!-- または明示的にパーシャルを指定 -->
<div class="posts-list">
  <%= render partial: 'post', collection: @posts, as: :post %>
</div>

3. ヘルパーメソッドの活用

ruby
# app/helpers/posts_helper.rb
module PostsHelper
  def post_status_badge(post)
    case post.status
    when 'published'
      content_tag :span, '公開', class: 'badge badge-success'
    when 'draft'
      content_tag :span, '下書き', class: 'badge badge-warning'
    when 'archived'
      content_tag :span, 'アーカイブ', class: 'badge badge-secondary'
    end
  end
  
  def post_reading_time(post)
    minutes = post.reading_time
    if minutes < 1
      '1分未満'
    else
      "約#{minutes}分"
    end
  end
  
  def formatted_post_date(post)
    return '未公開' unless post.published_at
    
    if post.published_at > 1.week.ago
      time_ago_in_words(post.published_at) + '前'
    else
      post.published_at.strftime('%Y年%m月%d日')
    end
  end
  
  def post_share_url(post, platform)
    case platform
    when :twitter
      "https://twitter.com/intent/tweet?text=#{CGI.escape(post.title)}&url=#{CGI.escape(post_url(post))}"
    when :facebook
      "https://www.facebook.com/sharer/sharer.php?u=#{CGI.escape(post_url(post))}"
    when :line
      "https://social-plugins.line.me/lineit/share?url=#{CGI.escape(post_url(post))}"
    end
  end
end

# app/helpers/application_helper.rb
module ApplicationHelper
  def page_title(title = nil)
    if title.present?
      "#{title} | My Blog"
    else
      "My Blog"
    end
  end
  
  def current_page_class(path)
    'active' if current_page?(path)
  end
  
  def flash_message_class(type)
    case type.to_sym
    when :notice then 'alert-success'
    when :alert then 'alert-danger'
    when :warning then 'alert-warning'
    else 'alert-info'
    end
  end
end

Controller層の詳細実装

1. RESTfulコントローラーの実装

ruby
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
  before_action :set_post, only: [:show, :edit, :update, :destroy, :publish]
  before_action :ensure_owner, only: [:edit, :update, :destroy, :publish]
  
  # GET /posts
  def index
    @posts = Post.published
                 .includes(:author, :tags, featured_image_attachment: :blob)
                 .page(params[:page])
                 .per(10)
    
    # 検索機能
    if params[:search].present?
      @posts = @posts.where("title ILIKE ? OR content ILIKE ?", 
                           "%#{params[:search]}%", "%#{params[:search]}%")
    end
    
    # タグフィルタ
    if params[:tag].present?
      @posts = @posts.joins(:tags).where(tags: { name: params[:tag] })
    end
    
    # 作者フィルタ
    if params[:author_id].present?
      @posts = @posts.where(author_id: params[:author_id])
    end
  end
  
  # GET /posts/:id
  def show
    # 閲覧数のカウントアップ(非同期で実行)
    IncrementViewCountJob.perform_later(@post)
    
    # 関連記事の取得
    @related_posts = @post.related_posts.limit(3)
  end
  
  # GET /posts/new
  def new
    @post = current_user.posts.build
  end
  
  # POST /posts
  def create
    @post = current_user.posts.build(post_params)
    
    if @post.save
      redirect_to @post, notice: '投稿を作成しました。'
    else
      render :new, status: :unprocessable_entity
    end
  end
  
  # GET /posts/:id/edit
  def edit
  end
  
  # PATCH/PUT /posts/:id
  def update
    if @post.update(post_params)
      redirect_to @post, notice: '投稿を更新しました。'
    else
      render :edit, status: :unprocessable_entity
    end
  end
  
  # DELETE /posts/:id
  def destroy
    @post.destroy
    redirect_to posts_path, notice: '投稿を削除しました。'
  end
  
  # PATCH /posts/:id/publish
  def publish
    result = PostPublishingService.new(@post, current_user).call
    
    if result.success?
      redirect_to @post, notice: result.message
    else
      redirect_to @post, alert: result.message
    end
  end
  
  private
  
  def set_post
    @post = Post.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    redirect_to posts_path, alert: '投稿が見つかりません。'
  end
  
  def ensure_owner
    unless @post.can_be_edited_by?(current_user)
      redirect_to posts_path, alert: '権限がありません。'
    end
  end
  
  def post_params
    params.require(:post).permit(:title, :content, :status, :featured_image, tag_ids: [])
  end
end

2. 名前空間とネストしたリソース

ruby
# config/routes.rb
Rails.application.routes.draw do
  root 'posts#index'
  
  resources :posts do
    member do
      patch :publish
    end
    
    resources :comments, except: [:index, :show]
  end
  
  namespace :admin do
    resources :posts do
      member do
        patch :approve
        patch :reject
      end
    end
    
    resources :users
    root 'dashboard#index'
  end
end

# app/controllers/admin/posts_controller.rb
class Admin::PostsController < Admin::BaseController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
    
    case params[:status]
    when 'pending'
      @posts = @posts.where(status: 'pending')
    when 'published'
      @posts = @posts.published
    end
  end
  
  def approve
    @post = Post.find(params[:id])
    @post.update!(status: 'published', published_at: Time.current)
    redirect_to admin_posts_path, notice: '投稿を承認しました。'
  end
end

# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_post
  before_action :set_comment, only: [:edit, :update, :destroy]
  
  def create
    @comment = @post.comments.build(comment_params)
    @comment.user = current_user
    
    if @comment.save
      redirect_to @post, notice: 'コメントを投稿しました。'
    else
      redirect_to @post, alert: 'コメントの投稿に失敗しました。'
    end
  end
  
  private
  
  def set_post
    @post = Post.find(params[:post_id])
  end
  
  def set_comment
    @comment = @post.comments.find(params[:id])
  end
  
  def comment_params
    params.require(:comment).permit(:content)
  end
end

実践的なMVCパターン

1. フォーム処理の完全な実装

ruby
# app/models/post.rb
class Post < ApplicationRecord
  attr_accessor :tag_names
  
  after_save :update_tags, if: :tag_names_changed?
  
  private
  
  def tag_names_changed?
    @tag_names.present?
  end
  
  def update_tags
    self.tags = @tag_names.split(',').map(&:strip).map do |name|
      Tag.find_or_create_by(name: name.downcase)
    end
  end
end
erb
<!-- app/views/posts/_form.html.erb -->
<%= form_with model: post, local: true, class: "post-form" do |form| %>
  <% if post.errors.any? %>
    <div class="alert alert-danger">
      <h4><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h4>
      <ul>
        <% post.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <%= form.label :title, class: "form-label" %>
    <%= form.text_field :title, class: "form-control" %>
  </div>

  <div class="form-group">
    <%= form.label :content, class: "form-label" %>
    <%= form.text_area :content, rows: 10, class: "form-control" %>
  </div>

  <div class="form-group">
    <%= form.label :status, class: "form-label" %>
    <%= form.select :status, 
          options_for_select([
            ['下書き', 'draft'],
            ['公開', 'published'],
            ['アーカイブ', 'archived']
          ], post.status), 
          {}, 
          { class: "form-control" } %>
  </div>

  <div class="form-group">
    <%= form.label :featured_image, "アイキャッチ画像", class: "form-label" %>
    <%= form.file_field :featured_image, class: "form-control", accept: "image/*" %>
  </div>

  <div class="form-group">
    <%= form.label :tag_names, "タグ(カンマ区切り)", class: "form-label" %>
    <%= form.text_field :tag_names, 
          value: post.tags.pluck(:name).join(', '), 
          class: "form-control",
          placeholder: "rails, programming, tutorial" %>
  </div>

  <div class="form-actions">
    <%= form.submit class: "btn btn-primary" %>
    <%= link_to "キャンセル", post_path(post), class: "btn btn-secondary" %>
  </div>
<% end %>

2. バリデーションエラーのハンドリング

ruby
# app/controllers/posts_controller.rb
def create
  @post = current_user.posts.build(post_params)
  @post.tag_names = params[:post][:tag_names]
  
  if @post.save
    redirect_to @post, notice: '投稿を作成しました。'
  else
    # エラー時は新規作成フォームを再表示
    render :new, status: :unprocessable_entity
  end
end

def update
  @post.assign_attributes(post_params)
  @post.tag_names = params[:post][:tag_names]
  
  if @post.save
    redirect_to @post, notice: '投稿を更新しました。'
  else
    # エラー時は編集フォームを再表示
    render :edit, status: :unprocessable_entity
  end
end

よくあるアンチパターンと解決方法

1. Fat Controller(太ったコントローラー)

ruby
# 悪い例:ControllerにビジネスロジックやView処理を記述
class PostsController < ApplicationController
  def index
    @posts = Post.all
    
    # ビジネスロジックをControllerに書いている
    @posts = @posts.select do |post|
      post.comments.count > 5 && 
      post.author.premium_user? &&
      post.published_at > 1.month.ago
    end
    
    # View処理をControllerに書いている
    @posts_html = ""
    @posts.each do |post|
      @posts_html += "<div class='post'>"
      @posts_html += "<h2>#{post.title}</h2>"
      @posts_html += "<p>#{post.content[0, 100]}...</p>"
      @posts_html += "</div>"
    end
  end
end

# 良い例:責任を適切に分離
class PostsController < ApplicationController
  def index
    @posts = Post.popular_posts
  end
end

class Post < ApplicationRecord
  scope :popular_posts, -> {
    joins(:comments, :author)
      .where(authors: { premium: true })
      .where('posts.published_at > ?', 1.month.ago)
      .group('posts.id')
      .having('COUNT(comments.id) > 5')
  }
end

2. Fat Model(太ったモデル)

ruby
# 悪い例:Modelに様々な責任を詰め込む
class User < ApplicationRecord
  def send_welcome_email
    UserMailer.welcome_email(self).deliver_now
  end
  
  def generate_report
    # 複雑なレポート生成処理
  end
  
  def sync_with_external_service
    # 外部サービスとの同期処理
  end
  
  def process_payment
    # 決済処理
  end
end

# 良い例:サービスオブジェクトで責任を分離
class User < ApplicationRecord
  # ユーザーのデータとコアなビジネスロジックのみ
  
  def full_name
    "#{first_name} #{last_name}"
  end
  
  def premium?
    subscription&.active?
  end
end

class UserWelcomeService
  def self.call(user)
    UserMailer.welcome_email(user).deliver_now
  end
end

class UserReportService
  def initialize(user)
    @user = user
  end
  
  def generate
    # レポート生成処理
  end
end

3. View に複雑なロジックを記述

erb
<!-- 悪い例:Viewに複雑な条件分岐やループ処理 -->
<% @posts.each do |post| %>
  <div class="post">
    <h2><%= post.title %></h2>
    
    <% if post.author.premium? && post.comments.count > 5 %>
      <span class="badge popular">人気投稿</span>
    <% elsif post.created_at > 1.week.ago %>
      <span class="badge new">新着</span>
    <% end %>
    
    <% if post.featured_image.attached? %>
      <% if post.featured_image.blob.content_type.start_with?('image/') %>
        <%= image_tag post.featured_image.variant(resize_to_limit: [300, 200]) %>
      <% end %>
    <% end %>
    
    <!-- さらに複雑な条件分岐... -->
  </div>
<% end %>
ruby
# 良い例:ヘルパーメソッドで処理を切り出し
module PostsHelper
  def post_badge(post)
    if post.popular?
      content_tag :span, '人気投稿', class: 'badge popular'
    elsif post.recent?
      content_tag :span, '新着', class: 'badge new'
    end
  end
  
  def post_featured_image(post)
    return unless post.featured_image.attached?
    return unless post.featured_image.blob.content_type.start_with?('image/')
    
    image_tag post.featured_image.variant(resize_to_limit: [300, 200]), 
              alt: post.title
  end
end

class Post < ApplicationRecord
  def popular?
    author.premium? && comments.count > 5
  end
  
  def recent?
    created_at > 1.week.ago
  end
end
erb
<!-- 良い例:シンプルなView -->
<% @posts.each do |post| %>
  <div class="post">
    <h2><%= post.title %></h2>
    <%= post_badge(post) %>
    <%= post_featured_image(post) %>
  </div>
<% end %>

まとめ

MVCアーキテクチャは、Railsアプリケーションの基礎となる重要な設計パターンです。適切な責任分離を行うことで、以下のメリットが得られます:

主要なポイント:

  • Model: データの管理とビジネスロジック
  • View: ユーザーインターフェースの表示
  • Controller: ユーザー入力の処理と制御

ベストプラクティス:

  • 各層の責任を明確に分離する
  • Fat ControllerやFat Modelを避ける
  • サービスオブジェクトで複雑なビジネスロジックを分離
  • ヘルパーメソッドでView処理を整理
  • 適切なバリデーションとエラーハンドリング

MVCアーキテクチャを正しく理解し実装することで、保守性が高く拡張しやすいRailsアプリケーションを構築できます。

AI が自動生成した技術記事をまとめたテックブログ