mybook

オニオンアーキテクチャ — ドメインを中心に

「クリーンと何が違うの?」

勉強会でユウが質問した。「クリーンアーキテクチャ、ヘキサゴナル、オニオン…全部同じじゃないですか?ポートとアダプターで外部依存を分離する、って話ですよね?」

正直な疑問だ。カオリも最初は同じように感じた。

「大きな思想は共通している。でも微妙な違いがある。特にオニオンはドメインモデルをより中心に置いていて、DDDとの親和性が高い」

Jeffrey Palermoが2008年に提唱したオニオンアーキテクチャは、「ドメインモデル」を絶対の中心に置く設計だ。

INFO

オニオンアーキテクチャの最大の特徴は、「ドメインモデル」が最も内側の層に位置し、インターフェース(ポート)もドメイン層に定義されることです。外側の層は常に内側に依存しますが、内側は外側を知りません。

オニオンの層構造

Loading diagram...

クリーンアーキテクチャとの最大の違いは、リポジトリのインターフェースがドメイン層に定義される点だ。

# オニオンアーキテクチャでのレイヤー配置
 
# 最内層: ドメインモデル(インターフェース定義も含む)
module Domain
  # エンティティ
  class Order; end
  class Product; end
  
  # 値オブジェクト
  class Money; end
  class OrderStatus; end
  
  # リポジトリインターフェース(ドメイン層に定義!)
  module Repositories
    module OrderRepository; end  # インターフェース
    module ProductRepository; end
  end
  
  # ドメインサービス
  module Services
    class PricingService; end    # エンティティをまたぐ計算
    class InventoryPolicy; end   # 在庫ルール
  end
end

ドメインモデル層の実装

エンティティ

# app/domain/entities/order.rb
module Domain
  module Entities
    class Order
      attr_reader :id, :user_id, :items, :status, :created_at
      
      def initialize(id:, user_id:, items:, status: OrderStatus::PENDING, created_at: Time.current)
        @id = id
        @user_id = user_id
        @items = items
        @status = status
        @created_at = created_at
      end
      
      def total
        items.sum(&:subtotal)
      end
      
      def confirm
        raise Domain::Errors::InvalidTransition unless status == OrderStatus::PENDING
        self.class.new(**to_attributes.merge(status: OrderStatus::CONFIRMED))
      end
      
      def cancel
        raise Domain::Errors::InvalidTransition unless [
          OrderStatus::PENDING, OrderStatus::CONFIRMED
        ].include?(status)
        
        self.class.new(**to_attributes.merge(status: OrderStatus::CANCELLED))
      end
      
      private
      
      def to_attributes
        { id:, user_id:, items:, status:, created_at: }
      end
    end
  end
end

値オブジェクト

# app/domain/value_objects/money.rb
module Domain
  module ValueObjects
    class Money
      include Comparable
      
      attr_reader :amount, :currency
      
      def initialize(amount, currency = 'JPY')
        raise ArgumentError, "金額は0以上" if amount.negative?
        @amount = amount.freeze
        @currency = currency.freeze
      end
      
      def +(other)
        raise TypeError, "通貨が異なります" unless currency == other.currency
        Money.new(amount + other.amount, currency)
      end
      
      def *(quantity)
        Money.new(amount * quantity, currency)
      end
      
      def <=>(other)
        return nil unless other.is_a?(Money) && currency == other.currency
        amount <=> other.amount
      end
      
      def to_s
        "#{currency} #{amount}"
      end
      
      def ==(other)
        other.is_a?(Money) && amount == other.amount && currency == other.currency
      end
    end
    
    # 注文ステータスも値オブジェクト
    class OrderStatus
      PENDING = new('pending')
      CONFIRMED = new('confirmed')
      SHIPPED = new('shipped')
      DELIVERED = new('delivered')
      CANCELLED = new('cancelled')
      
      attr_reader :value
      
      def initialize(value)
        @value = value.freeze
      end
      
      def to_s = value
      def ==(other) = other.is_a?(OrderStatus) && value == other.value
    end
  end
end

リポジトリインターフェース(ドメイン層に定義)

# app/domain/repositories/order_repository.rb
module Domain
  module Repositories
    module OrderRepository
      # ドメイン層でインターフェースを定義する(クリーンアーキテクチャとの違い)
      def save(order)
        raise NotImplementedError
      end
      
      def find_by_id(id)
        raise NotImplementedError
      end
      
      def find_by_user_id(user_id)
        raise NotImplementedError
      end
      
      def find_all_pending
        raise NotImplementedError
      end
    end
  end
end

ドメインサービス層

エンティティ単体では表現できないロジックがここに入る。

# app/domain/services/pricing_service.rb
module Domain
  module Services
    class PricingService
      def initialize(discount_repository:)
        @discount_repository = discount_repository
      end
      
      # 複数エンティティをまたぐビジネスロジック
      def calculate_order_total(order:, coupon_code: nil)
        base_total = order.total
        
        if coupon_code
          discount = @discount_repository.find_by_code(coupon_code)
          return base_total unless discount&.applicable_to?(order)
          base_total - discount.amount_for(base_total)
        else
          base_total
        end
      end
    end
    
    # 在庫ポリシー:ドメインのビジネスルール
    class InventoryPolicy
      REORDER_THRESHOLD = 10
      MAX_ORDER_QUANTITY = 100
      
      def can_place_order?(product:, quantity:)
        return false if quantity > MAX_ORDER_QUANTITY
        product.stock_count >= quantity
      end
      
      def needs_reorder?(product)
        product.stock_count <= REORDER_THRESHOLD
      end
    end
  end
end

アプリケーションサービス層

ユースケースのオーケストレーション。ドメインサービスとリポジトリを組み合わせる。

# app/application/services/order_application_service.rb
module Application
  module Services
    class OrderApplicationService
      def initialize(
        order_repository:,
        product_repository:,
        pricing_service:,
        inventory_policy:,
        event_bus:
      )
        @order_repository = order_repository
        @product_repository = product_repository
        @pricing_service = pricing_service
        @inventory_policy = inventory_policy
        @event_bus = event_bus
      end
      
      def place_order(command)
        product = @product_repository.find_by_id(command.product_id)
        
        unless @inventory_policy.can_place_order?(product: product, quantity: command.quantity)
          raise Application::Errors::InsufficientInventory
        end
        
        items = [
          Domain::Entities::OrderItem.new(
            product_id: product.id,
            quantity: command.quantity,
            unit_price: product.price
          )
        ]
        
        order = Domain::Entities::Order.new(
          id: nil,
          user_id: command.user_id,
          items: items
        )
        
        # クーポン適用(ドメインサービス)
        total = @pricing_service.calculate_order_total(
          order: order,
          coupon_code: command.coupon_code
        )
        
        saved_order = @order_repository.save(order)
        
        # ドメインイベントの発行
        @event_bus.publish(
          Domain::Events::OrderPlaced.new(order_id: saved_order.id, total: total)
        )
        
        saved_order
      end
    end
  end
end

インフラストラクチャ層

# app/infrastructure/repositories/ar_order_repository.rb
module Infrastructure
  module Repositories
    class ArOrderRepository
      include Domain::Repositories::OrderRepository  # インターフェースをinclude
      
      def save(order)
        record = find_or_initialize_record(order.id)
        
        record.assign_attributes(
          user_id: order.user_id,
          status: order.status.to_s
        )
        record.save!
        
        # アイテムの保存
        order.items.each do |item|
          record.order_items.find_or_initialize_by(product_id: item.product_id).update!(
            quantity: item.quantity,
            unit_price: item.unit_price.amount
          )
        end
        
        to_domain(record)
      end
      
      def find_by_id(id)
        record = OrderRecord.includes(:order_items).find_by(id: id)
        record ? to_domain(record) : nil
      end
      
      def find_all_pending
        OrderRecord.where(status: 'pending').map { |r| to_domain(r) }
      end
      
      private
      
      def to_domain(record)
        Domain::Entities::Order.new(
          id: record.id,
          user_id: record.user_id,
          status: Domain::ValueObjects::OrderStatus.new(record.status),
          items: record.order_items.map { |item|
            Domain::Entities::OrderItem.new(
              product_id: item.product_id,
              quantity: item.quantity,
              unit_price: Domain::ValueObjects::Money.new(item.unit_price)
            )
          },
          created_at: record.created_at
        )
      end
      
      def find_or_initialize_record(id)
        id ? OrderRecord.find(id) : OrderRecord.new
      end
    end
  end
end

DDDとの組み合わせ

オニオンアーキテクチャはDDD(ドメイン駆動設計)と自然に組み合わさる。

# 集約ルート(Aggregate Root)の概念
module Domain
  module Entities
    class Order
      # 注文は集約ルート。外部は必ずOrderを通じてアクセスする
      # OrderItemに直接アクセスしてはいけない
      
      def add_item(product:, quantity:)
        raise errors::MaxItemsExceeded if items.size >= 20
        
        existing_item = items.find { |i| i.product_id == product.id }
        
        if existing_item
          updated_items = items.map do |i|
            i.product_id == product.id ? i.with_quantity(i.quantity + quantity) : i
          end
          self.class.new(**to_attributes.merge(items: updated_items))
        else
          new_item = OrderItem.new(product_id: product.id, quantity: quantity, unit_price: product.price)
          self.class.new(**to_attributes.merge(items: items + [new_item]))
        end
      end
      
      def remove_item(product_id)
        updated_items = items.reject { |i| i.product_id == product_id }
        self.class.new(**to_attributes.merge(items: updated_items))
      end
    end
  end
end

INFO

DDDでは「集約」が整合性の境界です。オニオンアーキテクチャの「ドメインモデルが最内層」という設計は、集約の概念と自然に一致します。

AWSでの構成

Loading diagram...

3つのアーキテクチャの比較

特徴クリーンヘキサゴナルオニオン
リポジトリIFの場所ユースケース層コアドメイン層
DDDとの親和性
学習コスト
ファイル数多い多い
テスタビリティ

「結局どれを選べばいいか?」ユウが聞いた。

「DDDを本格的に導入するならオニオン。実用的にポートとアダプターを使いたいならヘキサゴナル。厳密な層の分離を学ぶならクリーン。今の私たちには、ヘキサゴナルが一番バランスが良いと思う」

カオリはホワイトボードに書いた。「アーキテクチャの名前に縛られるな。思想を理解して、自分たちの文脈で応用するのが大事」


次章では、全く異なるアプローチ「CQRS」を学ぶ。コマンドとクエリを分離することで、読み書きの非対称な性質を活かす設計を見ていこう。