🙆

『ドメイン駆動設計入門』を読んだ

2022/11/12に公開約10,800字

はじめに

ソフトウェア設計や思想について学びたいと思い、『ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本』という書籍を読みました。
特に学びが多かったところや大事そうだなと思ったところを抜粋してまとめようと思います。

TypeScript で実装

https://github.com/Kazuhiro-Mimaki/ddd-ts

こちらはまだ途中ですが、TypeScript で DDD の実装にチャレンジしています。
他にも参考になるリポジトリ等あれば是非教えていただけると嬉しいです。

ドメインサービスとアプリケーションサービス

ドメインをクライアントに提供する上で必要なロジックやふるまいを定義するのがサービスです。本書では 2 種類のサービスが紹介されています。1 つ目がドメインのルールやふるまいに関わるドメインサービス、2 つ目がユースケースを実現するアプリケーションサービスです。

本来ドメインサービスに書くべき内容をアプリケーションサービスに書いてしまうと、同じようなコードの点在を許し、ひいてはバグにつながります。
以下の例は、本来ドメインのルールであるはずの『ユーザーの重複確認』をアプリケーションサービスで定義してしまったケースです。

class UserApplicationService {
  private userRepository: IUserRepository;
  // 省略
  public register(name: string): void {
    // 重複確認に関するルールが記述されている
    const userName = new UserName(name);
    const duplicatedUser = this.userRepository.find(userName);
    if (!!duplicatedUser) {
      throw new Error(`${userName}: ユーザーは既に存在しています。`);
    }
    const user = new User(userName);
    this.userRepository.save(user);
  }
}

他に、ユーザーの重複確認は、更新処理でも必要です。

class UserApplicationService {
  private userRepository: IUserRepository;
  // 省略
  public update(cmd: userUpdateCommand): void {
    const targetId = new UserId(cmd.id);
    const user = this.userRepository.find(targetId);

    if (!user) {
      throw new Error(`ID: ${targetId} のユーザーが見つかりません。`);
    }

    const name = cmd.name;
    if (!!name) {
      // 更新処理においても重複確認のルールが定義されている
      const newUserName = new UserName(name);
      const duplicatedUser = this.userRepository.find(newUserName);
      if (!!duplicatedUser) {
        throw new Error(`${user}: ユーザーは既に存在しています。`);
      }
      user.changeName(newUserName);

      const mailAddress = cmd.mailAddress;
      if (!!mailAddress) {
        const newMailAddress = new MailAddress(mailAddress);
        user.changeMailAddress(newMailAddress);
      }

      this.userRepository.save(user);
    }
  }
}

仮に『ユーザーの重複確認』がユーザー名ではなくメールアドレスに基づいたロジックに変更になった場合、上記のユーザー登録処理、およびユーザー情報更新処理はいずれも修正が必要になります。この例では 2 つのメソッドだけですが、今後同様のロジックが色々なところで定義されていけば、修正箇所を網羅しきれずバグの温床になってしまう可能性が高まります。『ユーザーの重複確認』はドメインのルールのため、アプリケーションサービスではなくドメインサービスに記述するようにします。

class UserApplicationService {
  private userRepository: IUserRepository;
  private userService: IUserService;

  constructor(_userRepository: IUserRepository, _userService: IUserService) {
    this.userRepository = _userRepository;
    this.userService = _userService;
  }

  public register(name: string, mailAddress: string): void {
    const user = new User(new UserName(name), new MailAddress(mailAddress));
    // ドメインサービス である userService を利用して重複確認
    if (this.userService.exists(user)) {
      throw new Error(`${user}: ユーザーは既に存在しています。`);
    }
    this.userRepository.save(user);
  }

  public update(cmd: userUpdateCommand): void {
    const targetId = new UserId(cmd.id);
    const user = this.userRepository.find(targetId);

    if (!user) {
      throw new Error(`ID: ${targetId} のユーザーが見つかりませんでした。`);
    }

    const name = cmd.name;
    if (!!name) {
      const newUserName = new UserName(name);
      const duplicatedUser = this.userRepository.find(newUserName);
      // ドメインサービス である userService を利用して重複確認
      if (this.userService.exists(duplicatedUser)) {
        throw new Error(`${user}: ユーザーは既に存在しています。`);
      }
      user.changeName(newUserName);

      const mailAddress = cmd.mailAddress;
      if (!!mailAddress) {
        const newMailAddress = new MailAddress(mailAddress);
        user.changeMailAddress(newMailAddress);
      }

      this.userRepository.save(user);
    }
  }
}

これで『ユーザーの重複確認』に関わるロジックをドメインサービスに閉じ込めることができました。重複確認がユーザー名ではなくメールアドレスに基づいたロジックに変更になった場合でもドメインサービスを修正するだけで済みます。

class UserService {
  private userRepository: IUserRepository;

  constructor(userRepository: IUserRepository) {
    this.userRepository = userRepository;
  }

  public exists(user: User): boolean {
    // 重複確認のロジックをユーザー名からメールアドレスに変更
    // const duplicatedUser = this.userRepository.find(user.name);
    const duplicatedUser = this.userRepository.find(user.mailAddress);
    return !!duplicatedUser;
  }
}

サービスクラスの分割による凝集度の向上

『凝集度』とはモジュールの責務がどれくらいまとまっているかを測る尺度です。
一般的に凝集度は高いほど良く、低いほど悪いとされます。

次の例はユーザーの登録処理と退会処理です。
UserApplicationService クラスのメンバ変数である userRepository と userService に注目します。userRepository が全てのメソッドで利用されているのに対し、userService はユーザー登録用のメソッド(register)のみに利用され、ユーザー退会用のメソッド(delete)では利用されていません。これは凝集度が低い状態です。

ユーザー登録用の register メソッドとユーザー退会用の delete メソッドを定義
class UserApplicationService {
  private userRepository: IUserRepository;
  private userService: IUserService;

  constructor(_userRepository: IUserRepository, _userService: IUserService) {
    this.userRepository = _userRepository;
    this.userService = _userService;
  }

  public register(cmd: UserRegisterCommand): void {
    const user = new User(new UserName(cmd.name));
    // 重複確認で userService を利用
    if (this.userService.exists(user)) {
      throw new Error(`${user}: ユーザーは既に存在しています。`);
    }
    this.userRepository.save(user);
  }

  // userService を利用していない
  public delete(cmd: UserDeleteCommand): void {
    const userId = new UserId(cmd.id);
    const user = this.userRepository.find(userId);
    if (!user) {
      throw new Error(`ユーザー ${user} が見つかりません。`);
    }
    this.userRepository.delete(user);
  }
}

そこで、凝集度を高めるために処理を分割します。
具体的には、ユーザー登録クラスとユーザー退会クラスに処理を分割し、userService を必要な処理のみ利用できるようにします。

ユーザー登録クラス
class UserRegisterService {
  private userRepository: IUserRepository;
  private userService: IUserService;

  constructor(_userRepository: IUserRepository, _userService: IUserService) {
    this.userRepository = _userRepository;
    this.userService = _userService;
  }

  public handle(cmd: UserRegisterCommand): void {
    const user = new User(new UserName(cmd.name));
    // userService を利用して重複確認
    if (this.userService.exists(user)) {
      throw new Error(`${user}: ユーザーは既に存在しています。`);
    }
    this.userRepository.save(user);
  }
}
ユーザー退会クラス
class UserDeleteService {
  // userService を利用しないためメンバ変数に登録しない
  private userRepository: IUserRepository;

  constructor(_userRepository: IUserRepository) {
    this.userRepository = _userRepository;
  }

  public handle(cmd: UserDeleteCommand): void {
    const userId = new UserId(cmd.id);
    const user = this.userRepository.find(userId);
    if (!user) {
      throw new Error(`ユーザー ${user} が見つかりません。`);
    }
    this.userRepository.delete(user);
  }
}

userService はユーザー登録用のクラスのみで利用されており、凝集度を高めることができました。

DTO の役割

アプリケーションサービスよりも上層のレイヤーがドメインオブジェクトの直接のクライアントになることを避ける方法の 1 つとして、本書では DTO(Data Transfer Object)が紹介されています。DTO にデータを移し替えることでドメインオブジェクトを非公開にできるため、クライアントからの直接の操作を防ぐことができます。

以下で DTO を利用しない場合と利用する場合を比較しています。

DTO を利用しない場合

以下の例は、ユーザー取得用のメソッド(get)を備えた UserApplicationService と、それを利用してユーザー名を変更する Client の処理(changeName)です。Client から直接ドメインオブジェクトのメソッドが呼ばれてしまっています。

class UserApplicationService {
  private userRepository: IUserRepository;
  // 省略
  // ユーザー取得用メソッド
  public get(userId: string): User {
    const targetId = new UserId(userId);
    return this.userRepository.find(targetId);
  }
}
class Client {
  private userApplicationService: UserApplicationService;
  // 省略
  // ユーザー名変更用メソッド
  public changeName(id: string, name: string) {
    const target = this.userApplicationService.get(id);
    const newName = new UserName(name);
    // Client からドメインオブジェクトのメソッドが直接呼ばれてしまっている
    target.changeName(newName);
  }
}

本来ドメインオブジェクトのメソッドは Client から直接操作されたくはないはずです。
そこで DTO を利用します。

DTO を利用する場合

DTO
class UserData {
  public readonly id: string;
  public readonly name: string;

  constructor(_user: User) {
    this.id = _user.id;
    this.name = _user.name;
  }
}
class UserApplicationService {
  private userRepository: IUserRepository;
  // 省略
  public get(userId: string): User {
    const targetId = new UserId(userId);
    const user = this.userRepository.find(targetId);
    // ドメインオブジェクトをDTOに詰め替え
    return new UserData(user);
  }
}

このように DTO に値を詰め替えて返すことで、外部から直接 User オブジェクトの操作ができないようになります。

依存関係逆転の原則

依存関係逆転の原則とは『モジュールは具体ではなく抽象に依存すべきである』という原則です。
以下は、UserApplicationService が具体である UserRepository に依存してしまっている例です。

class UserApplicationService {
  // 具体である UserRepository に依存している
  private userRepository: UserRepository;

  constructor(userRepository: UserRepository) {
    this.userRepository = userRepository;
  }
  // 省略
}

UserRepository はデータ永続化を責務としているため、RDB や NoSQLDB など特定のデータストアに依存することになります。
つまりこの状態では UserApplicationService は間接的にデータストアに依存することになってしまうのです。

そこで、具体ではなく抽象に依存させるようコードを修正します。

// UserRepository の interface
interface IUserRepository {
  find(id: UserId): User;
}

// UserRepository は抽象型である IUserRepository に依存
class UserRepository implements IUserRepository {
  public find(id: UserId): User {
    // 省略
  }
}
// UserApplicationService は抽象型である IUserRepository に依存
class UserApplicationService {
  private userRepository: IUserRepository;

  constructor(userRepository: IUserRepository) {
    this.userRepository = userRepository;
  }
  // 省略
}

UserRepository と UserApplicationService が UserRepository の interface である IUserRepository に依存するようになりました。特定のデータストアへの依存がなくなるので、データストアの変更やテスト用のリポジトリ引き渡しが可能になります。

集約

一定の意味のあるオブジェクトのまとまりを『集約』と呼びます。集約内の操作は『集約ルート』というオブジェクトに限定され、外部の集約を操作する時は必ず集約ルートを介さねばいけません。

例えば、User オブジェクト、UserId オブジェクト、UserName オブジェクトは 1 つの集約であり、集約ルートは User オブジェクトです。よって、UserName 変更等の操作は User オブジェクトを通して行わなければならない、ということになります。

以下はサークルにメンバーを追加するコードです。

class CircleApplicationService {
  // 省略
  public Join(cmd: CircileJoinCommand): void {
    // 省略
    // CircleApplicationService で members を直接操作している
    circle.members.Add(member);
    // 省略
  }
}

CircleApplicationService のメソッド内で members を直接操作してしまっています。
members の集約ルートは Circle オブジェクトなので、以下のように Circle オブジェクトのメソッドとし、CircleApplicationService は Circle オブジェクトのメソッドを呼び出すべきです。

class Circle {
  // 省略
  // Circle オブジェクトのメソッド内で members のメソッドを呼ぶ
  public Join(member: User) {
    // 省略
    members.Add(member);
    // 省略
  }
}
class CircleApplicationService {
  // 省略
  // CircleApplicationService からは Circle オブジェクトのメソッドを呼ぶ
  public Join(cmd: CircileJoinCommand): void {
    // 省略
    circle.Join(member);
    // 省略
  }
}

最後に

個人的にドメイン、ドメインサービス、アプリケーションサービスの粒度が難しく、実際にプロジェクトで DDD を実践する場合はかなり悩みそうだなと思いました。とはいえ、本書を通じて少しだけドメイン駆動設計への理解が深まったのは事実です。凝集度やパッケージ管理の考え方は DDD に限らず日々の開発にも適用できる内容だと思います。

Discussion

ログインするとコメントできます