mybook

クリーンアーキテクチャ — 依存の方向を制御する

「なぜドメインがDBに依存しているの?」

カオリは夜遅く、技術書を読んでいた。ロバート・C・マーティン(Uncle Bob)の言葉が目に止まった。

The architecture of a system should tell you what the system does, not what framework it uses.

「システムのアーキテクチャは、何をするかを語るべきであって、どのフレームワークを使うかを語るべきではない」

レイヤードアーキテクチャを導入したばかりなのに、すでに限界が見えてきた。ドメインモデルは ApplicationRecord を継承しており、データベース(ActiveRecord)に依存している。「もしデータベースをPostgreSQLからMongoDBに変えたい場合、ドメインコードも全部変わってしまう」

クリーンアーキテクチャは、この問題を「依存の方向を逆転させる」ことで解決する。

INFO

クリーンアーキテクチャは、依存関係が常に「内側」(ドメイン)に向かうように設計します。外側の詳細(DB、フレームワーク、UI)がドメインに依存するのであって、その逆はありません。

同心円モデル

クリーンアーキテクチャは4つの同心円で表現される。

Loading diagram...

依存の黄金律: 依存は常に外側から内側へ。内側のコードは外側の存在を知らない。

各層の役割

エンティティ層(最内層)

最も重要で、最も変化しない。純粋なRubyオブジェクト。フレームワーク非依存。

# app/domain/entities/order.rb
module Domain
  module Entities
    class Order
      attr_reader :id, :user_id, :product_id, :quantity, :status, :unit_price
      
      VALID_STATUSES = %w[pending confirmed shipped delivered cancelled].freeze
      
      def initialize(id:, user_id:, product_id:, quantity:, status:, unit_price:)
        @id = id
        @user_id = user_id
        @product_id = product_id
        @quantity = quantity
        @status = status
        @unit_price = unit_price
        
        validate!
      end
      
      def total_price
        quantity * unit_price
      end
      
      def cancellable?
        status.in?(%w[pending confirmed])
      end
      
      def cancel
        raise Domain::Errors::BusinessRuleViolation, "キャンセル不可の状態" unless cancellable?
        # 新しい状態のエンティティを返す(イミュータブル設計)
        self.class.new(**to_h.merge(status: 'cancelled'))
      end
      
      private
      
      def validate!
        raise ArgumentError, "数量は1以上" unless quantity.positive?
        raise ArgumentError, "無効なステータス" unless VALID_STATUSES.include?(status)
      end
      
      def to_h
        { id:, user_id:, product_id:, quantity:, status:, unit_price: }
      end
    end
  end
end

このエンティティは ActiveRecord を継承していない。データベースの存在を知らない。

ユースケース層

アプリケーション固有のビジネスロジック。エンティティを組み合わせてユースケースを実現する。

# app/use_cases/create_order.rb
module UseCases
  class CreateOrder
    # 依存逆転の原則:具体的な実装ではなくインターフェース(抽象)に依存する
    def initialize(
      order_repository:,     # IOrderRepository インターフェース
      product_repository:,   # IProductRepository インターフェース
      inventory_service:,    # IInventoryService インターフェース
      event_publisher:       # IEventPublisher インターフェース
    )
      @order_repository = order_repository
      @product_repository = product_repository
      @inventory_service = inventory_service
      @event_publisher = event_publisher
    end
    
    Result = Struct.new(:order, :error, keyword_init: true) do
      def success? = error.nil?
    end
    
    def call(user_id:, product_id:, quantity:)
      product = @product_repository.find(product_id)
      
      return Result.new(error: '商品が見つかりません') unless product
      return Result.new(error: '在庫不足') unless @inventory_service.available?(product, quantity)
      
      order = Domain::Entities::Order.new(
        id: nil,
        user_id: user_id,
        product_id: product_id,
        quantity: quantity,
        status: 'pending',
        unit_price: product.price
      )
      
      saved_order = @order_repository.save(order)
      @inventory_service.reserve(product, quantity)
      @event_publisher.publish('order.created', order_id: saved_order.id)
      
      Result.new(order: saved_order)
    rescue Domain::Errors::BusinessRuleViolation => e
      Result.new(error: e.message)
    end
  end
end

ユースケース層は「何をするか」を定義するが、「どのDBを使うか」は知らない。

インターフェースアダプター層

外側と内側を橋渡しする。Railsのコントローラ、リポジトリの実装がここに入る。

# app/adapters/repositories/active_record_order_repository.rb
module Adapters
  module Repositories
    class ActiveRecordOrderRepository
      # ここでActiveRecordモデル(外側)とドメインエンティティ(内側)を変換する
      
      def find(id)
        record = OrderRecord.find_by(id: id)
        return nil unless record
        to_entity(record)
      end
      
      def save(order)
        record = if order.id
          OrderRecord.find(order.id)
        else
          OrderRecord.new
        end
        
        record.assign_attributes(
          user_id: order.user_id,
          product_id: order.product_id,
          quantity: order.quantity,
          status: order.status,
          unit_price: order.unit_price
        )
        record.save!
        
        to_entity(record)
      end
      
      private
      
      def to_entity(record)
        Domain::Entities::Order.new(
          id: record.id,
          user_id: record.user_id,
          product_id: record.product_id,
          quantity: record.quantity,
          status: record.status,
          unit_price: record.unit_price
        )
      end
    end
  end
end
 
# ActiveRecordモデルは単なるデータ永続化の道具(ビジネスロジックなし)
class OrderRecord < ApplicationRecord
  self.table_name = 'orders'
end

フレームワーク層(最外層)

Railsのコントローラは、依存を組み立てて(DIコンテナ)ユースケースを呼び出す。

# app/controllers/api/v1/orders_controller.rb
module Api
  module V1
    class OrdersController < ApplicationController
      def create
        # 依存を組み立てる(本来はDIコンテナが担当)
        use_case = UseCases::CreateOrder.new(
          order_repository: Adapters::Repositories::ActiveRecordOrderRepository.new,
          product_repository: Adapters::Repositories::ActiveRecordProductRepository.new,
          inventory_service: Adapters::Services::InventoryService.new,
          event_publisher: Adapters::Events::SnsEventPublisher.new
        )
        
        result = use_case.call(
          user_id: current_user.id,
          product_id: params[:product_id],
          quantity: params[:quantity].to_i
        )
        
        if result.success?
          render json: present_order(result.order), status: :created
        else
          render json: { error: result.error }, status: :unprocessable_entity
        end
      end
      
      private
      
      def present_order(order)
        {
          id: order.id,
          status: order.status,
          total_price: order.total_price,
          quantity: order.quantity
        }
      end
    end
  end
end

依存逆転の原則(DIP)

クリーンアーキテクチャの核心は依存逆転の原則だ。

# NG: ユースケースが具体的な実装に依存
class CreateOrder
  def initialize
    @repository = ActiveRecordOrderRepository.new  # 具体的な実装に依存
  end
end
 
# OK: 抽象(インターフェース)に依存(Rubyではduck typingで実現)
class CreateOrder
  def initialize(order_repository:)
    @order_repository = order_repository  # 何でもいい。saveとfindに応答できれば
  end
end
 
# テスト時はインメモリの実装を注入できる
class InMemoryOrderRepository
  def initialize
    @store = {}
  end
  
  def save(order)
    @store[order.object_id] = order
    order
  end
  
  def find(id)
    @store[id]
  end
end

テストの強力さ

クリーンアーキテクチャの最大のメリットはテストだ。

# ドメインエンティティのテスト(DB不要、高速、完全独立)
RSpec.describe Domain::Entities::Order do
  subject(:order) do
    described_class.new(
      id: 1, user_id: 1, product_id: 1,
      quantity: 2, status: 'pending', unit_price: 1000
    )
  end
  
  it 'total_price を計算できる' do
    expect(order.total_price).to eq 2000
  end
  
  it 'pending 状態はキャンセル可能' do
    expect(order.cancellable?).to be true
  end
end
 
# ユースケースのテスト(インメモリリポジトリを使用、DB不要)
RSpec.describe UseCases::CreateOrder do
  let(:order_repository) { InMemoryOrderRepository.new }
  let(:product_repository) { InMemoryProductRepository.new }
  let(:inventory_service) { FakeInventoryService.new(available: true) }
  let(:event_publisher) { FakeEventPublisher.new }
  
  subject(:use_case) do
    described_class.new(
      order_repository: order_repository,
      product_repository: product_repository,
      inventory_service: inventory_service,
      event_publisher: event_publisher
    )
  end
  
  before do
    product_repository.add(id: 1, price: 1000, name: 'テスト商品')
  end
  
  it '注文を作成し、イベントを発行する' do
    result = use_case.call(user_id: 1, product_id: 1, quantity: 2)
    
    expect(result.success?).to be true
    expect(result.order.total_price).to eq 2000
    expect(event_publisher.events).to include('order.created')
  end
end

テストがDBなしで動く。テストスイート全体が30秒以内で完了する。

AWSでの展開

Loading diagram...

クリーンアーキテクチャにより、SNSをEventBridgeに変えても、ユースケース層のコードは変わらない。アダプター(実装)だけを差し替えればいい。

# 本番環境:SNSを使うアダプター
Adapters::Events::SnsEventPublisher.new(topic_arn: ENV['SNS_TOPIC_ARN'])
 
# テスト環境:メモリに記録するフェイク
FakeEventPublisher.new
 
# 将来:EventBridgeに変えてもユースケースは無変更
Adapters::Events::EventBridgePublisher.new(event_bus: ENV['EVENT_BUS_NAME'])

クリーンアーキテクチャのコスト

カオリは正直に伝えた。「この設計にはコストがある」

WARNING

クリーンアーキテクチャは、初期の実装コストが高いです。ファイル数が増え、コードを追う時間も長くなります。チームが十分に理解していないと、逆に複雑さだけが増す結果になります。

コスト:
- ファイル数が増える(Entity, UseCase, Repository, Adapter...)
- 学習曲線が急(チーム全員の理解が必要)
- CRUD単純な機能でもオーバーエンジニアリングになりやすい

ベネフィット:
- ドメインロジックが外部依存から完全独立
- テストが高速・容易
- フレームワークやDBを差し替えられる
- 長期的な保守性が高い

「今の私たちに、このレベルの厳密さが必要か?」カオリは問いかけた。「少し緩やかなアプローチも見てみよう」

次章で学ぶヘキサゴナルアーキテクチャは、同じ思想をより実用的な形で実現する。