オニオンアーキテクチャ — ドメインを中心に
「クリーンと何が違うの?」
勉強会でユウが質問した。「クリーンアーキテクチャ、ヘキサゴナル、オニオン…全部同じじゃないですか?ポートとアダプターで外部依存を分離する、って話ですよね?」
正直な疑問だ。カオリも最初は同じように感じた。
「大きな思想は共通している。でも微妙な違いがある。特にオニオンはドメインモデルをより中心に置いていて、DDDとの親和性が高い」
Jeffrey Palermoが2008年に提唱したオニオンアーキテクチャは、「ドメインモデル」を絶対の中心に置く設計だ。
INFO
オニオンアーキテクチャの最大の特徴は、「ドメインモデル」が最も内側の層に位置し、インターフェース(ポート)もドメイン層に定義されることです。外側の層は常に内側に依存しますが、内側は外側を知りません。
オニオンの層構造
クリーンアーキテクチャとの最大の違いは、リポジトリのインターフェースがドメイン層に定義される点だ。
# オニオンアーキテクチャでのレイヤー配置
# 最内層: ドメインモデル(インターフェース定義も含む)
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
endDDDとの組み合わせ
オニオンアーキテクチャは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
endINFO
DDDでは「集約」が整合性の境界です。オニオンアーキテクチャの「ドメインモデルが最内層」という設計は、集約の概念と自然に一致します。
AWSでの構成
3つのアーキテクチャの比較
| 特徴 | クリーン | ヘキサゴナル | オニオン |
|---|---|---|---|
| リポジトリIFの場所 | ユースケース層 | コア | ドメイン層 |
| DDDとの親和性 | 中 | 中 | 高 |
| 学習コスト | 高 | 中 | 高 |
| ファイル数 | 多い | 中 | 多い |
| テスタビリティ | 高 | 高 | 高 |
「結局どれを選べばいいか?」ユウが聞いた。
「DDDを本格的に導入するならオニオン。実用的にポートとアダプターを使いたいならヘキサゴナル。厳密な層の分離を学ぶならクリーン。今の私たちには、ヘキサゴナルが一番バランスが良いと思う」
カオリはホワイトボードに書いた。「アーキテクチャの名前に縛られるな。思想を理解して、自分たちの文脈で応用するのが大事」
次章では、全く異なるアプローチ「CQRS」を学ぶ。コマンドとクエリを分離することで、読み書きの非対称な性質を活かす設計を見ていこう。