ヘキサゴナルアーキテクチャ — ポートとアダプター
六角形の直感
「なんで六角形なの?」
チームメンバーのエリが不思議そうに聞いた。ホワイトボードには正六角形が描かれている。
カオリは笑った。「六角形に深い意味はないんだ。重要なのは、中心に『アプリケーションコア』があって、外側からいくつかの入口(ポート)でアクセスできるという構造。六角形は複数の入口を表現しやすいから選ばれた」
Alistair Cockburnが2005年に提唱したヘキサゴナルアーキテクチャ(別名:ポートとアダプターパターン)の核心はシンプルだ。
アプリケーションを、ユーザー側とサーバー側の両方から対称に扱えるようにする
INFO
ヘキサゴナルアーキテクチャでは、アプリケーションコアが「何を必要とするか」をポート(インターフェース)として定義し、具体的な実装はアダプターが担います。クリーンアーキテクチャと同じ思想を、より実用的な粒度で表現します。
ポートとアダプターの概念
プライマリポート(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
endDBなし、メールなし、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層(コア + 外側) |
| 依存の方向 | 内側へ | コアへ |
| フレームワーク | 最外層 | アダプター |
| 実用性 | より厳密 | より実用的 |
| 学習コスト | 高い | 中程度 |
「どちらも同じ思想を共有している。ヘキサゴナルは少し実用寄りのアプローチ」とカオリは説明した。
次章では「オニオンアーキテクチャ」を学ぶ。クリーンアーキテクチャとの微妙な違いと、ドメイン中心設計の深化を見ていこう。