📗

[DDD in TS]: 意外と便利な「仕様」を理解する

2024/06/11に公開

自ブログからの引用です。

概要

仕様DDDの原典(エリックエヴァンスのドメイン駆動設計)に登場する概念なのですが、DDDを学習していてもあまり見かけることがありませんでした。しかし、原典の内容を読み直して見ると意外と利用できる場面も多いと感じました。

本記事では、仕様とは何か?を解説したあと、原典で紹介されている例をTypeScriptを使って解説をしていきたいと思います。

「仕様」ってなに?

ドメインやユースケースを実装ていると、よく boolean を返すような関数を書くことがあります。例えば、Validationなどの事前条件の判定や、「この'商品'は軽減税率か?」といった特徴の判定などです。こういった処理を、切り出した物が仕様になります。

原典での定義は以下の通りです。

p228 仕様とは、あるオブジェクトが何らかの基準を満たしているかどうかを判定する述語である。

(※: 述語という言葉が出てきますが、論理プログラミングの用語のようで、理解としては単純にbooleanを返す関数をイメージすると良さそうです。)

なぜ「仕様」に切り出す必要があるのか

原典では主に、以下の3点を挙げています。

  1. 判定ロジックが複雑になりがち
  2. 特定のドメインオブジェクトにとっては存在感が薄いことがある
  3. 複数のドメインオブジェクトに跨るルールがある

1について、みなさんも経験があると思いますが、事前条件・事後条件の確認するための判定コードは年々複雑化し、複雑に絡み合っていくことがあります。
2に関しては、後述の請求書の例を確認していただきたいと思いますが、細かな判定ロジックばかりがかさばってしまい、ドメインオブジェクト本来の振る舞いが埋もれてしまうことがあります。
3については、抽象的な表現になりますが、データの整合性を判定するようなロジックは他のドメインオブジェクトなどに依存するケースがよくありますが、条件判定のためだけに関数に本来不要なパラメータ(依存関係)が増えてしまうことがあります。

以上のような負を解決する際に、仕様という概念を用いると、整理が進んで便利なことがあります。

「仕様」の活用事例

原典では仕様が有用になるパターンとして、以下の3つを挙げています。

  1. 検証
    • 事前条件・事後条件の確認
  2. 選択
    • 配列などから、条件に一致するオブジェクトを選択する
  3. 要求に応じた構築

以下で、それぞれのパターンを噛み砕いて行きたいと思います。

1.検証

例として、請求管理システムで、以下のユースケースを実装することを考えます。

ユースケース:
「ある 顧客請求書 について、金額の大きい 巨額請求書 に合致するもののリストを取得したい」

この時に、ある請求書巨額請求書に合致するか判定するための関数の置き場所を考えます。
単純に考えると請求書クラスに配置するのが良さそうです。

イメージ

class 請求書 {
  constructor(public 金額: number) {}

  // 100万円以上の請求書
  is巨額請求書(): boolean {
    return this.金額 > 1_000_000;
  }
}

このコードには全く問題ありません。特に問題を感じることがなければこの実装のままで良いと思われます。
仕様が効果的になるのは、時間とともにシステムの要求が膨らんで、複雑になっていった時です。請求書の種類は巨額の他にも様々な種類が想定されます。

例えば、延滞請求書, 電子請求書, 定期請求書, 過去請求書, 前払請求書, 割引請求書, 手数料請求書, ... などなど

このように無数に種類が増えて行った時に、請求書クラスを拡張してisXXXクラスを追加し続けると、仔細な判定ロジックの山で請求書クラス本来の振る舞いが埋もれてしまいます。
結果として、折角のドメインオブジェクトが分かりにくくなってしまいます。

そのような時は、仕様に切り分けることで、請求書クラス自体はシンプルに保ちつつ、多様な仕様を切り分けていくことができます。

// entity/請求書/index.ts <= ディレクトリの例
class 請求書 {
  constructor(public 金額: number, public 支払い期限: Date) {}
}

// entity/請求書/仕様/interface.ts
type 請求書仕様 = {
  is満たす: (i: 請求書, ...args: any[]) => boolean;
}

// entity/請求書/仕様/巨額.ts
const 巨額請求書仕様: 請求書仕様 = {
  is満たす: (i) => i.金額 > 1_000_000,
}

// entity/請求書/仕様/延滞.ts
const 延滞請求書仕様: 請求書仕様 = {
  is満たす: (i) => i.支払い期限 < today,
}

以上のように判定ロジックを分離することで、請求書クラスをシンプルに保つことができました。

2.選択

請求書仕様の例に関して、ユースケース側のコードを考えて見ましょう。
ある顧客請求書の中から、巨額請求書を一覧したいので、顧客クラスにメソッドを追加して見ます。

class 顧客 {
  constructor() {}

  get請求書一覧(): 請求書[] {
    return /* DBから請求書を一覧する */;
  }

  get巨額請求書一覧(): 請求書[] {
    return this.get請求書一覧().filter(巨額請求書仕様.is満たす /* 仕様を利用する*/ );
  }
}

1.検証2.選択は根本的には同じことしている(booleanのまま返すか、filterするか?の違い)ので、仕様の分割方法は同じになります。

しかしながら、お気づきの通りこのコードには大きな欠点があります。巨額請求書に該当していない請求書も全て取得する必要があることで、DBへのアクセスにオーバーヘッドが出てしまいそうです。

原典にはこんな啓示がありました。

p232 モデルの焦点は、こうした他の技術と交差するところで失われがちなのです。

筆者の経験上も、基本的に保守性パフォーマンスはトレードオフの関係にあると考えています。そのため、完璧な書き方はなかなか見つからないことが多いですが、両者のバランスをとって折衷させていくのが重要です。

そこで、原典で示されている1つの対応方法は、仕様側にSQLを書いてしまう方式です。

class 請求書 {
  constructor(public 金額: number) {}
}

type 請求書仕様 = { asSQL: () => string };

const 巨額請求書仕様: 請求書仕様 = {
  asSQL(): string {
    return `SELECT * FROM 請求書 WHERE 金額 > 1_000_000`;
  }
}

class 請求書Repository {
  constructor(private 本日: Date) {}

  仕様に一致する一覧(仕様: 請求書仕様): 請求書[] {
    return db.query(仕様.asSQL());
  }
}

class 顧客 {
  constructor(private repository: 請求書Repository) {}

  get巨額請求書一覧(): 請求書[] {
    return this.repository.仕様に一致する一覧(巨額請求書仕様);
  }
}

このように、SQLをドメイン層にある仕様に記載することで、巨額請求書の選択方法に関する知識をドメイン層に留めおくことができています。DB基板側の概念であるSQLをドメイン層に格納するのはいかがなものか?という意見ももちろん自然ですが、この場合はSQL自体が汎用的な概念だと捉えて正当化するようです。

ただ、やっぱりテーブル名のような、明らかにリポジトリ層の知識がドメイン層に混ざってしまっているのは事実です。

そこで、原典では対応策2として、特殊なクエリメソッドを請求書リポジトリに追加するアイディアを示しています。

class 請求書Repository {
  constructor(private 本日: Date) {}

  get指定金額以上の一覧(金額: number): 請求書[] {
    const sql = `SELECT * FROM 請求書 WHERE 金額 >= ${金額}`;
    return db.query(sql);
  }
}

const 巨額請求書仕様 = {
  対象一覧: (repository: 請求書Repository) => {
    return repository.get指定金額以上の一覧(1_000_000);
  }
}

class 顧客 {
  get巨額請求書一覧(): 請求書[] {
    return 巨額請求書仕様.対象一覧(repository);
  }
}

このパターンでは、あまり条件を仕様に集めることができてないことは原典でも言及されていますが、根本的な呼び出しの起点としては仕様を通す形になっており、金額が100万円以上の請求書が巨額請求書である、という知識がドメイン層に記述されています。
一方、Repositoryの実装では金額で絞り込んだ一覧を返すだけで、少し恣意的(特殊)なメソッドになってはいるものの、ドメイン層の概念を表現することなくプレーンに保たれています。

前にも述べた通り、モデル駆動と技術基盤への最適化は両立が難しい部分なので、トレードオフの選び方として非常に参考になる例でした。

3.要求に応じた構築

(ここは簡単な説明だけにします。)
例えば、クラスからインスタンスを作る時に、内装をスタンダード、プレミアムなど複数のカスタム仕様の中から選べるといった場合に、カスタム仕様の内容を仕様クラスに切り出しておき、Factory関数に別途渡すことができます。

その他の例

原典で紹介されている化学薬品倉庫の例も面白かったので、簡略化してご紹介します。

この工場には格納用のコンテナが並んでおり、コンテナに化学薬品を詰める作業を行います。
化学薬品には様々な性質があり、性質によって格納可能なコンテナの性能が限定られます。

ex)

  • 爆発物 => 強化コンテナ
  • 揮発性 => 密閉コンテナ

今回は、コンテナTNT火薬が格納可能か?を判定するコードの作成を考えてみます。

container.格納可能か(tnt火薬);

/**
 * コンテナの性能を定義します
 */
const コンテナ性能 = {
  強化: '強化',
  換気: '換気',
} as const;
type コンテナ性能 = (typeof コンテナ性能)[keyof typeof コンテナ性能];

/**
 * コンテナは0個以上の性能を持っています
 */
class コンテナ {
  性能: コンテナ性能[] = [];

  constructor(性能: コンテナ性能[]) {
    this.性能 = 性能;
  }

  格納可能か(薬品: 薬品) {
    return 薬品.格納仕様.is適合する(this);
  }
}

/**
 * 薬品が格納先のコンテナに要求する仕様
 */
class 格納仕様 {
  constructor(private _必要な性能: コンテナ性能) {}

  is適合する(c: コンテナ) {
    c.性能.includes(this._必要な性能);
  };
}
const 爆発物格納仕様 = new 格納仕様(コンテナ性能.強化);
const 揮発物格納仕様 = new 格納仕様(コンテナ性能.換気);

class 薬品 {
  constructor(public 名前: string,  public 格納仕様: 格納仕様) {}
}

const TNT火薬 = new 薬品('TNT火薬', 爆発物格納仕様);
const 強化コンテナ = new コンテナ([コンテナ性能.強化]);

強化コンテナ.格納可能か(TNT火薬); // => OK

もちろん、この例では判定が簡単すぎて仕様に分離した意義を感じることができませんが、時間とともにコンテナ仕様がより複雑になっていくことが容易に想像できるため、リファクタリングの一つのアイディアとして面白いと思います。

感想

ここからは個人的な感想です。

仕様の第一印象としては、DomainServiceに近いと感じました。
例えば、2.選択巨額請求書の一覧を取得する操作では、複数の請求書インスタンスに依存するため、請求書DomainServiceに定義するという考え方もあります。
ただ、個人的にはDomainServiceDDDの中でも特に分かりにくい部分で、何を切り出すべきか迷ったり、人によってばらつきが出やすいポイントと感じます。
仕様DomainServiceとやっていることは自体はあまり変わりませんが、仕様という概念でとらえることで、ドメインオブジェクトのどのような側面を切り出しているのかが非常に分かりやすくなると感じました。

また、仕様自体はDDDの中で比較的マイナーな概念と認識していますが、仕様切り出されるような細かなルール(述語)がドメイン層やユースケース層を汚染していく経験を何度もしたことがあるので、案外と幅広いシステムで一考に値する概念なのではないかと感じました。

以上です。

GitHubで編集を提案

Discussion