mybook

レイヤードアーキテクチャ — 層で分ける

コードが「スパゲッティ」になる瞬間

プロジェクト開始から2週間が経った。カオリがコードレビューをしていると、気になるPRを見つけた。

# 問題のあるコード(実際にレビューで見つかった)
class OrdersController < ApplicationController
  def create
    # コントローラでSQLを直接書いている
    product = Product.where('stock_count > 0 AND price < ?', params[:max_price]).first
    
    # ビジネスロジックがコントローラに書かれている
    if product.stock_count >= params[:quantity].to_i
      order = Order.new(
        user_id: current_user.id,
        product_id: product.id,
        total: product.price * params[:quantity].to_i
      )
      
      # メール送信もここで直接
      OrderMailer.confirmation(order).deliver_now
      
      render json: order
    else
      render json: { error: '在庫不足' }, status: 422
    end
  end
end

「これだと、このロジックは再利用できない。APIからも同じことをしたいとき、また書き直すの?」

カオリはコードに赤いコメントを追加した。「責務を分離しましょう」

INFO

レイヤードアーキテクチャ(層状アーキテクチャ)は、システムを水平な「層」に分割し、各層が特定の責務を持つ設計パターンです。依存関係は上から下(上位層 → 下位層)の一方向のみが原則です。

4層の基本構造

レイヤードアーキテクチャの最も一般的な構成は4層だ。

Loading diagram...

各層の責務は明確に分かれている。

責務Railsでの対応
プレゼンテーションHTTPリクエスト/レスポンスの処理Controllers, Serializers
アプリケーションユースケースのオーケストレーションService Objects, Jobs
ドメインビジネスルール・エンティティModels, Value Objects
インフラ永続化・外部連携Repositories, API Clients

Railsでの実装

カオリはチームにリファクタリング後のコードを示した。

プレゼンテーション層

# app/controllers/api/v1/orders_controller.rb
module Api
  module V1
    class OrdersController < ApplicationController
      def create
        result = CreateOrderUseCase.new(
          user: current_user,
          params: order_params
        ).call
        
        if result.success?
          render json: OrderSerializer.new(result.order), status: :created
        else
          render json: { errors: result.errors }, status: :unprocessable_entity
        end
      end
      
      private
      
      def order_params
        params.require(:order).permit(:product_id, :quantity, :coupon_code)
      end
    end
  end
end

コントローラは「受け取って、委譲して、返す」だけ。ビジネスロジックは一行もない。

アプリケーション層

# app/use_cases/create_order_use_case.rb
class CreateOrderUseCase
  Result = Struct.new(:success?, :order, :errors, keyword_init: true)
  
  def initialize(user:, params:)
    @user = user
    @params = params
    @order_repository = OrderRepository.new
    @inventory_service = InventoryService.new
    @payment_service = PaymentService.new
  end
  
  def call
    product = ProductRepository.new.find(@params[:product_id])
    quantity = @params[:quantity].to_i
    
    # バリデーション
    unless @inventory_service.available?(product, quantity)
      return Result.new(success?: false, errors: ['在庫が不足しています'])
    end
    
    # ビジネス処理のオーケストレーション
    order = nil
    ActiveRecord::Base.transaction do
      order = @order_repository.create(
        user: @user,
        product: product,
        quantity: quantity
      )
      @inventory_service.reserve(product, quantity)
    end
    
    # 副作用(メール送信)はトランザクション外で
    OrderMailer.confirmation(order).deliver_later
    
    Result.new(success?: true, order: order)
  rescue => e
    Result.new(success?: false, errors: [e.message])
  end
end

ドメイン層

# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
  belongs_to :product
  
  validates :quantity, numericality: { greater_than: 0 }
  validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
  
  # ビジネスルール:ドメインに閉じたロジック
  def cancellable?
    status.in?(%w[pending confirmed])
  end
  
  def total_price
    quantity * product.price
  end
  
  def cancel!
    raise "キャンセル不可の状態です" unless cancellable?
    update!(status: 'cancelled')
  end
end
 
# app/models/product.rb
class Product < ApplicationRecord
  validates :name, presence: true
  validates :price, numericality: { greater_than: 0 }
  validates :stock_count, numericality: { greater_than_or_equal_to: 0 }
  
  def in_stock?(quantity = 1)
    stock_count >= quantity
  end
end

インフラストラクチャ層

# app/repositories/order_repository.rb
class OrderRepository
  def find(id)
    Order.find(id)
  end
  
  def create(user:, product:, quantity:)
    Order.create!(
      user: user,
      product: product,
      quantity: quantity,
      status: 'pending',
      unit_price: product.price
    )
  end
  
  def find_by_user(user, page: 1, per: 20)
    Order.where(user: user)
         .includes(:product)
         .order(created_at: :desc)
         .page(page)
         .per(per)
  end
end

リポジトリパターンにより、SQLの詳細がドメイン層から隠蔽される。

層の「ルール」と「違反」

依存の方向

重要な原則: 依存は上から下(プレゼンテーション → アプリケーション → ドメイン → インフラ)の一方向のみ。

# NG: ドメイン層からプレゼンテーション層に依存
class Order < ApplicationRecord
  def to_json_response  # これはシリアライザの責務
    { id: id, status: status, formatted_price:#{total_price}" }
  end
end
 
# OK: ドメインはドメインの責務のみ
class Order < ApplicationRecord
  def total_price
    quantity * unit_price
  end
end
 
# シリアライザ(プレゼンテーション層)が変換を担当
class OrderSerializer
  def as_json
    { id: order.id, status: order.status, price:#{order.total_price}" }
  end
end

「Fat Model, Skinny Controller」の罠

Railsコミュニティでは「Fat Model, Skinny Controller」というスローガンがある。コントローラを薄くするのは正しいが、全てをモデルに押し込めるのは誤りだ。

# NG: ドメインモデルにビジネスフローのロジックが入る
class Order < ApplicationRecord
  def self.create_with_inventory_check_and_payment(user, product, quantity, payment_info)
    # これはService/UseCaseの責務
    transaction do
      # 在庫確認、注文作成、決済処理...
    end
  end
end
 
# OK: モデルはエンティティのルールのみ
class Order < ApplicationRecord
  def cancellable?  # ← これがドメインロジック
    status.in?(%w[pending confirmed])
  end
end

WARNING

「全てをモデルに書く」と「スパゲッティコード」という二項対立ではありません。適切な層(Service Object, Use Case)を使い、責務を分散させましょう。

AWSでの層ごとの関心事

レイヤードアーキテクチャは、インフラレベルでも対応する分離を生む。

Loading diagram...
# インフラの関心事分離の例
# 各層の責務に対応したAWSサービス選定
 
presentation:
  service: ALB + WAF
  concern: HTTPルーティング、DDoS保護、SSL終端
 
application:
  service: ECS Fargate (Rails)
  concern: ビジネスロジックの実行、スケーリング
 
domain:
  # コードレベルの分離(AWSサービスなし)
  
infrastructure:
  database: RDS PostgreSQL  # 永続化
  cache: ElastiCache Redis   # キャッシュ
  email: Amazon SES          # 通知
  storage: S3                # ファイル
  queue: SQS                 # 非同期処理

テスト戦略

層の分離はテストを格段に書きやすくする。

# ドメイン層のテスト(外部依存なし、高速)
RSpec.describe Order do
  describe '#cancellable?' do
    it 'pending の注文はキャンセル可能' do
      order = build(:order, status: 'pending')
      expect(order.cancellable?).to be true
    end
    
    it 'shipped の注文はキャンセル不可' do
      order = build(:order, status: 'shipped')
      expect(order.cancellable?).to be false
    end
  end
end
 
# アプリケーション層のテスト(ドメインモデルは実物、外部サービスはモック)
RSpec.describe CreateOrderUseCase do
  let(:user) { create(:user) }
  let(:product) { create(:product, stock_count: 10, price: 1000) }
  
  it '在庫がある場合、注文を作成できる' do
    result = CreateOrderUseCase.new(
      user: user,
      params: { product_id: product.id, quantity: 2 }
    ).call
    
    expect(result.success?).to be true
    expect(result.order.quantity).to eq 2
    expect(product.reload.stock_count).to eq 8
  end
  
  it '在庫不足の場合、エラーを返す' do
    result = CreateOrderUseCase.new(
      user: user,
      params: { product_id: product.id, quantity: 100 }
    ).call
    
    expect(result.success?).to be false
    expect(result.errors).to include('在庫が不足しています')
  end
end

レイヤードアーキテクチャの限界

カオリは正直に限界も伝えた。

「レイヤードアーキテクチャで責務は分離できる。でも、依然として全ての層がデータベースに強く依存している」

# 問題: ドメイン層がRails(ActiveRecord)に依存している
class Order < ApplicationRecord  # ActiveRecord::Baseに依存
  # ドメインロジックとDBの実装が混在
end

これを解決するのが次の章で学ぶ「クリーンアーキテクチャ」だ。依存の方向をより厳密に制御し、ドメインコードを完全に外部依存から解放する。


「まず全員がService ObjectとRepository を使うルールを守れるようになってから、次のステップを考えよう」

カオリは満足した。チームのコードレビューで指摘することが減り、新機能の追加が格段に速くなった気がした。

次章では、依存の方向をさらに厳密に制御する「クリーンアーキテクチャ」へ進む。