mybook

集約 — 整合性の境界

「なぜ在庫の不整合が起きるんだ」

リナはインシデントレポートを読んでいた。注文確定時に在庫が引き当てられるはずが、二重引き当てが発生し、実際には存在しない在庫が注文されてしまう問題だ。倉庫から「在庫が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)と呼ばれるエンティティが存在し、集約内の他のオブジェクトへのアクセスはすべて集約ルートを通じて行われる。

Loading diagram...

INFO

集約の核心ルール: 外部からは集約ルートしか参照してはならないOrderOrderItem に直接アクセスしてはいけない。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
end

WARNING

複数の集約を1トランザクションで変更するのは設計の臭い(smell)だ。注文と在庫を同じトランザクションで変更したくなるが、それは「1集約1トランザクション」原則の違反だ。代わりにドメインイベントを使って最終的整合性(Eventual Consistency)を達成する。

集約サイズのガイドライン

集約は小さく保つ。パフォーマンスと整合性のバランスが取れる。

Loading diagram...

悪い例: 大きすぎる集約

# 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
end

Railsでの実装パターン

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

リナの気づき

「集約の境界を決めるのが一番難しい」

OrderStock を1つの集約にしたいという誘惑がある。一緒に変更するなら一緒に管理した方が整合性が保てそうだ。でも、それは違う。

1つの集約にすると:

  • 注文確定のたびに全在庫データをロックする
  • 在庫が増えるほどパフォーマンスが悪化する
  • 在庫の変更と注文の変更が互いに干渉する

「一緒に変更される必要があるか」という問いが、境界を決める最良のガイドだ。OrderItem(注文明細)は注文と一緒に変更される——だから Order 集約に含める。Stock(在庫)は注文とは独立して変更される——だから別の集約にする。

2つの集約の整合性はドメインイベントで最終的に達成する。完璧な即時整合性ではなく、「最終的に整合する」という設計が、スケーラビリティを生む。

まとめ

  • 集約 = 整合性の境界を形成するオブジェクトのクラスタ
  • 集約ルート = 外部からアクセスできる唯一の入口
  • 1トランザクション = 1集約 = 基本ルール(競合・パフォーマンスのため)
  • 小さな集約 = 並行性と性能の観点から望ましい
  • 集約間の連携 = ドメインイベントで最終的整合性を実現
  • 楽観的ロック = 集約の並行更新を安全に管理する

次の章では、集約の永続化を担う「リポジトリパターン」を学ぶ。データベースへの直接依存がテストを困難にする問題を、リポジトリが解決する。