mybook

Stage 6: アーキテクチャスタイル — MVC・レイヤード・クリーン

アーキテクチャとは何か

「今まではクラスやコンポーネントの話だった。今日はさらにズームアウトして、システム全体の構造を考える。」

ヒロシはうなずいた。最近、チームで「クリーンアーキテクチャにすべきか」という議論があった。でも何がどう違うのか、よくわからなかった。

「アーキテクチャスタイルは、コードをどう分割し、どう通信させるかの大きな判断枠組み。それぞれに向いている問題と、向いていない問題がある。」

MVC: Railsの基盤

Railsが採用するMVCから始めよう。

Loading diagram...
# 教科書的なRails MVC
class ArticlesController < ApplicationController  # Controller
  def show
    @article = Article.find(params[:id])  # Model呼び出し
    render :show  # View呼び出し
  end
 
  def create
    @article = Article.new(article_params)
    if @article.save
      redirect_to @article
    else
      render :new, status: :unprocessable_entity
    end
  end
end
 
class Article < ApplicationRecord  # Model
  validates :title, presence: true, length: { maximum: 100 }
  validates :body,  presence: true
 
  scope :published, -> { where(status: "published") }
 
  def reading_time_minutes
    (body.split.count / 200.0).ceil
  end
end

MVCの落とし穴: Fat Model, Skinny Controller

「RailsコミュニティはSkinny Controller, Fat Modelを推奨してきた。でも、これが問題を引き起こす。」

# Fat Modelのアンチパターン
class User < ApplicationRecord
  # バリデーション ✓
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
 
  # 関連 ✓
  has_many :orders
  has_many :reviews
 
  # ドメインロジック ✓
  def premium?
    subscription_tier == "premium"
  end
 
  # 外部API呼び出し ✗
  def sync_to_crm
    CrmApi.create_contact(name: name, email: email)
  end
 
  # メール送信 ✗
  def send_welcome_email
    UserMailer.welcome(self).deliver_later
  end
 
  # 複雑なビジネスプロセス ✗
  def upgrade_to_premium!
    update!(subscription_tier: "premium")
    sync_to_crm
    send_upgrade_email
    grant_premium_perks
    notify_sales_team
  end
end

「このUserモデル、何のテストをすれば良いか分かる?CRMのモックも必要、メールのモックも必要。変更の理由が多すぎる。」

レイヤードアーキテクチャ: 責務の層分け

「MVCの問題を解決するのがレイヤードアーキテクチャ。責務を層に分ける。」

Loading diagram...
# Presentation Layer: コントローラーは薄く
class UsersController < ApplicationController
  def create
    result = UserRegistrationService.new(registration_params).call
 
    if result.success?
      render json: { user: UserSerializer.new(result.user).as_json }, status: :created
    else
      render json: { errors: result.errors }, status: :unprocessable_entity
    end
  end
 
  private
 
  def registration_params
    params.require(:user).permit(:name, :email, :password)
  end
end
 
# Application Layer: ユースケースを担う
class UserRegistrationService
  Result = Struct.new(:success?, :user, :errors, keyword_init: true)
 
  def initialize(params)
    @params = params
  end
 
  def call
    user = User.new(@params)
 
    unless user.valid?
      return Result.new(success?: false, errors: user.errors.full_messages)
    end
 
    ActiveRecord::Base.transaction do
      user.save!
      CrmRepository.create_contact(user)
      WelcomeEmailJob.perform_later(user.id)
    end
 
    Result.new(success?: true, user: user)
  rescue ActiveRecord::RecordInvalid => e
    Result.new(success?: false, errors: [e.message])
  end
end
 
# Domain Layer: ドメインロジックのみ
class User < ApplicationRecord
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :name,  presence: true, length: { minimum: 2 }
 
  def premium?
    subscription_tier == "premium"
  end
 
  def display_name
    name.presence || email.split("@").first
  end
end
 
# Infrastructure Layer: 外部依存を隠蔽
class CrmRepository
  def self.create_contact(user)
    CrmApi.new(ENV["CRM_API_KEY"]).contacts.create(
      name: user.name,
      email: user.email,
      source: "webapp"
    )
  rescue CrmApi::Error => e
    Rails.logger.error("CRM sync failed for user #{user.id}: #{e.message}")
    false
  end
end

INFO

Railsでのディレクトリ構成

app/
  controllers/    # Presentation Layer
  services/       # Application Layer
  models/         # Domain Layer
  repositories/   # Infrastructure Layer
  serializers/    # Presentation Layer (レスポンス整形)
  jobs/           # Application Layer (非同期処理)

クリーンアーキテクチャ: 依存関係を内向きに

「レイヤードアーキテクチャの発展形がクリーンアーキテクチャ。最大の特徴は依存関係が常に内向きなこと。」

「つまり、ドメインモデルはRailsを知らない。ActiveRecordを知らない。データベースを知らない。純粋なビジネスロジックだけで成り立つ。」

# ドメインエンティティ: フレームワークに依存しない純粋なRubyクラス
module Domain
  class User
    attr_reader :id, :name, :email, :subscription_tier
 
    def initialize(id:, name:, email:, subscription_tier: "free")
      @id = id
      @name = name
      @email = email
      @subscription_tier = subscription_tier
      validate!
    end
 
    def premium?
      @subscription_tier == "premium"
    end
 
    def upgrade_to_premium
      # 新しいUserエンティティを返す(イミュータブル)
      Domain::User.new(
        id: @id,
        name: @name,
        email: @email,
        subscription_tier: "premium"
      )
    end
 
    private
 
    def validate!
      raise ArgumentError, "名前は必須" if @name.blank?
      raise ArgumentError, "メールは必須" if @email.blank?
    end
  end
end
 
# ユースケース: ドメインロジックとインフラを繋ぐ
module UseCase
  class UpgradeUserToPremium
    def initialize(user_repository:, payment_gateway:, notification_service:)
      @user_repository = user_repository
      @payment_gateway = payment_gateway
      @notification_service = notification_service
    end
 
    def execute(user_id:, payment_token:)
      user = @user_repository.find(user_id)
      raise "ユーザーが見つかりません" unless user
 
      @payment_gateway.charge(amount: 9_800, token: payment_token)
      upgraded_user = user.upgrade_to_premium
      @user_repository.save(upgraded_user)
      @notification_service.notify_premium_upgrade(upgraded_user)
 
      upgraded_user
    end
  end
end
 
# インフラストラクチャ: 具体的な実装
class ActiveRecordUserRepository
  def find(id)
    record = UserRecord.find_by(id: id)
    return nil unless record
    to_domain(record)
  end
 
  def save(user)
    record = UserRecord.find_or_initialize_by(id: user.id)
    record.update!(
      name: user.name,
      email: user.email,
      subscription_tier: user.subscription_tier
    )
    to_domain(record)
  end
 
  private
 
  def to_domain(record)
    Domain::User.new(
      id: record.id,
      name: record.name,
      email: record.email,
      subscription_tier: record.subscription_tier
    )
  end
end

どのアーキテクチャを選ぶか

Loading diagram...

WARNING

オーバーエンジニアリングに注意

クリーンアーキテクチャはファイル数が増え、最初の実装コストが高くなります。シンプルなCRUDアプリに適用するのは過剰です。「今のコードが変更しにくくなってきた」というタイミングで段階的に導入しましょう。

AWSでのアーキテクチャスタイル

インフラレベルでも同じ考え方が適用できる。

# レイヤードアーキテクチャをAWSで表現
# Presentation Layer
CloudFront → ALB → ECS (Railsアプリ)
 
# Application Layer
ECS (Railsアプリ) → SQS → ECS Workers
 
# Domain Layer
ECS内のドメインロジック
 
# Infrastructure Layer
RDS Aurora PostgreSQL  # メインDB
ElastiCache Redis      # キャッシュ・セッション
S3                     # ファイルストレージ
# config/initializers/dependencies.rb
# 依存性の組み立て(Composition Root)
Rails.application.config.after_initialize do
  # 本番環境: 本物の実装を使う
  if Rails.env.production?
    Container = {
      user_repository: ActiveRecordUserRepository.new,
      payment_gateway: StripePaymentGateway.new(ENV["STRIPE_SECRET_KEY"]),
      notification_service: MultiChannelNotificationService.new
    }.freeze
  else
    # テスト/開発環境: フェイク実装
    Container = {
      user_repository: InMemoryUserRepository.new,
      payment_gateway: FakePaymentGateway.new,
      notification_service: LogNotificationService.new
    }.freeze
  end
end

Stage 6 のまとめ

「アーキテクチャスタイルには正解がない。現在の問題に合った構造を選ぶことが大切。」マイが言った。

スタイル向いている場面Railsでの実現
標準MVCシンプルなCRUDRailsデフォルト
レイヤードビジネスロジックが増えてきたService Object, Repository
クリーン複雑なドメイン、長期メンテナンスDomain層の分離、UseCase

「一番危険なのは、アーキテクチャの話で議論ばかりして実装が進まないこと。最初はシンプルに始めて、必要になったときに改善する。」

「次はデータベース設計。どんなアーキテクチャを選んでも、データをどう永続化するかは避けられない。」

ヒロシのチームのアーキテクチャ議論が、急に明確になった気がした。