mybook

ヘキサゴナルアーキテクチャ — ポートとアダプター

六角形の直感

「なんで六角形なの?」

チームメンバーのエリが不思議そうに聞いた。ホワイトボードには正六角形が描かれている。

カオリは笑った。「六角形に深い意味はないんだ。重要なのは、中心に『アプリケーションコア』があって、外側からいくつかの入口(ポート)でアクセスできるという構造。六角形は複数の入口を表現しやすいから選ばれた」

Alistair Cockburnが2005年に提唱したヘキサゴナルアーキテクチャ(別名:ポートとアダプターパターン)の核心はシンプルだ。

アプリケーションを、ユーザー側とサーバー側の両方から対称に扱えるようにする

INFO

ヘキサゴナルアーキテクチャでは、アプリケーションコアが「何を必要とするか」をポート(インターフェース)として定義し、具体的な実装はアダプターが担います。クリーンアーキテクチャと同じ思想を、より実用的な粒度で表現します。

ポートとアダプターの概念

Loading diagram...

プライマリポート(Driving Side): アプリケーションを「使う」側。HTTPリクエスト、CLIコマンド、テストがここに入る。

セカンダリポート(Driven Side): アプリケーションが「使う」側。DB、メール、決済サービスがここに入る。

Rubyでポートを定義する

Rubyは静的型がないため、ポートはモジュール(インターフェース)として定義する。

# app/ports/order_repository_port.rb
module Ports
  module OrderRepositoryPort
    # このモジュールを include したクラスは以下のメソッドを実装しなければならない
    
    def save(order)
      raise NotImplementedError, "#{self.class}#save を実装してください"
    end
    
    def find(id)
      raise NotImplementedError, "#{self.class}#find を実装してください"
    end
    
    def find_by_user(user_id, page: 1, per: 20)
      raise NotImplementedError, "#{self.class}#find_by_user を実装してください"
    end
  end
end
 
# app/ports/notification_port.rb
module Ports
  module NotificationPort
    def send_order_confirmation(user:, order:)
      raise NotImplementedError
    end
    
    def send_shipping_notification(user:, order:, tracking_number:)
      raise NotImplementedError
    end
  end
end
 
# app/ports/payment_port.rb
module Ports
  module PaymentPort
    def charge(amount:, currency:, payment_method_id:)
      raise NotImplementedError
    end
    
    def refund(charge_id:, amount:)
      raise NotImplementedError
    end
  end
end

セカンダリアダプターの実装

データベースアダプター(本番用)

# app/adapters/secondary/active_record_order_repository.rb
module Adapters
  module Secondary
    class ActiveRecordOrderRepository
      include Ports::OrderRepositoryPort
      
      def save(order)
        record = order.id ? OrderRecord.find(order.id) : OrderRecord.new
        
        record.update!(
          user_id: order.user_id,
          product_id: order.product_id,
          quantity: order.quantity,
          status: order.status,
          unit_price: order.unit_price
        )
        
        map_to_domain(record)
      end
      
      def find(id)
        record = OrderRecord.find_by(id: id)
        record ? map_to_domain(record) : nil
      end
      
      def find_by_user(user_id, page: 1, per: 20)
        OrderRecord
          .where(user_id: user_id)
          .order(created_at: :desc)
          .offset((page - 1) * per)
          .limit(per)
          .map { |r| map_to_domain(r) }
      end
      
      private
      
      def map_to_domain(record)
        Domain::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,
          created_at: record.created_at
        )
      end
    end
  end
end

メール通知アダプター(本番用:SendGrid)

# app/adapters/secondary/sendgrid_notification_adapter.rb
module Adapters
  module Secondary
    class SendgridNotificationAdapter
      include Ports::NotificationPort
      
      def send_order_confirmation(user:, order:)
        OrderMailer.confirmation(user: user, order: order).deliver_later
      end
      
      def send_shipping_notification(user:, order:, tracking_number:)
        OrderMailer.shipping(
          user: user,
          order: order,
          tracking_number: tracking_number
        ).deliver_later
      end
    end
  end
end

決済アダプター(本番用:Stripe)

# app/adapters/secondary/stripe_payment_adapter.rb
module Adapters
  module Secondary
    class StripePaymentAdapter
      include Ports::PaymentPort
      
      def charge(amount:, currency:, payment_method_id:)
        intent = Stripe::PaymentIntent.create(
          amount: amount,
          currency: currency,
          payment_method: payment_method_id,
          confirm: true
        )
        
        { success: true, charge_id: intent.id }
      rescue Stripe::CardError => e
        { success: false, error: e.message }
      end
      
      def refund(charge_id:, amount:)
        Stripe::Refund.create(payment_intent: charge_id, amount: amount)
        { success: true }
      rescue Stripe::StripeError => e
        { success: false, error: e.message }
      end
    end
  end
end

テスト用アダプター(テスト時の差し替え)

ここがヘキサゴナルアーキテクチャの最大の恩恵だ。

# spec/support/fake_adapters.rb
 
# インメモリのリポジトリ(テスト用)
class FakeOrderRepository
  include Ports::OrderRepositoryPort
  
  def initialize
    @store = {}
    @counter = 0
  end
  
  def save(order)
    @counter += 1
    id = order.id || @counter
    stored = order.with(id: id)
    @store[id] = stored
    stored
  end
  
  def find(id)
    @store[id]
  end
  
  def find_by_user(user_id, page: 1, per: 20)
    @store.values.select { |o| o.user_id == user_id }
  end
  
  def all
    @store.values
  end
end
 
# メール送信を記録するスパイ(テスト用)
class SpyNotificationAdapter
  include Ports::NotificationPort
  
  attr_reader :sent_notifications
  
  def initialize
    @sent_notifications = []
  end
  
  def send_order_confirmation(user:, order:)
    @sent_notifications << { type: :order_confirmation, user: user, order: order }
  end
  
  def send_shipping_notification(user:, order:, tracking_number:)
    @sent_notifications << { 
      type: :shipping_notification, 
      user: user, 
      order: order, 
      tracking_number: tracking_number 
    }
  end
end
 
# 決済を常に成功させるスタブ(テスト用)
class FakePaymentAdapter
  include Ports::PaymentPort
  
  def initialize(success: true)
    @should_succeed = success
  end
  
  def charge(amount:, currency:, payment_method_id:)
    if @should_succeed
      { success: true, charge_id: "fake_charge_#{SecureRandom.hex(8)}" }
    else
      { success: false, error: '決済に失敗しました(テスト)' }
    end
  end
  
  def refund(charge_id:, amount:)
    { success: @should_succeed }
  end
end

アプリケーションコア(ユースケース)

# app/core/use_cases/place_order.rb
module Core
  module UseCases
    class PlaceOrder
      def initialize(
        order_repository:,
        product_repository:,
        notification_adapter:,
        payment_adapter:
      )
        @order_repository = order_repository
        @product_repository = product_repository
        @notification_adapter = notification_adapter
        @payment_adapter = payment_adapter
      end
      
      def call(user:, product_id:, quantity:, payment_method_id:)
        product = @product_repository.find(product_id)
        return failure('商品が見つかりません') unless product
        return failure('在庫不足') if product.stock_count < quantity
        
        amount = product.price * quantity
        
        payment_result = @payment_adapter.charge(
          amount: amount,
          currency: 'jpy',
          payment_method_id: payment_method_id
        )
        
        return failure(payment_result[:error]) unless payment_result[:success]
        
        order = Domain::Order.new(
          user_id: user.id,
          product_id: product_id,
          quantity: quantity,
          unit_price: product.price,
          status: 'confirmed',
          charge_id: payment_result[:charge_id]
        )
        
        saved_order = @order_repository.save(order)
        
        @notification_adapter.send_order_confirmation(user: user, order: saved_order)
        
        success(saved_order)
      end
      
      private
      
      def success(order) = { ok: true, order: order }
      def failure(error) = { ok: false, error: error }
    end
  end
end

テストの威力

RSpec.describe Core::UseCases::PlaceOrder do
  let(:order_repo) { FakeOrderRepository.new }
  let(:product_repo) { FakeProductRepository.new }
  let(:notification_adapter) { SpyNotificationAdapter.new }
  let(:payment_adapter) { FakePaymentAdapter.new(success: true) }
  
  let(:use_case) do
    described_class.new(
      order_repository: order_repo,
      product_repository: product_repo,
      notification_adapter: notification_adapter,
      payment_adapter: payment_adapter
    )
  end
  
  let(:user) { OpenStruct.new(id: 1, email: 'test@example.com') }
  
  before do
    product_repo.add(
      Domain::Product.new(id: 1, name: 'テスト商品', price: 1000, stock_count: 10)
    )
  end
  
  context '正常な注文' do
    it '注文が作成される' do
      result = use_case.call(
        user: user,
        product_id: 1,
        quantity: 2,
        payment_method_id: 'pm_test'
      )
      
      expect(result[:ok]).to be true
      expect(result[:order].quantity).to eq 2
      expect(result[:order].status).to eq 'confirmed'
    end
    
    it 'メール通知が送信される' do
      use_case.call(user: user, product_id: 1, quantity: 1, payment_method_id: 'pm_test')
      
      notifications = notification_adapter.sent_notifications
      expect(notifications.count).to eq 1
      expect(notifications.first[:type]).to eq :order_confirmation
    end
  end
  
  context '決済失敗' do
    let(:payment_adapter) { FakePaymentAdapter.new(success: false) }
    
    it 'エラーが返りメール通知は送信されない' do
      result = use_case.call(user: user, product_id: 1, quantity: 1, payment_method_id: 'pm_test')
      
      expect(result[:ok]).to be false
      expect(notification_adapter.sent_notifications).to be_empty
    end
  end
end

DBなし、メールなし、Stripe APIなし。このテストは1秒以内で完了する。

AWSでの環境別アダプター切り替え

# 環境ごとのアダプター設定
production:
  order_repository: Adapters::Secondary::ActiveRecordOrderRepository
  notification_adapter: Adapters::Secondary::SendgridNotificationAdapter
  payment_adapter: Adapters::Secondary::StripePaymentAdapter
 
staging:
  order_repository: Adapters::Secondary::ActiveRecordOrderRepository
  notification_adapter: Adapters::Secondary::LoggingNotificationAdapter  # ログのみ
  payment_adapter: Adapters::Secondary::StripeTestModeAdapter            # テストモード
 
test:
  order_repository: FakeOrderRepository
  notification_adapter: SpyNotificationAdapter
  payment_adapter: FakePaymentAdapter
# config/initializers/adapters.rb
Rails.application.config.adapters = {
  order_repository: ENV.fetch('ORDER_REPO_ADAPTER', 'active_record').then do |adapter|
    case adapter
    when 'active_record' then Adapters::Secondary::ActiveRecordOrderRepository.new
    when 'in_memory' then FakeOrderRepository.new
    end
  end,
  payment_adapter: Rails.env.production? \
    ? Adapters::Secondary::StripePaymentAdapter.new \
    : Adapters::Secondary::StripeTestModeAdapter.new
}

INFO

ヘキサゴナルアーキテクチャにより、「本番はStripe、ステージングはStripeテストモード、テストはフェイク」を設定ファイルだけで切り替えられます。コアロジックは一行も変わりません。

クリーンアーキテクチャとの違い

観点クリーンアーキテクチャヘキサゴナル
層の数4層(同心円)2層(コア + 外側)
依存の方向内側へコアへ
フレームワーク最外層アダプター
実用性より厳密より実用的
学習コスト高い中程度

「どちらも同じ思想を共有している。ヘキサゴナルは少し実用寄りのアプローチ」とカオリは説明した。


次章では「オニオンアーキテクチャ」を学ぶ。クリーンアーキテクチャとの微妙な違いと、ドメイン中心設計の深化を見ていこう。