集約 — 整合性の境界
「なぜ在庫の不整合が起きるんだ」
リナはインシデントレポートを読んでいた。注文確定時に在庫が引き当てられるはずが、二重引き当てが発生し、実際には存在しない在庫が注文されてしまう問題だ。倉庫から「在庫が0なのに注文が来た」という問い合わせが週に数件届くようになっていた。
コードを調べると、複数の場所から Stock テーブルを直接更新していた。
# 注文確定時(orders_controller.rb から呼ばれる)
order.confirm!
Stock.find_by(product_id: order.product_id)
.decrement!(:quantity, order.quantity)
# 同時に、管理画面からも(admin_stocks_controller.rb)
Stock.find_by(product_id: product_id)
.update!(quantity: new_quantity)
# さらに、バッチ処理からも(stock_sync_worker.rb)
Stock.where('quantity < ?', 0).update_all(quantity: 0)
# さらに、在庫補充時も(receiving_controller.rb)
Stock.find(stock_id).increment!(:quantity, received_quantity)4箇所から直接 Stock テーブルを変更している。競合状態(Race Condition)が起きた時に整合性が崩れる。
原因は明確だ。「誰でもどこからでも在庫を変更できる」状態だ。
集約とは
集約(Aggregate)とは、一緒に変更される必要があるオブジェクトのクラスタで、整合性の境界を形成するものだ。
集約には必ず集約ルート(Aggregate Root)と呼ばれるエンティティが存在し、集約内の他のオブジェクトへのアクセスはすべて集約ルートを通じて行われる。
INFO
集約の核心ルール: 外部からは集約ルートしか参照してはならない。Order の OrderItem に直接アクセスしてはいけない。order.add_item(...) で Order を通じて操作する。これが整合性を保証する鍵だ。
Order集約の設計
FreshCartの注文関連オブジェクトを集約として設計する。
Order(注文)が集約ルート。OrderItem(注文明細)は集約内部のオブジェクト。
module OrderContext
# 集約ルート: 外部からアクセスできる唯一の窓口
class Order
attr_reader :id, :customer_id, :status, :delivery_address,
:confirmed_at, :cancelled_at, :cancellation_reason
def initialize(id:, customer_id:, delivery_address:)
@id = id
@customer_id = customer_id
@delivery_address = delivery_address
@status = OrderStatus::PENDING
@order_items = []
@domain_events = []
end
# 集約ルートを通じてOrderItemを追加する
def add_item(product_id:, product_name:, unit_price:, quantity:)
raise OrderAlreadyConfirmed, "確定済み注文には商品を追加できません" unless pending?
raise ArgumentError, "数量は1以上です" if quantity < 1
existing = find_item_by_product(product_id)
if existing
# 既存商品の数量を増やす
existing.increase_quantity(quantity)
else
@order_items << OrderItem.new(
product_id: product_id,
product_name: product_name,
unit_price: unit_price,
quantity: quantity
)
end
self
end
# 集約ルートを通じてOrderItemを削除する
def remove_item(product_id:)
item = find_item_by_product(product_id)
raise ItemNotFoundInOrder, "商品(#{product_id})が注文に含まれていません" unless item
@order_items.delete(item)
self
end
# 集約ルートを通じて数量変更する
def update_item_quantity(product_id:, new_quantity:)
raise OrderAlreadyConfirmed, "確定済み注文の数量は変更できません" unless pending?
item = find_item_by_product(product_id)
raise ItemNotFoundInOrder unless item
if new_quantity <= 0
remove_item(product_id: product_id)
else
item.update_quantity(new_quantity)
end
self
end
def confirm!
raise InvalidStateTransition,
"#{status}状態の注文は確定できません" unless can_confirm?
raise EmptyOrder, "注文商品が0件です" if @order_items.empty?
@status = OrderStatus::CONFIRMED
@confirmed_at = Time.current
add_event(OrderConfirmed.new(
order_id: id,
customer_id: customer_id,
order_items: order_items.map(&:to_event_payload),
total_amount: total_amount,
confirmed_at: @confirmed_at
))
self
end
def cancel!(reason:)
raise InvalidStateTransition,
"#{status}状態の注文はキャンセルできません" unless can_cancel?
@status = OrderStatus::CANCELLED
@cancellation_reason = reason
@cancelled_at = Time.current
add_event(OrderCancelled.new(
order_id: id,
customer_id: customer_id,
reason: reason,
cancelled_at: @cancelled_at
))
self
end
# 集約内の読み取りアクセスは提供するが、外部から変更はできない
def order_items
@order_items.dup.freeze # コピーを返してイミュータブルに
end
def total_amount
@order_items.sum(&:subtotal)
end
def item_count
@order_items.sum(&:quantity)
end
def domain_events
@domain_events.dup.freeze
end
def clear_events
@domain_events.clear
self
end
def pending?
status == OrderStatus::PENDING
end
def confirmed?
status == OrderStatus::CONFIRMED
end
def ==(other)
other.is_a?(Order) && id == other.id
end
alias eql? ==
def hash
id.hash
end
# リポジトリが状態を復元するために使う(内部アクセス)
protected
def restore_state(status:, order_items:, confirmed_at: nil, cancelled_at: nil, cancellation_reason: nil)
@status = status
@order_items = order_items
@confirmed_at = confirmed_at
@cancelled_at = cancelled_at
@cancellation_reason = cancellation_reason
self
end
private
def find_item_by_product(product_id)
@order_items.find { |item| item.product_id == product_id }
end
def can_confirm?
status == OrderStatus::PENDING
end
def can_cancel?
[OrderStatus::PENDING, OrderStatus::CONFIRMED].include?(status)
end
def add_event(event)
@domain_events << event
end
end
# 集約内部のエンティティ(外部から直接アクセスしてはならない)
class OrderItem
attr_reader :product_id, :product_name, :unit_price, :quantity
def initialize(product_id:, product_name:, unit_price:, quantity:)
raise ArgumentError, "数量は1以上です: #{quantity}" if quantity < 1
@product_id = product_id
@product_name = product_name
@unit_price = unit_price # SharedKernel::Money
@quantity = quantity
end
def increase_quantity(amount)
raise ArgumentError, "増加量は1以上です" if amount < 1
@quantity += amount
end
def update_quantity(new_quantity)
raise ArgumentError, "数量は1以上です: #{new_quantity}" if new_quantity < 1
@quantity = new_quantity
end
def subtotal
unit_price.multiply(quantity)
end
def to_event_payload
{
product_id: product_id,
product_name: product_name,
unit_price: unit_price.to_h,
quantity: quantity,
subtotal: subtotal.to_h
}
end
end
endトランザクション境界
集約はトランザクション境界とも一致する。1つのトランザクションで変更するのは、1つの集約のみが原則だ。
なぜか?複数の集約を1トランザクションで変更すると:
- ロック競合が増える(パフォーマンス低下)
- トランザクションが長くなる(デッドロックリスク上昇)
- 境界が曖昧になる(どこからでも変更可能な状態に戻る)
class ConfirmOrderUseCase
def initialize(order_repository:, event_bus: EventBus)
@order_repository = order_repository
@event_bus = event_bus
end
def call(order_id:, customer_id:)
order = @order_repository.find(order_id)
# 権限チェック
raise Unauthorized unless order.customer_id == customer_id
# 1つのトランザクションで1つの集約(Order)のみを変更
ApplicationRecord.transaction do
order.confirm!
@order_repository.save(order)
# Outboxパターン: イベントもトランザクション内で記録
record_outbox_events(order.domain_events)
end
# トランザクション外でイベントを発行
# (在庫引き当てはドメインイベントを受けた別のサービスが担当)
publish_events(order.domain_events)
order.clear_events
ConfirmOrderResult.success(order: order)
rescue OrderContext::InvalidStateTransition => e
ConfirmOrderResult.failure(reason: :invalid_state, message: e.message)
rescue OrderContext::EmptyOrder => e
ConfirmOrderResult.failure(reason: :empty_order, message: e.message)
end
private
def record_outbox_events(events)
events.each do |event|
EventOutbox.create!(
event_id: event.event_id,
event_type: event.event_type,
payload: event.to_h.to_json
)
end
end
def publish_events(events)
events.each { |event| @event_bus.publish(event) }
end
endWARNING
複数の集約を1トランザクションで変更するのは設計の臭い(smell)だ。注文と在庫を同じトランザクションで変更したくなるが、それは「1集約1トランザクション」原則の違反だ。代わりにドメインイベントを使って最終的整合性(Eventual Consistency)を達成する。
集約サイズのガイドライン
集約は小さく保つ。パフォーマンスと整合性のバランスが取れる。
悪い例: 大きすぎる集約
# NG: Order集約に在庫まで含めている
# この設計の問題: Orderと同時にStockを更新するとロックが長くなる
class Order
attr_reader :id, :order_items, :stocks # Stocksを集約内に持つ
def confirm!
@status = :confirmed
# ここで直接Stockを変更してしまっている(別集約を侵犯)
order_items.each do |item|
stock = @stocks.find { |s| s.product_id == item.product_id }
stock.decrement!(item.quantity) # OrderがStockの内部状態を変更
end
end
end良い例: 適切に分割された集約
# OK: OrderとStockは別の集約
# Orderは注文の確定のみを担当
class Order
def confirm!
raise InvalidStateTransition unless pending?
@status = OrderStatus::CONFIRMED
# ドメインイベントを発行するだけ。在庫への操作は担当しない。
add_event(OrderConfirmed.new(
order_id: id,
customer_id: customer_id,
order_items: order_items.map(&:to_event_payload)
))
self
end
end
# Stockは在庫管理の独立した集約ルート
# 注文確定イベントを受け取って自律的に動作する
module InventoryContext
class Stock
attr_reader :id, :product_id, :total_quantity
def reserve(quantity:, order_id:)
raise InsufficientStock,
"在庫不足 (利用可能: #{available_quantity}, 必要: #{quantity})" if available_quantity < quantity
@reservations << StockReservation.new(
order_id: order_id,
quantity: quantity,
reserved_at: Time.current
)
add_event(StockReserved.new(
stock_id: id,
product_id: product_id,
order_id: order_id,
quantity: quantity
))
self
end
def release_reservation(order_id:)
reservation = @reservations.find { |r| r.order_id == order_id }
raise ReservationNotFound, "注文(#{order_id})の引き当てが見つかりません" unless reservation
@reservations.delete(reservation)
self
end
def available_quantity
total_quantity - reserved_quantity
end
private
def reserved_quantity
@reservations.sum(&:quantity)
end
end
endRailsでの実装パターン
ActiveRecordのモデルと集約オブジェクトを共存させる方法を具体的に示す。
# ActiveRecordはデータ層(薄く保つ)
class OrderRecord < ApplicationRecord
self.table_name = 'orders'
has_many :order_item_records, foreign_key: :order_id, dependent: :destroy
belongs_to :customer_record, foreign_key: :customer_id, class_name: 'CustomerRecord'
validates :customer_id, :status, presence: true
validates :status, inclusion: { in: OrderContext::OrderStatus::ALL_VALUES }
scope :pending, -> { where(status: 'pending') }
scope :by_customer, ->(id) { where(customer_id: id) }
scope :recent, -> { order(created_at: :desc) }
scope :stale_pending, -> { pending.where('created_at < ?', 30.minutes.ago) }
end
class OrderItemRecord < ApplicationRecord
self.table_name = 'order_items'
belongs_to :order_record, foreign_key: :order_id
validates :product_id, :product_name, :unit_price_cents, :quantity, presence: true
validates :quantity, numericality: { greater_than: 0 }
end# リポジトリで集約とActiveRecordを橋渡し
class ActiveRecordOrderRepository
def find(id)
record = OrderRecord
.includes(:order_item_records)
.find_by!(id: id)
reconstruct_from_record(record)
rescue ActiveRecord::RecordNotFound
raise OrderContext::OrderNotFound, "注文が見つかりません: #{id}"
end
def save(order)
ApplicationRecord.transaction do
record = OrderRecord.find_or_initialize_by(id: order.id)
record.assign_attributes(
customer_id: order.customer_id,
status: order.status.to_s,
delivery_postal_code: order.delivery_address.postal_code,
delivery_prefecture: order.delivery_address.prefecture,
delivery_city: order.delivery_address.city,
delivery_street: order.delivery_address.street,
delivery_building: order.delivery_address.building,
delivery_recipient_name: order.delivery_address.recipient_name,
total_amount_cents: order.total_amount.to_i,
confirmed_at: order.confirmed_at,
cancelled_at: order.cancelled_at,
cancellation_reason: order.cancellation_reason
)
record.save!
sync_order_items(record, order.order_items)
end
rescue ActiveRecord::RecordInvalid => e
raise OrderContext::OrderPersistenceError, e.message
end
private
def reconstruct_from_record(record)
order = OrderContext::Order.new(
id: record.id,
customer_id: record.customer_id,
delivery_address: build_delivery_address(record)
)
order.__send__(:restore_state,
status: OrderContext::OrderStatus.from_string(record.status),
order_items: record.order_item_records.map { |r| rebuild_order_item(r) },
confirmed_at: record.confirmed_at,
cancelled_at: record.cancelled_at,
cancellation_reason: record.cancellation_reason
)
order
end
def build_delivery_address(record)
OrderContext::DeliveryAddress.new(
postal_code: record.delivery_postal_code,
prefecture: record.delivery_prefecture,
city: record.delivery_city,
street: record.delivery_street,
building: record.delivery_building,
recipient_name: record.delivery_recipient_name
)
end
def rebuild_order_item(record)
OrderContext::OrderItem.new(
product_id: record.product_id,
product_name: record.product_name,
unit_price: SharedKernel::Money.new(
amount: record.unit_price_cents,
currency: :jpy
),
quantity: record.quantity
)
end
def sync_order_items(order_record, order_items)
existing_product_ids = order_record.order_item_records.pluck(:product_id)
new_product_ids = order_items.map(&:product_id)
# 削除されたアイテムを削除
order_record.order_item_records
.where(product_id: existing_product_ids - new_product_ids)
.destroy_all
# 更新・追加
order_items.each do |item|
order_record.order_item_records
.find_or_initialize_by(product_id: item.product_id)
.update!(
product_name: item.product_name,
unit_price_cents: item.unit_price.to_i,
quantity: item.quantity,
subtotal_cents: item.subtotal.to_i
)
end
end
end楽観的ロックで並行性を管理する
複数のリクエストが同じ集約を同時に更新しようとした場合を対処する。
# ActiveRecordのlockVersionで楽観的ロック
class OrderRecord < ApplicationRecord
# lock_version カラムが必要 (migrationで追加)
# t.integer :lock_version, default: 0
end
# リポジトリでの使用
class ActiveRecordOrderRepository
def save(order)
ApplicationRecord.transaction do
record = OrderRecord.find_or_initialize_by(id: order.id)
# 楽観的ロック: 他のリクエストが先に更新していたら例外が発生する
record.assign_attributes(
status: order.status.to_s,
lock_version: order.lock_version # 取得時のバージョンを渡す
# ...
)
record.save!
end
rescue ActiveRecord::StaleObjectError
raise OrderContext::ConcurrentModificationError,
"注文が別のリクエストで更新されました。再試行してください"
end
end# Golangでの実装例(楽観的ロックのパターンが明確)
type Order struct {
ID string
CustomerID string
Status OrderStatus
OrderItems []OrderItem
LockVersion int
}
func (r *OrderRepository) Save(ctx context.Context, order *Order) error {
result, err := r.db.ExecContext(ctx,
`UPDATE orders
SET status = $1, lock_version = lock_version + 1
WHERE id = $2 AND lock_version = $3`,
order.Status, order.ID, order.LockVersion,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrConcurrentModification
}
return nil
}INFO
楽観的ロックは「衝突はまれ」という前提のアプローチだ。更新時に「自分が読んだ後に誰かが変更していないか」をバージョン番号で検証する。悲観的ロック(SELECT FOR UPDATE)より並行性が高い。
在庫集約の設計
注文集約とは独立した在庫集約。
module InventoryContext
class Stock
attr_reader :id, :product_id, :total_quantity
def initialize(id:, product_id:, total_quantity:, reservations: [])
@id = id
@product_id = product_id
@total_quantity = total_quantity
@reservations = reservations.dup
@domain_events = []
end
def reserve(quantity:, order_id:)
if available_quantity < quantity
raise InsufficientStock.new(
product_id: product_id,
requested: quantity,
available: available_quantity
)
end
existing = @reservations.find { |r| r.order_id == order_id }
raise DuplicateReservation, "注文(#{order_id})の引き当ては既に存在します" if existing
@reservations << StockReservation.new(
order_id: order_id,
quantity: quantity,
reserved_at: Time.current
)
add_event(StockReserved.new(
stock_id: id,
product_id: product_id,
order_id: order_id,
reserved_quantity: quantity,
available_quantity: available_quantity
))
self
end
def release_reservation(order_id:)
reservation = @reservations.find { |r| r.order_id == order_id }
raise ReservationNotFound, "注文(#{order_id})の引き当てが見つかりません" unless reservation
@reservations.delete(reservation)
add_event(StockReleased.new(
stock_id: id,
product_id: product_id,
order_id: order_id,
released_quantity: reservation.quantity
))
self
end
def receive_shipment(quantity:, lot_number:, received_at: Time.current)
raise ArgumentError, "入荷数量は1以上です" if quantity < 1
@total_quantity += quantity
add_event(StockReceived.new(
stock_id: id,
product_id: product_id,
quantity: quantity,
lot_number: lot_number,
received_at: received_at
))
self
end
def available_quantity
total_quantity - reserved_quantity
end
def low_stock?
available_quantity <= low_stock_threshold
end
def domain_events
@domain_events.dup.freeze
end
def clear_events
@domain_events.clear
self
end
private
def reserved_quantity
@reservations.sum(&:quantity)
end
def low_stock_threshold
10 # 本来は商品カテゴリごとに設定する
end
def add_event(event)
@domain_events << event
end
end
class StockReservation
attr_reader :order_id, :quantity, :reserved_at
def initialize(order_id:, quantity:, reserved_at:)
@order_id = order_id
@quantity = quantity
@reserved_at = reserved_at
freeze
end
end
class InsufficientStock < StandardError
attr_reader :product_id, :requested, :available
def initialize(product_id:, requested:, available:)
@product_id = product_id
@requested = requested
@available = available
super("商品(#{product_id})の在庫が不足: 必要#{requested}, 利用可能#{available}")
end
end
endリナの気づき
「集約の境界を決めるのが一番難しい」
Order と Stock を1つの集約にしたいという誘惑がある。一緒に変更するなら一緒に管理した方が整合性が保てそうだ。でも、それは違う。
1つの集約にすると:
- 注文確定のたびに全在庫データをロックする
- 在庫が増えるほどパフォーマンスが悪化する
- 在庫の変更と注文の変更が互いに干渉する
「一緒に変更される必要があるか」という問いが、境界を決める最良のガイドだ。OrderItem(注文明細)は注文と一緒に変更される——だから Order 集約に含める。Stock(在庫)は注文とは独立して変更される——だから別の集約にする。
2つの集約の整合性はドメインイベントで最終的に達成する。完璧な即時整合性ではなく、「最終的に整合する」という設計が、スケーラビリティを生む。
まとめ
- 集約 = 整合性の境界を形成するオブジェクトのクラスタ
- 集約ルート = 外部からアクセスできる唯一の入口
- 1トランザクション = 1集約 = 基本ルール(競合・パフォーマンスのため)
- 小さな集約 = 並行性と性能の観点から望ましい
- 集約間の連携 = ドメインイベントで最終的整合性を実現
- 楽観的ロック = 集約の並行更新を安全に管理する
次の章では、集約の永続化を担う「リポジトリパターン」を学ぶ。データベースへの直接依存がテストを困難にする問題を、リポジトリが解決する。