ドメインモデリング — 業務の言葉をコードの形にする
テーブル設計から始めると、業務ルールの置き場所がなくなる。何が概念で、何が制約かを見つける作業。
先に読んでおくとよい記事
この記事の進み方
What — 3種類の構成要素
ドメインモデリングは、業務の話を聞いて何が概念で、何が制約かを見つける作業です。見つけたものは、だいたい3種類に分かれます。
値そのものが同一性を決める。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 ではなく専用の型で扱うと、何が防げますか?
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 で参照し、結果整合でよい)
集約は小さいほどよいとされます。大きいと、更新時にロックする範囲が広がり、同時実行性能が落ちるからです。
テーブル設計から始めると、業務ルールはどこに置かれがちですか?
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
この業務をどうモデリングしますか?
- レンタルスペースの予約システム。業務担当者への聞き取り内容は以下
- 「予約は開始時刻と終了時刻で管理します。終了は開始より後でないといけません」
- 「同じ部屋で時間が重なる予約は取れません」
- 「料金は 30 分単位で、部屋ごとの単価 × コマ数です」
- 「予約確定後は時間を変更できません。キャンセルして取り直しです」
- 「キャンセルは開始 24 時間前まで無料、それ以降は 50% のキャンセル料がかかります」
- 「法人のお客様は請求書払いなので、予約時点では決済しません」
- 現在の実装は Reservation クラスに startAt: Date, endAt: Date, price: number があるだけ
- · 聞き取りの中で、どれが概念でどれが制約か
- · 「時間が重なる予約は取れない」は、どこで守るべきルールか
- · 値オブジェクトにすべきものはどれか
「まず ER 図を書こう」という進め方に、先に確認したいことを説明してください
- 相手はテーブル設計から着手しようとしている
- 業務にはいくつかの状態遷移ルールがある
- ER 図を否定するのではなく順序の話にしたい
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。