仕組みから学ぶ Web
発展読了目安 16#ドメインモデル#DDD#値オブジェクト#モデリング

ドメインモデリング — 業務の言葉をコードの形にする

テーブル設計から始めると、業務ルールの置き場所がなくなる。何が概念で、何が制約かを見つける作業。

この記事の進み方

What — 3種類の構成要素

ドメインモデリングは、業務の話を聞いて何が概念で、何が制約かを見つける作業です。見つけたものは、だいたい3種類に分かれます。

Compare値オブジェクト・エンティティ・集約

値そのものが同一性を決める。1000円は1000円で、どの1000円かは問わない。

class Money {
private constructor(
  private readonly amount: number,
  private readonly currency: Currency,
) {
  if (!Number.isInteger(amount)) throw new Error('金額は整数');
}

static yen(amount: number) { return new Money(amount, Currency.JPY); }

add(other: Money): Money {
  if (this.currency !== other.currency) {
    throw new Error('通貨が異なります');   // ← 型が事故を防ぐ
  }
  return new Money(this.amount + other.amount, this.currency);
}
}
同一性
値が等しければ同じ
変更
不変。変更は新しい値を作る
持つもの
値と、その値に関するルール
金額・メールアドレス・期間・住所
境界の決め方
トランザクション
外からの参照
大きさ
  • 「不正な値をそもそも作れない」ようにコンストラクタで検証する。生成できた時点で正しいことが保証される。
  • number や string で表していたものを値オブジェクトにすると、ルールの置き場所ができる。

「同一性をどう判断するか」と「どこまでを一度に整合させるか」で分かれる。この区別がつくと、置き場所が決まる。

値オブジェクトが事故を止める

冒頭の事故は、型で表現するだけで防げます。

// number のまま:コンパイルが通ってしまう
const total: number = subtotalExcludingTax + taxIncludedAmount;

// 型を分ける:コンパイルが通らない
const total: TaxIncluded = subtotal.add(taxIncluded);
//                         ^^^^^^^^ TaxExcluded に TaxIncluded は足せない
class TaxExcluded {
  constructor(readonly value: Money) {}
  withTax(rate: TaxRate): TaxIncluded {
    return new TaxIncluded(this.value.multiply(1 + rate.value));
  }
}
class TaxIncluded {
  constructor(readonly value: Money) {}
}

税込と税抜が別の型になれば、混ぜること自体が不可能になります。 レビューで気をつける必要も、テストで検出する必要もありません。

確認 — ここまで読めたか

金額を number ではなく専用の型で扱うと、何が防げますか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — なぜテーブル設計から始めてはいけないのか

多くのプロジェクトは、テーブル設計から始まります。そして、こういうコードになります。

// テーブルの形をそのままクラスにした(貧血モデル)
class Order {
  id: number;
  status: string;
  total: number;
  createdAt: Date;
  // getter / setter しかない
}

// ルールはサービス層に散らばる
class OrderService {
  cancel(order: Order) {
    if (order.status === 'shipped') throw ...;
    if (daysSince(order.createdAt) > 7) throw ...;
    order.status = 'cancelled';
  }
}

これ自体が常に悪いわけではありません。CRUD が中心で業務ルールが少ないなら、この形が最も簡潔です。

問題になるのは、ルールが増えたときです。

  • ルールがサービス層に散らばり、同じ判定が複数箇所にコピーされる
  • order.status = 'cancelled' を誰でもどこでも実行できるので、ルールを通さない経路が生まれる
  • 「この状態はありえない」という保証がどこにもない

オブジェクトが状態だけを持ち、ルールを持たない状態を「貧血ドメインモデル」と呼びます。

業務の言葉を使う

モデリングの出発点は、業務の人が使っている言葉です。

業務の人「発送済みの注文はキャンセルできません」
         「7日を過ぎるとキャンセル期限切れです」
         「見積は確定すると金額を変更できません」

この言葉の中に、概念(注文・見積)と状態(発送済み・確定)と制約(キャンセルできない・変更できない)が全部入っています。

order.cancel()          // 業務の言葉と同じ
order.status = 'cancelled'   // 実装の都合の言葉

前者なら、業務の人と同じ語彙で会話できます。「キャンセルの条件が変わりました」と言われたとき、どこを見ればよいかが即座に分かる

集約の境界の決め方

集約は「同時に整合していなければならない範囲」です。

注文と明細 → 同じ集約
  「明細の合計が注文の合計と一致している」を常に保ちたい

注文と顧客 → 別の集約
  「顧客の名前が変わったとき、注文を同時に更新する必要はない」

判断の問いは「片方だけ更新された中間状態が、一瞬でも存在してよいか」です。

  • 存在してはいけない → 同じ集約(1トランザクションで守る)
  • 存在してよい → 別の集約(ID で参照し、結果整合でよい)

集約は小さいほどよいとされます。大きいと、更新時にロックする範囲が広がり、同時実行性能が落ちるからです。

確認 — ここまで読めたか

テーブル設計から始めると、業務ルールはどこに置かれがちですか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この業務をどうモデリングしますか?

与えられた条件
  • レンタルスペースの予約システム。業務担当者への聞き取り内容は以下
  • 「予約は開始時刻と終了時刻で管理します。終了は開始より後でないといけません」
  • 「同じ部屋で時間が重なる予約は取れません」
  • 「料金は 30 分単位で、部屋ごとの単価 × コマ数です」
  • 「予約確定後は時間を変更できません。キャンセルして取り直しです」
  • 「キャンセルは開始 24 時間前まで無料、それ以降は 50% のキャンセル料がかかります」
  • 「法人のお客様は請求書払いなので、予約時点では決済しません」
  • 現在の実装は Reservation クラスに startAt: Date, endAt: Date, price: number があるだけ
この軸で考える
  • · 聞き取りの中で、どれが概念でどれが制約か
  • · 「時間が重なる予約は取れない」は、どこで守るべきルールか
  • · 値オブジェクトにすべきものはどれか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「まず ER 図を書こう」という進め方に、先に確認したいことを説明してください

与えられた条件
  • 相手はテーブル設計から着手しようとしている
  • 業務にはいくつかの状態遷移ルールがある
  • ER 図を否定するのではなく順序の話にしたい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。