mybook

ユビキタス言語 — チームの共通語を作る

「在庫引き当てのロジックを変えたい」

カズキの要望を聞いたリナは、コードを検索した。「在庫引き当て」という言葉は、コードの中にどこにも存在しなかった。Stock.decrement!inventory_reserveitem_holddecrease_stock...さまざまな表現が散在していた。

グローバルな grep をかけてみる。

# FreshCartのコードベースで "reserve" を検索
$ grep -r "reserve" app/ --include="*.rb" -l
app/models/stock.rb
app/services/cart_service.rb
app/workers/order_processing_worker.rb
app/models/inventory.rb
app/controllers/orders_controller.rb

5ファイルに「在庫引き当て」に関係する処理が存在しているが、それぞれ異なる実装だ。どれが正規の実装なのか、それぞれどういう違いがあるのか、コードを追わなければわからない。

リナはため息をついた。「カズキに『カート追加時に仮押さえしたい』と言われても、今の仕様がどのコードなのかも特定できない」

ユビキタス言語とは

ユビキタス言語(Ubiquitous Language)とは、開発チームとドメインエキスパート(ビジネス側)が共有する共通の語彙のことだ。

「ユビキタス」は「どこにでも存在する」という意味だ。チームの会話、ドキュメント、コード、テスト、データベーススキーマ——あらゆる場所で同じ言葉を使う。

Loading diagram...

INFO

ユビキタス言語は「ビジネス用語をそのまま使う」という単純なルールだが、これがコードの品質を劇的に改善する。コードを読めば仕様がわかり、仕様を聞けばコードの場所が予測できる。「在庫引き当て」という言葉を変更したいと言われたら、StockReservation クラスを見ればいい。

言語の乖離がもたらすコスト

リナはFreshCartのコードベースで「同じ概念に使われている異なる言葉」を調査した。

ビジネス概念コード内の表現(バラバラ)
在庫引き当てstock_reserve, item_hold, inventory_lock, decrement_stock, hold_quantity
注文確定process(id, 1), confirm_order, finalize, complete_purchase, seal_order
商品item, product, goods, merchandise, sku
顧客user, customer, buyer, purchaser, member
配送先住所address, shipping_address, delivery_addr, dest, to_addr
注文合計total_price, amount, total, grand_total, order_amount

この混乱が認知負荷を増大させ、バグを生み出す温床になっていた。「在庫引き当てのバグを直して」と言われた時、どのファイルのどのコードを直すべきか、経験豊富なエンジニアでも判断できない。

この「翻訳コスト」を数値で考えてみよう。

1つの概念について5つの異なる名前がある場合:
- コードを書く時: どの名前を使うべきか判断する (5分)
- コードを読む時: 「これはどの概念を指しているか」理解する (3分)
- 新機能を追加する時: 全パターンを探して影響確認 (30分)
- バグを修正する時: 全パターンを修正 (1時間)
- 新メンバーへの説明: 「このプロジェクトでの用語説明」 (2時間)

10の概念 × このコスト = 毎週数時間の無駄

ユビキタス言語の価値は「1週間で数時間の節約」という形で積み重なっていく。

用語集を作る実践

リナはカズキとともに「用語統一ワークショップ」を開催した。1時間のセッションで、FreshCartの主要な概念とその正式名称を決めていく。

ルールはシンプルだ:

  1. 概念を書き出す(ホワイトボードに付箋を貼る)
  2. 各概念について合意した英語名を決める
  3. 決まったら変えない(変える場合はチーム全員の合意が必要)
  4. コードに反映する

FreshCart ユビキタス言語辞典 v1.0

注文(Order)

  • 顧客がカートを確定したもの
  • ライフサイクル: pendingconfirmedshippeddelivered | cancelled
  • 注: カート(Cart)は注文になる前の段階。別の概念として扱う

在庫引き当て(StockReservation)

  • 注文確定時に商品の在庫を一時的に確保する行為
  • 引き当て数量が在庫数量を超える場合は注文不可
  • キャンセル時は在庫引き当てを「解放(release)」する

商品(Product)

  • FreshCartが販売する食料品の単位
  • ProductVariant(サイズ・容量違い)を持つ場合がある
  • 注: 内部でバラバラに使われていた item, goods, sku は廃止

顧客(Customer)

  • FreshCartのサービスを利用する人
  • 注: 内部実装の User(認証・ログイン管理)とは概念を区別する
  • ビジネス文脈では常に Customer、認証文脈では User

配送先(DeliveryAddress)

  • 顧客が注文時に指定する届け先
  • 顧客が保存している住所帳(AddressBook)から選択するか、新規入力
  • 注: shipping_address, delivery_addr などは廃止

在庫(Stock)

  • 倉庫内にある商品の実数量
  • available_quantity(引き当て可能数量)= total_quantity - reserved_quantity

WARNING

用語集は「決めたら終わり」ではない。ビジネスが進化するにつれて言語も進化する。定期的に見直し、コードと同期させることが重要だ。用語の変更は「コードのリネーム」とセットで行う。

コードへの反映

ユビキタス言語を決めたら、コードに反映する。これは単なるリネームではなく、設計の改善だ。名前が変わることで、コードの構造自体も改善される。

Before: 曖昧なコード

# 何をする処理か、名前からわからない
def process(id, type, flag = false)
  obj = Obj.find(id)
  if type == 1
    obj.update(status: flag ? 2 : 1)
    Stock.where(item_id: obj.item_id).first.decrement!(:count)
    Mailer.send_mail(obj.user_id, type)
  elsif type == 2
    obj.update(status: 3)
    Stock.where(item_id: obj.item_id).first.increment!(:count)
    Mailer.send_mail(obj.user_id, type)
  end
end
 
# 呼び出し元 — 何をしているのかわからない
process(order_id, 1)       # ??
process(order_id, 2, true) # ???

After: ユビキタス言語を反映したコード

class Order < ApplicationRecord
  # ビジネスの「注文ライフサイクル」がコードに現れている
  enum :status, {
    pending:   'pending',
    confirmed: 'confirmed',
    shipped:   'shipped',
    delivered: 'delivered',
    cancelled: 'cancelled'
  }
 
  def confirm!
    raise Order::InvalidStateTransition,
      "#{status}状態の注文は確定できません" unless pending?
 
    transaction do
      reserve_stock!              # 在庫引き当て(ビジネス用語のまま)
      update!(status: :confirmed, confirmed_at: Time.current)
      OrderConfirmationMailer.send_confirmation(self).deliver_later
    end
  end
 
  def cancel!(reason:)
    raise Order::InvalidStateTransition,
      "配送済みの注文はキャンセルできません" if shipped? || delivered?
 
    transaction do
      release_stock_reservations! # 在庫引き当て解放(ビジネス用語のまま)
      update!(status: :cancelled, cancellation_reason: reason)
      OrderCancellationMailer.send_cancellation(self).deliver_later
    end
  end
 
  private
 
  def reserve_stock!
    order_items.each do |order_item|
      StockReservation.reserve!(
        product: order_item.product,
        quantity: order_item.quantity,
        order: self
      )
    end
  end
 
  def release_stock_reservations!
    StockReservation.where(order: self).each(&:release!)
  end
end

confirm! は注文確定の行為を、cancel! はキャンセルを、reserve_stock! は在庫引き当てを表している。コードを読めば、ビジネスで何が起きているかが理解できる。

StockReservation クラスの実装

class StockReservation < ApplicationRecord
  belongs_to :product
  belongs_to :order
 
  class InsufficientStockError < StandardError
    attr_reader :product_name, :requested_quantity, :available_quantity
 
    def initialize(product_name:, requested_quantity:, available_quantity:)
      @product_name = product_name
      @requested_quantity = requested_quantity
      @available_quantity = available_quantity
      super("#{product_name}の在庫が不足しています。" \
            "必要数: #{requested_quantity}, 在庫数: #{available_quantity}")
    end
  end
 
  # 在庫引き当て — ビジネス用語がそのままメソッド名に
  def self.reserve!(product:, quantity:, order:)
    product.with_lock do
      available = product.available_quantity
 
      if available < quantity
        raise InsufficientStockError.new(
          product_name: product.name,
          requested_quantity: quantity,
          available_quantity: available
        )
      end
 
      reservation = create!(
        product: product,
        order: order,
        reserved_quantity: quantity,
        reserved_at: Time.current,
        status: :active
      )
 
      product.decrement!(:available_quantity, quantity)
      reservation
    end
  end
 
  # 在庫引き当て解放 — 「解放」というビジネス用語をメソッドに
  def release!
    transaction do
      product.increment!(:available_quantity, reserved_quantity)
      update!(status: :released, released_at: Time.current)
    end
  end
end

「在庫引き当て」という概念が StockReservation クラスとして存在し、reserve! メソッドが「引き当て」という動詞を、release! が「解放」という動詞を体現している。

テストにもユビキタス言語を

テストコードにも同じ言語を使う。テストはドキュメントとしても機能する。ビジネス担当者が読んで理解できるテストが理想だ。

RSpec.describe Order, '注文確定' do
  let(:customer) { create(:customer, membership_rank: :gold) }
  let(:product) { create(:product, name: 'りんご 1kg', available_quantity: 10) }
  let(:order) { create(:order, customer: customer, status: :pending) }
 
  before do
    create(:order_item, order: order, product: product, quantity: 3)
  end
 
  context '在庫が十分な場合' do
    it '注文が確定される' do
      order.confirm!
      expect(order.reload.status).to eq('confirmed')
    end
 
    it '在庫が引き当てられる' do
      order.confirm!
 
      reservation = StockReservation.find_by(order: order, product: product)
      expect(reservation).to be_present
      expect(reservation.reserved_quantity).to eq(3)
    end
 
    it '引き当て後の利用可能在庫が減少する' do
      order.confirm!
      expect(product.reload.available_quantity).to eq(7) # 10 - 3
    end
 
    it '注文確定メールが送信される' do
      expect(OrderConfirmationMailer)
        .to receive(:send_confirmation).with(order)
        .and_call_original
 
      order.confirm!
    end
  end
 
  context '在庫が不足している場合' do
    before { product.update!(available_quantity: 2) }
 
    it '在庫不足エラーが発生する' do
      expect { order.confirm! }
        .to raise_error(StockReservation::InsufficientStockError)
        .with_message(/りんご 1kg/)
    end
 
    it '注文状態がpendingのまま変わらない(ロールバック)' do
      order.confirm! rescue nil
      expect(order.reload.status).to eq('pending')
    end
 
    it '在庫が変化しない(ロールバック)' do
      order.confirm! rescue nil
      expect(product.reload.available_quantity).to eq(2)
    end
  end
 
  context '既に確定済みの注文の場合' do
    before { order.update!(status: :confirmed) }
 
    it '二重確定エラーが発生する' do
      expect { order.confirm! }
        .to raise_error(Order::InvalidStateTransition)
        .with_message(/confirmed/)
    end
  end
end

テストを読むだけで「在庫が十分な場合、注文が確定され、在庫が引き当てられる」というビジネスルールが理解できる。テストがビジネス仕様書の役割を果たしている。

データベーススキーマにも反映

# db/migrate/20240101000001_create_stock_reservations.rb
class CreateStockReservations < ActiveRecord::Migration[7.1]
  def change
    create_table :stock_reservations do |t|
      t.references :product, null: false, foreign_key: true
      t.references :order, null: false, foreign_key: true
      t.integer :reserved_quantity, null: false
      t.datetime :reserved_at, null: false
      t.datetime :released_at    # キャンセル時に在庫引き当てを解放した日時
      t.string :status, null: false, default: 'active'  # active / released
 
      t.timestamps
    end
 
    # 同一注文で同一商品の重複引き当てを防ぐ
    add_index :stock_reservations, [:order_id, :product_id],
      unique: true,
      where: "status = 'active'",
      name: 'idx_active_reservations_uniqueness'
 
    add_index :stock_reservations, :status
  end
end

テーブル名 stock_reservations(在庫引き当て)、カラム名 reserved_quantity(引き当て数量)、reserved_at(引き当て日時)、released_at(解放日時)——全てユビキタス言語に沿っている。

DBAとエンジニアが同じ用語でデータベースを議論できる。「stock_reservations テーブルの released_at がNULLのレコードが引き当て中の在庫です」という説明は、ビジネス担当者にも理解しやすい。

INFO

データベーススキーマにユビキタス言語を使うメリット: SQLを書く時もビジネス概念で考えられる。「アクティブな在庫引き当てを一覧する」という要件が、SELECT * FROM stock_reservations WHERE status = 'active' というSQLに自然に対応する。

APIレスポンスにも反映

フロントエンドや外部APIとのコントラクトにも同じ言語を使う。

// NG: 内部実装の言葉が漏れているAPIレスポンス
{
  "ord_id": 12345,
  "stat": 1,
  "items": [
    { "itm_id": 999, "qty": 3, "prc": 200 }
  ]
}
 
// OK: ユビキタス言語を使ったAPIレスポンス
{
  "order_id": "ord-12345",
  "status": "confirmed",
  "order_items": [
    {
      "product_id": "prod-999",
      "product_name": "りんご 1kg",
      "quantity": 3,
      "unit_price": { "amount": 200, "currency": "JPY" }
    }
  ],
  "total_amount": { "amount": 600, "currency": "JPY" },
  "confirmed_at": "2024-01-15T10:30:00+09:00"
}

フロントエンドエンジニアとの会話でも「statusconfirmed になったら確定メールを表示してください」と話せる。内部コードの話ではなく、ビジネスの話として。

ユビキタス言語の進化管理

言語は進化する。管理の方法を決めておく。

# FreshCart ユビキタス言語辞典
 
バージョン: 2.1
最終更新: 2024-03-01
オーナー: プロダクトチーム
レビュアー: エンジニアチームリード、ビジネスアナリスト
 
## 変更履歴
 
### v2.1 (2024-03-01)
- `StockAllocation``StockReservation` に改名
  - 理由: 「割り当て」より「引き当て」がビジネス側の一般的な言い方
  - 関連コード変更: PR #234
 
### v2.0 (2024-02-01)
- `item``product` に統一
  - 理由: ドメインエキスパートが「商品」と言う時の英語は `product`
  - `item` は「注文明細の一行」(`OrderItem`) の意味に限定
 
### v1.0 (2024-01-15)
- 初版作成

バージョン管理されたドキュメントとして保持し、コードのPRと一緒にレビューする。用語が変わる際は、コード変更とドキュメント変更を同じPRに含める。

実践でよく起きる抵抗

リナがチームに「ユビキタス言語の統一」を提案した時、いくつかの抵抗があった。

「今のコードを全部書き直すの?」

→ 違う。新しくコードを書く時から使い始め、変更する機会があれば徐々に統一する。

「英語の命名は難しい」

→ まず日本語で合意してから英語化する。「在庫引き当て」→ StockReservation。ビジネス担当者の言葉を尊重する。

「チームによって言い方が違う」

→ それがユビキタス言語が必要な理由だ。全員が合意する一つの名前を選ぶ。

WARNING

ユビキタス言語の失敗パターン: 「エンジニアだけで決める」こと。ビジネス担当者が「その言葉は聞いたことない」という名前を使っても意味がない。必ずビジネス側と共同で作業する。

ユビキタス言語とAPIデザイン

ユビキタス言語はコードの内部だけでなく、外部に公開するAPIにも反映すべきだ。

# Bad: 内部実装の言葉がAPI仕様に漏れている
# POST /api/v1/orders
{
  "status": 1,           # 1が何か知らないとわからない
  "item_refs": [...],    # refsって何?
  "usr_id": 123          # 省略系が意味不明
}
 
# Good: ユビキタス言語に基づいたAPI
# POST /api/v1/orders
{
  "customer_id": 123,
  "delivery_address": {
    "postal_code": "150-0001",
    "prefecture": "東京都",
    "city": "渋谷区"
  },
  "order_items": [
    {
      "product_id": "PROD-001",
      "quantity": 2
    }
  ]
}

APIレスポンスも同様だ。

# Bad: DB構造がそのまま出てしまっている
{
  "id": 1,
  "status_cd": 2,         # status_cdって何?
  "ttl_amt": 5400,        # ttl_amtって?
  "created_at": "..."
}
 
# Good: ビジネスの言葉で表現
{
  "order_id": "ORD-20240315-001",
  "order_status": "confirmed",              # ステータスが明確
  "total_amount": {
    "amount_jpy": 5400,                     # 単位が明確
    "formatted": "¥5,400"
  },
  "ordered_at": "2024-03-15T10:30:00+09:00" # ordered_at は注文日時
}

INFO

外部APIはコードよりも変更が難しい。一度公開したAPIフィールド名を変えるとクライアントが壊れる。ユビキタス言語でAPI設計すれば、ビジネス用語との対応が取れたフィールド名になり、長期的に意味が変わりにくい。

DBスキーマへの反映

ユビキタス言語はDBのカラム名にも反映する。

# Bad: 短すぎる・意味不明なカラム名
create_table :orders do |t|
  t.integer :uid           # user_idのこと?unit_id?
  t.integer :st            # status? stock?
  t.integer :ttl           # total? title?
  t.integer :typ           # type? typography?
  t.datetime :upd          # updated_at?
end
 
# Good: ユビキタス言語に基づいたカラム名
create_table :orders do |t|
  t.references :customer, null: false, foreign_key: true
  t.string :status, null: false, default: 'pending'
  t.integer :total_amount_cents, null: false, default: 0
  t.string :currency, null: false, default: 'JPY'
  t.datetime :confirmed_at
  t.datetime :cancelled_at
  t.timestamps
end

スキーマを見ればビジネスの構造がわかる状態を目指す。

テストの名前もユビキタス言語で

テストの説明文も共通言語で書く。これにより、テストが「仕様書」として機能する。

# Bad: 技術的な記述のテスト
RSpec.describe Order do
  describe '#confirm!' do
    it 'updates status field to confirmed' do
      order = create(:order, status: 'pending')
      order.confirm!
      expect(order.status).to eq('confirmed')
    end
 
    it 'calls Stock.decrement with correct args' do
      # ...
    end
  end
end
 
# Good: ビジネスルールを記述したテスト
RSpec.describe OrderContext::Order do
  describe '#confirm!' do
    context '注文を確定する' do
      it 'pending状態の注文が確定済みになる' do
        order = build_pending_order_with_items
        order.confirm!
        expect(order.status).to eq(OrderContext::OrderStatus::CONFIRMED)
      end
 
      it '在庫がない場合は確定できない' do
        order = build_pending_order_with_out_of_stock_items
        expect { order.confirm! }.to raise_error(OrderContext::InsufficientStock)
      end
 
      it '既に確定済みの注文は再確定できない' do
        order = build_confirmed_order
        expect { order.confirm! }.to raise_error(OrderContext::InvalidTransition,
          /confirmed状態の注文は確定できません/)
      end
    end
  end
end

テストの説明を読めば、そのコードが何のビジネスルールを実装しているかが一目でわかる。

リナの気づき

2週間後、リナとカズキは改善されたコードをレビューした。

「在庫引き当てのロジックを変えたい」とカズキが言った時、リナは即座に「 StockReservation クラスの reserve! メソッドと、注文確定時に呼ばれる reserve_stock! を変更することになります」と答えられた。

コードとビジネスが同じ言語で繋がった瞬間だった。

「こんなに変わるんだね」とカズキが言った。「コードを見せてもらう気になってきた」

それがユビキタス言語の本当の効果だ。エンジニアだけのものだったコードが、ビジネス担当者も関与できるものになる。

まとめ

  • ユビキタス言語 = チーム全員が使う共通の語彙
  • 適用範囲 = 会話・コード・テスト・DBスキーマ・APIレスポンス・ドキュメントのすべて
  • 作り方 = エンジニアとビジネス担当者が共同でワークショップを開き合意する
  • 維持方法 = バージョン管理された用語集、コード変更とセットで更新
  • 効果 = 認知負荷の低下、バグの減少、チームコミュニケーションの改善

次の章では、ドメインを適切な単位に分割する「境界づけられたコンテキスト」を学ぶ。FreshCartの Product クラスが2000行になった謎が解けるはずだ。