2022年8月13日土曜日

DDDを実践するための手引き(概論・導入編)

https://zenn.dev/kohii/articles/b96634b9a14897

ナニコレ

DDDは「Domain-Driven Design(ドメイン駆動設計)」の略語で、エリック・エヴァンスさんという人が考えるソフトウェア設計におけるプラクティスまとめみたいなものです。

エリック・エヴァンスのドメイン駆動設計』というバイブル的な書籍がありますが、「途中で挫折した」「読んでもよくわからない」「よくわからないけど自分なりに解釈して実践している」というような感想をよく聞きます[1]。DDDの概念は幅広く、哲学的で、抽象的であるため、DDDをどのように解釈しどのように実践すればいいのかわかりにくいものです。

この記事ではそのような問題に悩んでいる人たちのために、数年に渡りDDD(的なもの)を実践してきた筆者が噛み砕いた(個人の独断的な)解釈と実践方法を解説します。

DDDってなぁに?

DDDがカバーする領域

DDDが言及する範囲はとても幅広いです。エリック・エヴァンスさんが言っていることを分類すると以下の領域に分けられます。

  • 思想・哲学としてのDDD ...複雑になりがちなシステム開発にどう取り組むか。開発の考え方や進め方
  • 設計戦略としてのDDD (戦略的DDD) ...ドメインモデリングに繋げるためのアプローチの方針 (ドメインエキスパートとの協調、ユビキタス言語、境界づけられたコンテキスト等)
  • 実装パターンとしてのDDD (戦術的DDD) ...ドメインモデルを実装レベルで体現するためのパターン(エンティティ、リポジトリ、レイヤードアーキテクチャ等)

幅広いですね。。。

DDDの定義

エリックさんによるとDDDは以下の4つの原則から成り立ちます。

  • Focus on the core complexity and opportunity in the domain
  • Explore models in a collaboration of domain experts and software experts
  • Write software that expresses those models explicitly
  • Use a ubiquitous language inside a bounded context

出典: https://www.zweitag.de/en/blog/ddd-europe-2016

日本語にするとこんな感じになると思います。

  • ドメイン(*1)の中核となる複雑さと機会にフォーカスする
  • ドメインエキスパート(*2)とソフトウェアエキスパートのコラボレーションからモデルを探求する
  • そのモデルをそのまま表現するソフトウェアを書く
  • 境界づけられたコンテキスト(*3)におけるユビキタス言語(*4)を用いる

(*1)...システムの対象とする業務領域のこと
(*2)...ドメインに関する深い知識を持っている人
(*3)...特定のモデルが定義され適用される境界(サブシステムとか)
(*4)...チームで使う共通言語

実践的な意味で噛み砕くと

こんなイメージだと思います。

  • システムが適用されるユーザーの業務や文脈(ドメイン)を深く知ることからはじめよう。
  • ドメインに関する知識を持っている人たちと対話・協力しながら、システムの業務ルールをモデル・概念として練り上げよう。
  • WEBとかDBのような"手段"や"技術"は一旦脇に置いておき、システムの業務仕様(ドメイン)を純粋なモデルとして作り上げていこう。
  • オブジェクト指向言語の表現力を駆使して、そのモデルそのものをそのまま表すコードを書こう。
  • 言葉は大事。エンジニアも非エンジニアも同じ概念を表すのに共通の用語を使って話そう。

なお、たまにある勘違いとして、「DDDとは既存の業務ルールをそのままモデル、ソフトウェアに落とし込むこと」と捉えられてしまうことがあります。これでは単なる現状をデジタル化しただけのもので終わってしまいます。

しかし、モノづくりとはもっとクリエイティブなもののはずです。プロダクトのベストな形を考えて示していくのは最終的には作り手の使命であり、そのようにして作られたプロダクトは今までになかった新たなドメインを世界にもたらします。

なのでDDDとは、どちらかとういうと、「ドメイン(=対象とする業務の世界やコンテキスト)を把握した上で、その知識を材料にプロダクトの扱う業務仕様をドメインモデルとして練り上げ、そのモデルを動くソフトウェアに落とし込むこと」ということだと思います。

重要なポイント

モデルとモデリング

ちょっと抽象的な話になってしまいますが、すべてのプロダクトは固有の概念(モデル)を持っています。(Twitterで言うと”ツイート”や”タイムライン”、”ユーザー”など。)
プロダクトとは概念の集合体そのもののことであり、プロダクトを閲覧/操作することは概念そのものを閲覧/操作することと言えます。

OOUI(オブジェクト指向ユーザーインターフェース)はこのような考え方に基づいたUI設計の思想であり、OOUIを調べてみるとモデリングがどのようなものなのかイメージしやすくなるかもしれません。

DDDでは、仕様を作ることはモデルを作ることであり、そのモデルはコードで表現されるようになります。そうすると、"仕様=モデル=コード"となり、コードが仕様そのものを表すようになるはずです。

モデリングの流れ

教科書的には以下のような流れになると思います。

① システム化対象の業務・活動・影響(ドメイン知識)を調査・把握
② ①をもとに情報を取捨選択しながら、システムの業務仕様をモデル化
③ モデルをコードで表現する
④ 継続的に見直していく

① システム化対象の業務・活動・影響(ドメイン知識)を調査・把握

  • ドメインエキスパート(対象領域の知識を持っている人/実際に業務を遂行している人たち)と対話しながら、必要なドメイン知識を得る

② ①をもとに情報を取捨選択しながら、システムの業務仕様をモデル化

  • ①の内容から洞察を得て取捨選択しながら、システムが扱うドメインの仕様を概念化する → ドメインモデル
  • ドメインモデルは図や言葉などで表現して伝えることできる
  • ドメインモデルは技術仕様ではなく、業務の仕様を表現したものである。そのため開発の専門家でなくても同じドメインモデルを見ながら話ができるはず

③ モデルをコードで表現する

  • オブジェクト指向言語の表現力を駆使し、モデルをそのままコードに落とす。
  • コードで「処理を記述する」のではなく、「モデルを表現する」という感じ。

④ 継続的に見直していく

  • モデルは一度定義して終わりではない。より深い洞察を得て改善する。

(余談: ちなみに私は③④がいい感じにできればここらへんのプロセスは何でもいいと思っています)

ユビキタス言語

  • 関係者間で共有された言葉であるユビキタス言語で話す
  • ビジネスサイドの人と話すとき、エンジニアと話すとき、プログラミングをするとき、ドキュメントを書くとき、一貫し同じユビキタス言語を使用する
  • ユビキタス言語はもともとドメインエキスパートが使っていた言葉であったり、モデリングの過程で出てきた言葉であったりする。(「名前をつける」とはある種の概念化・モデル化の過程の一部である。)

レイヤーを区切る

DDDでシステムを構築する場合、ドメインの情報をクリーンに保つために、ドメイン層(ドメインモデルを記述する場所)をそれ以外の関心事と切り離して独立させることが有効です。

DDDでよく採用されているのはレイヤードアーキテクチャだと思います。その他にもヘキサゴナルアーキテクチャ、オニオンアーキテクチャなど、いろいろな切り分け方がありますが、最も重要なのはドメイン層が他の何にも依存しないようになっていることです。

レイヤードアーキテクチャの場合

※矢印は依存の方向

プレゼンテーション層 (UIとか)

アプリケーション層 (全体をコントロールする)

ドメイン層 (モデル化された業務仕様)

インフラ層 (DBとか)

  • ドメイン層が最も重要。技術的な問題ではなく、ドメインの"価値のある、複雑な部分"にフォーカスする
  • ドメイン層は他のどの層にも依存しない。する必要がない。
  • ドメインの世界ではDBやWEBなどの具体的な手段の話は一切出てこないし、それに依存することもない
  • ドメイン層に業務仕様を表すピュアなモデルを作り上げていくことにこだわる
  • ドメイン層以外に業務ロジックが漏れ出している場合は、ドメイン層に集めていく

ドメイン層を作る作業は、システムの業務仕様そのものを抽出し、ライブラリ化、API化していくイメージに近いと思います。
上図の依存の方向を見てもわかるように、ドメイン層は他の層によって使われるために存在しています。なのでドメイン層の実装をするときは、使う側にとって理解しやすく、間違って使われる余地がない概念・インターフェイスになるように心がけるのがコツだと思います。

境界づけられたコンテキスト

TODO: あとで書く

それで、どうやってモデリング/実装するの?

まずモデリングのルール

無秩序にモデリングするよりも一定の秩序があったほうが理解しやすいし効率がいい。
DDDは基本的には↓のようなルールでモデリングします。

エンティティ

  • ドメインモデルの主役
  • アイデンティティを持つオブジェクト
  • 時間とともに変化しても「アイデンティティ(ID)」が同じものは同一のエンティティ
    • 例えば"社員"を扱うシステムにおいて、山田太郎という社員がいたとして、異動によって所属部署や役職が変わったとしても同じ山田太郎という社員である
    • "社員"はアイデンティティによって同一性が識別されるエンティティである、と言える
  • 逆に、属性がまったく同じでも、アイデンティティが違えば別物となる
    • 同姓同名で生年月日など全ての属性が同じ社員が2人いても、2人は同一人物ではなく別人である
  • データベースやORMのエンティティとは意味が違う。単なるデータの入れ物ではない。

余談:
ソフトウェアはその時々の「状態」を持っていて、その状態の変化によって基本的なビジネスロジックが実現されます。(例えばTwitterで誰かがつぶやけば"ツイート"というオブジェクトが新規作成された状態になる)
DDD的な設計において、ソフトウェアの状態は各種エンティティの状態(の集合)であると言えます。エンティティの状態が変化(作成/変更/削除)することによって基本的なビジネスロジックが実現するので、基本的にはエンティティはミュータブルなクラスとして実装されます。(イミュータブルにする設計もありえます。)

バリューオブジェクト

  • 値や属性を表すもの
  • たとえば「色」や「量」のように、その属性だけが重要で、アイデンティティを考えることに意味のないオブジェクト
  • 不変。イミュータブル
  • 1つの値(バリューオブジェクト)が複合的な値から成り立つような場合もある
    • 例えば「住所」という値は、「郵便番号」「都道府県」「市区町村」「番地」「建物名・部屋番号」といった値から成り立つ、というようにモデリングすることができる

リポジトリ

  • エンティティは、作成→変更(*N回)→削除のようなライフサイクルを辿るが、そのエンティティの今の状態を保存しておくものがリポジトリ
  • 保存したエンティティを必要なときに復元して返す
  • ドメイン層にとって重要なのはリポジトリのインターフェイスだけであり、それがどのように実装されるかはどうでもいい。一般的にリポジトリの実装はインフラ層に置かれる。
    • リポジトリの実装はRDBでもNoSQLでもKVSでもHashMapでもなんでもいい。(エンティティを保存、復元できればなんでもいい)
  • リポジトリはエンティティを渡したらそのまま保管しておいてくれて、必要な時に保存時と全く同じ状態で取り出せる、という単純な仕様であるので、複雑なロジックが入り得ないし、使う側が中のロジックを知る必要がない
  • リポジトリを通して保存/復元されるエンティティを集約ルートと呼ぶ

ドメインサービス

  • モノではなく純粋な処理
  • 1つの機能や処理が単体で存在していて、もの(オブジェクト)として扱うのが不自然なものをサービスとして定義
  • 純粋な処理であり、状態を持たない

サンプル: 実際にどうやって実装するか

"通話"を管理するシステムを考えてみます。言語はJavaで実装してみます。(あとでKotlinに書き換えたい...)

システムとして次の業務を実現したいとします。

  • 電話をかける(相手を指定して呼び出す)
  • キャンセルする(相手が応答する前に通話をやめる)
  • 通話開始(相手が応答し、通話が始まる)
  • 通話終了(通話を終える)

まず「通話」(電話がかけられてから終了するまで)を表したモデルを作ってみましょう。
ドメインエキスパートとの対話から引き出した情報等から、「通話」に必要な構成要素は↓みたいな感じだとわかったとします。(※筆者は通話業務に詳しいわけではないので細かいところは適当)

  • かけた人
  • かけられた人
  • ステータス(電話かけてつながるまで or キャンセル or 通話中 or 終了)
  • かけた日時
  • 通話が開始日時(繋がった日時)
  • 通話が終了した日時
  • 通話料金

普通のデータ中心アプローチ的なやり方

まず普通のデータ中心アプローチに近い実装を考えてみます

通話を表したモデル

/**
 * 通話
 */
@Data
class TelephoneCall {

  /** ID */
  private long id;

  /** かけた人(のユーザID) */
  private long callerUserId;

  /** かけられた人(のユーザID) */
  private long receiverUserId;

  /** ステータス */
  private int status;

  /** かけた日時 */
  private Calendar createdAt;

  /** 繋がった日時 */
  private Calendar talkStartedAt;

  /** 通話が終了した日時 */
  private Calendar finishedAt;

  /** 通話料金 */
  private int callCharge;
}

/**
 * 通話ステータス
 */
public class PhoneCallStatus {
  public static final int WAITING_RECEIVER = 1;
  public static final int CANCELED = 2;
  public static final int TALK_STARTED = 3;
  public static final int FINISHED = 4;
}

アプリケーションのロジック

public class PhoneCallService {

  /**
   * 電話をかける
   *
   * @param callerUserId   かける人
   * @param receiverUserId かけられる人
   * @return 通話ID
   */
  public long makePhoneCall(long callerUserId, long receiverUserId) {
    PhoneCall phoneCall = new PhoneCall();
    phoneCall.setCallerUserId(callerUserId);
    phoneCall.setReceiverUserId(receiverUserId);
    phoneCall.setCreatedAt(Calendar.getInstance());
    phoneCall.setStatus(PhoneCallStatus.WAITING_RECEIVER);
    phoneCallRepository.save(phoneCall);
    return phoneCall.getId();
  }

  /**
   * キャンセルする
   *
   * @param phoneCallId 通話ID
   */
  public void cancelPhoneCall(long phoneCallId) {
    PhoneCall phoneCall = phoneCallRepository.findById(phoneCallId);
    phoneCall.setStatus(PhoneCallStatus.CANCELED);
    phoneCall.setFinishedAt(Calendar.getInstance());
    phoneCallRepository.save(phoneCall);
  }

  /**
   * 通話開始
   *
   * @param phoneCallId 通話ID
   */
  public void startTalking(long phoneCallId) {
    PhoneCall phoneCall = phoneCallRepository.findById(phoneCallId);
    phoneCall.setStatus(PhoneCallStatus.TALK_STARTED);
    phoneCall.setTalkStartedAt(Calendar.getInstance());
    phoneCallRepository.save(phoneCall);
  }

  /**
   * 通話終了
   *
   * @param phoneCallId 通話ID
   */
  public void finishPhoneCall(long phoneCallId) {
    PhoneCall phoneCall = phoneCallRepository.findById(phoneCallId);
    phoneCall.setStatus(PhoneCallStatus.FINISHED);
    phoneCall.setFinishedAt(Calendar.getInstance());
    phoneCall.setCallCharge(calculateCharge(phoneCall));
    phoneCallRepository.save(phoneCall);
  }
}

要件を満たしていて、一見問題ないコードができました。
が、DDD的にはまだあまりよろしくない。

問題1: 「通話」というモデルを正確に表せていない(ドメインモデル貧血症)

通話クラスはただのデータの入れ物になっている。このクラスには属性以外の情報がなく、モデルをちゃんと表せていない。
→「通話」というモデルをもっと正確に表せるようにしましょう

問題2: なんでもできてしまう

例えば、ステータスを「通話終了」にしたけど「通話終了日時」「通話料金」を入れ忘れてしまうことができてしまいます。
このモデルを使う側に知識がないと簡単にバグを作り込んでしまう可能性があります。
→理解しやすく、使い方に間違えようのないモデルを作りましょう。ドメインモデルは使う側のためにあります

問題3: ミュータブルな属性がある

イミュータブルでない値を属性に持つと、その値がどこで変更されてしまうかわからないので、気にしなければいけないことが増えます

PhoneCall phoneCall = new PhoneCall();
Calendar now = Calendar.getInstance();
phoneCall.setFinishedAt(now);
// としたあとに
now.set(Calendar.HOUR, 1);
// とすると、`phoneCall`の中の値も変わってしまう

改善後: もっとDDD的なやり方

通話エンティティ

/**
 * 通話
 */
@Data
@AllArgsConstructor
@Setter(AccessLevel.PRIVATE) // セッターは公開しない
public class PhoneCall {

  /** ID */
  private PhoneCallId id; // ただのlongではなく、属性の意味を正確に表す値オブジェクトを作った

  /** かけた人 */
  @NonNull
  private final UserId caller;  // 通話作成時に必ず指定されて、変更されないので、null不可にしてfinalにする

  /** かけられた人 */
  @NonNull
  private final UserId receiver;

  /** ステータス */
  @NonNull
  private PhoneCallStatus status;

  /** かけた日時 */
  @NonNull
  private final LocalDateTime createdAt;

  /** 繋がった日時 */
  private LocalDateTime talkStartedAt;

  /** 通話が終了した日時 */
  private LocalDateTime finishedAt;

  /** 通話料金 */
  private Price callCharge;

  // 新規作成用のファクトリを用意。
  // 特に複雑なエンティティを生成する場合はファクトリを用意することが推奨される。
  // 今回の場合はエンティティ内にstaticなファクトリメソッドを用意したが、ファクトリ自体を別のクラスに切り出す方が有効なこともある)
  /**
   * 新規作成
   *
   * @param caller   かけた人
   * @param receiver かけられた人
   * @return 新規通話
   */
  public static PhoneCall create(UserId caller,
                                 UserId receiver) { // 新規作成時にはcallerとreceiverを必ず指定し、完全な状態で作成されるように強制する
    return new PhoneCall(
        null,
        caller,
        receiver,
        PhoneCallStatus.WAITING_RECEIVER,
        LocalDateTime.now(),
        null,
        null,
        null
    );
  }

  /**
   * キャンセル
   */
  public void cancel() { // setterを公開しない代わりに、モデルができることをpublicメソッドとして公開
    if (getStatus() != PhoneCallStatus.WAITING_RECEIVER) { // 状態をチェックし、間違った使い方ができないようにする
      throw new IllegalStateException();
    }
    // 必ずまとめて更新されるようにする
    setStatus(PhoneCallStatus.CANCELED);
    setFinishedAt(LocalDateTime.now());
  }

  /**
   * 通話開始
   */
  public void startTalking() {
    if (getStatus() != PhoneCallStatus.WAITING_RECEIVER) {
      throw new IllegalStateException();
    }
    setStatus(PhoneCallStatus.TALK_STARTED);
    setTalkStartedAt(LocalDateTime.now());
  }

  /**
   * 通話終了
   */
  public void finishCalling(@NonNull Price callCharge) {
    if (getStatus() != PhoneCallStatus.TALK_STARTED) {
      throw new IllegalStateException();
    }
    setStatus(PhoneCallStatus.FINISHED);
    setFinishedAt(LocalDateTime.now());
    setCallCharge(callCharge);
  }

  /**
   * @return 通話が終わっていないかどうか
   */
  public boolean isInProgress() { // 必要であれば情報を導出するメソッドを追加
    return getStatus() == PhoneCallStatus.WAITING_RECEIVER
        || getStatus() == PhoneCallStatus.TALK_STARTED;
  }

  /**
   * @return 通話時間
   */
  public Duration getDurationTime() {
    if (isInProgress()) {
      return Duration.between(createdAt, LocalDateTime.now());
    } else {
      return Duration.between(createdAt, finishedAt);
    }
  }
}

/**
 * 通話ID(バリューオブジェクト)
 */
@Value(staticConstructor = "of")
public class PhoneCallId {
  long value;
}

/**
 * 通話ステータス(バリューオブジェクト)
 */
public enum PhoneCallStatus {
  WAITING_RECEIVER, CANCELED, TALK_STARTED, FINISHED
}

/**
 * ユーザーID(バリューオブジェクト)
 */
@Value(staticConstructor = "of")
public class UserId {
  long value;
}

アプリケーションロジック

@RequiredArgsConstructor
public class PhoneCallService {

  private final PhoneCallRepository phoneCallRepository; // リポジトリの型はインターフェイスで宣言。実装はDIとかで注入してもらう

  /**
   * 電話をかける
   *
   * @param caller   かける人
   * @param receiver かけられる人
   * @return 通話ID
   */
  public PhoneCallId makePhoneCall(UserId caller, UserId receiver) {
    PhoneCall phoneCall = PhoneCall.create(caller, receiver);
    phoneCallRepository.save(phoneCall);
    return phoneCall.getId();
  }

  /**
   * キャンセルする
   *
   * @param phoneCallId 通話ID
   */
  public void cancelPhoneCall(PhoneCallId phoneCallId) {
    PhoneCall phoneCall = phoneCallRepository.findById(phoneCallId);
    phoneCall.cancel(); // データをセットするのではなく、ふるまいを呼び出すことによって処理を実現
    phoneCallRepository.save(phoneCall);
  }

  /**
   * 通話開始
   *
   * @param phoneCallId 通話ID
   */
  public void startTalking(PhoneCallId phoneCallId) {
    PhoneCall phoneCall = phoneCallRepository.findById(phoneCallId);
    phoneCall.startTalking();
    phoneCallRepository.save(phoneCall);
  }

  /**
   * 通話終了
   *
   * @param phoneCallId 通話ID
   */
  public void finishPhoneCall(PhoneCallId phoneCallId) {
    PhoneCall phoneCall = phoneCallRepository.findById(phoneCallId);
    phoneCall.finishCalling(calculateCharge(phoneCall));
    phoneCallRepository.save(phoneCall);
  }
}

イメージとしてはだいたいこんな感じです。
具体的なテクニックはまたそのうち書きます。

最後に

※記事の内容にまだまだ至らない部分もあると思います。間違って理解している箇所を見つけられた場合やさしく諭していただけるととても喜びます。筆者の理解が更新されたら、この記事も更新していきます。

脚注
  1. エリック本で挫折された方や読み切れるか不安な方は、『Domain Driven Design Quickly 日本語版』をまず読んでみるといいかもしれません。 ↩︎


0 コメント:

コメントを投稿