りゅうじの学習blog

学習したことをアウトプットしていきます。

5/21 ドメイン駆動設計Chapter6 退会処理から終わりまで

退会処理を作成する

// deleteのコマンドオブジェクト
class UserDeleteCommand {
  constructor(public id: string) {}
}

class UserApplicationService {
  constructor(private readonly userRepository: IUserRepository, private readonly userService: UserService) {}

  async deleteUser(command: UserDeleteCommand) {
    const targetId = new UserId(command.id);
    const user = await this.userRepository.find(targetId);

    if(user === undefined) {
      throw new Error('該当ユーザが見つかりません');
    }
    await this.userRepository.delete(user);
  }
}
  • リポジトリにdeleteメソッドを追加したと仮定
  • コマンドオブジェクトは各ユースケースごとに定義する必要があるので、今回、UserDeleteCommandを定義しています

ユーザが見つからない場合に、該当ユーザが見つかりませんというエラーをスローしていますが、見つからない場合は退会成功とさせて正常終了するという判断もあります。

ドメインルールの流出

  • アプリケーションサービスはドメインオブジェクトのタスク調整に徹するべきで、ドメインのルールは記述されるべきではない
    • これをしてしまうと、同じようなコードを点在させることにつながる

例: 重複禁止というのはドメインのルールですが、これをあえて、アプリケーションサービスに書いてみます。

悪い例:アプリケーションサービスに重複に関するルールが記述されているユーザ登録処理

class UserApplicationService {
  constructor(private readonly userRepository: IUserRepository, private readonly userService: UserService) {}

  async register(name: string) {
    const userName = new UserName(name);
    const duplicatedUser = this.userRepository.find(userName);
    if(duplicatedUser !== undefined) {
      throw new Error('ユーザはすでに存在しています');
    }
    const user = new User(userName);
    this.userRepository.save(user);
  }
}
  • ユーザの重複が許可されないというルールは登録処理だけでなく、ユーザ情報を変更するときにも確認しなくてはならないルールです
  • ユーザ情報更新処理でも同じように重複確認する必要があります
async updateUser(command: UserUpdateCommand) {
    const targetId = new UserId(command.id);
    const user = await this.userRepository.find(targetId);

    if (user === null) {
      throw new Error('ユーザが存在しません');
    }

    const name = command.name;
    if(user !== null) {
      const newUserName = new UserName(name);
      const duplicatedUser = this.userRepository.find(newUserName);
      if(duplicatedUser !== null) {
        throw new Error('ユーザはすでに存在しています');
      }
      user.changeName(newUserName);
    }
  }
  • 更新処理でも重複確認をしていて、問題なく動作もしますが、ユーザの重複のルールが変更されたときはどうなるでしょうか。
    • 例としてシステムの利用者が増えたときに、利用したいと思ったユーザ名を他の利用者が使っているといった場合に、メールアドレスの重複は許可しないというルールに変更したとします
interface IUserRepository {
  find(email: Email): Promise<Email | null>;
}

インターフェースの修正をする

このインターフェースを利用しているユーザ登録処理も引きずられるように変更する必要が出てくる。

class UserApplicationService {
  constructor(private readonly userRepository: IUserRepository, private readonly userService: UserService) {}

  async register(name: string, rawEmail): Promise<void> {
    const email = new Email(rawEmail);
    const duplicatedUser = this.userRepository.find(email);
    if(dupulicatedUser !== null) {
      throw new Error('ユーザはすでに存在しています');
    }
    const userName = new UserName(name);
    const user = new User(userName, email);
    this.userRepository.save(user);
  }
}

これで終わりであれば単純ですが、ユーザ情報更新処理においてもユーザの重複を確認していたことを思い出してください。

こちらも同様にユーザ名の重複ルールをメールアドレスの重複ルールに修正する必要が出てきます。(大変すぎる)

  • 現状の例ではコードの量が少ないので全体を見通すことができるため、修正箇所に気づくことができます
    • 今後コード量が増えてきたときにユーザ重複を確認するコードがこの2箇所のみだと保証できるでしょうか
      • 保証するのは無理なので、修正漏れをし、バグを引き起こしてしまうのは目に見えています

ここまでが、アプリケーションサービスにドメインのルールを記述した悪い例です。どんな問題が起こるかは理解できました。解決策は、アプリケーションサービスにドメインのルールを記述しなければ良いです。

ドメインのルールはドメインオブジェクトに記述し、アプリケーションサービスはそのドメインオブジェクトを利用するようにします。

ユーザの重複確認をドメインサービスを利用したものに変更します。

async register(id: string, name: string): Promise<void> {
    const userId = new UserId(id);
    const userName = new UserName(name);
    

    const user = new User(userId, userName);
    // ドメインサービスに重複確認を依頼するように変更
    if (this.userService.exists(user)) {
      throw new Error("ユーザはすでに存在しています。");
    }
    await this.userRepository.save(user);
  }

  async getUser(userId: string): Promise<UserData> {
    const targetId = new UserId(userId);
    const user = await this.userRepository.find(targetId);

    if(!user) {
      throw new Error('ユーザが存在しません');
    }

    const userData: UserData = createUserDTO(user);

    return userData;
  }

そして重複確認のルールに変更があった場合は、ドメインサービスを修正する

class UserService {
  constructor(private readonly user: User, private readonly userRepository: IUserRepository) {}
  public exists(user: User): boolean {
    // const duplicatedUser = this.userRepository.find(this.user.name);
    const duplicatedUser = this.userRepository.find(this.user.email);
    return duplicatedUser !== null;
  }
}
  • ユーザ名で重複確認をしていたものをメールアドレスに変更

リポジトリにメールアドレスのfindメソッドを追加

interface IUserRepository {
  save(user: User): void;
  // find(name: UserName): Promise<User | null>;
  find(email: Email): Promise<User | null>;
}

UserServiceのexistsメソッドを利用している箇所を確認して、必要に応じて修正することで、漏れなく修正できます。

ルールをドメインオブジェクトに記述することは、同じルールを点在させることなく、修正漏れによるバグを防止します。

アプリケーションサービスと凝集度

  • 凝集度とはモジュールの責任範囲がどれだけ集中しているかを測る尺度です
  • 凝集度を高めると、モジュールが一つの事柄に集中することになり、堅牢性・信頼性・再利用性・可読性の観点から好ましいとされています
  • LCOM(Lack of Cohesion in Methods)という計算式で凝集度を測れます
    • 端的にいうと、全てのインスタンス変数は全てのメソッドで使われるべきというもの
      • 計算式を覚えることが主題ではないので、ここでは凝集度がどういったものかを具体的なコードで確認します

凝集度が低い例

class LowCohesion {
  constructor(private value1: number, private value2: number, private value3: number, private value4: number) {}

  public methodA(): number {
    return this.value1 + this.value2;
  }

  public methodB(): number {
    return this.value3 + this.value4;
  }
}
  • value1はmethodAで使われているが、methodBでは使われていない
    • value1とmethodBは本質的に関係がない
      • 同じことが他の属性にも言える

これらを分離することで凝集度を高めることができます。

凝集度が高い例

class HighCohesionA {
   constructor(private value1: number, private value2: number) {}
   
   public MethodA() {
    return this.value1 + this.value2;
   }
}

class HighCohesionB {
   constructor(private value3: number, private value4: number) {}
   
   public MethodB() {
    return this.value3 + this.value4;
   }
}
  • 凝集度を高くすることが常に正解ではない
    • コードを取り巻く環境によっては、あえて凝集度を下げるという選択肢が正解となることもあり得る
      • クラスの設計をする上で、凝集度は一考の価値がある尺度であることに間違いないです

凝集度の低いアプリケーションサービス

class UserApplicationService {
  constructor(private readonly userRepository: IUserRepository, private readonly userService: UserService) {}

  async register(command: UserRegisterCommand): Promise<void> {
    const userId = new UserId(command.id);
    const userName = new UserName(command.name);

    const user = new User(userId, userName);
    if (this.userService.exists(user)) {
      throw new Error("ユーザはすでに存在しています。");
    }
    await this.userRepository.save(user);
  }

  async deleteUser(command: UserDeleteCommand) {
    const targetId = new UserId(command.id);
    const user = await this.userRepository.find(targetId);

    if(!user) {
      throw new Error('該当ユーザが見つかりません');
    }
    await this.userRepository.delete(user);
  }
}
  • registerメソッドとdeleteUserメソッドがあるアプリケーションサービスです
  • アプリケーションサービスの引数に注目してください
    • userRepositoryは全てのメソッドで使用されているので凝集度の高い状態です
    • userServiceはユーザの重複確認に使用されており、登録時のregisterでは使用されていますが、deleteでは重複確認をする必要がないので使用されていません
      • UserApplicationServiceは凝集度の観点から考えて望ましくない状態です

凝集度を高めるために分割します

登録と退会をアプリケーションサービスの単位で分割して不要な引数を受け取らないようにすることで凝集度が高まっている

class UserRegisterService {
  constructor(private readonly userRepository: IUserRepository, private readonly userService: UserService) {}
  
  public async handle(command: UserRegisterCommand): Promise<void> {
    const userId = new UserId(command.id);
    const userName = new UserName(command.name);

    const user = new User(userId, userName);
    if (this.userService.exists(user)) {
      throw new Error("ユーザはすでに存在しています。");
    }
    await this.userRepository.save(user);
  }
}

class UserDeleteService {
  constructor(private readonly userRepository: IUserRepository) {}
  
  public async handle(command: UserDeleteCommand): Promise<void> {
    const targetId = new UserId(command.id);
    const user = await this.userRepository.find(targetId);

    if(!user) {
      throw new Error('該当ユーザが見つかりません');
    }
    await this.userRepository.delete(user);
  }
}
  • クラス名で意図が明確になることで、メソッド名をhandleというシンプルな表現に変更できます
  • UserDeleteServiceではuserServiceは使わないので、引数に受け取っていません
  • ユーザという概念で二つは繋がっていますが、目的や処理内容は真逆であり、責務を厳密に分担したのであれば、クラスが分かれるのは当然です

こうして、クラスを分けた上でまとまりを表現するためにパッケージ(名前空間とも呼ばれる)が使われます。

ユーザ登録処理とユーザ退会処理は次のようにパッケージでまとめます。

  • Application.Users.UserRegisterService
  • Application.Users.UserDelelteService

パッケージはそのままディレクトリ構成に反映されることが多いです。

  • ユースケースごとにクラスを必ず分けるべきであるということではないです
    • フィールドとメソッドに基づく凝集度に着目し、こういったクラス構成を採用することもあり得るということです
    • ユーザに関するユースケースだからといって、全てUserApplicationServiceクラスに同居させる必要はありません
  • 凝集度こそが絶対の指標ではなく、クラスを構成するインタンス変数とメソッドには対応関係があり、それが健全であるかどうかを示す凝集度という視点を、コードを整頓する際のヒントとして覚えておくと良いでしょう

アプリケーションサービスのインターフェース

より柔軟性を担保するためにアプリケーションサービスのインターフェースを用意することがあります

interface IUserRegisterService {
  handle(command: UserRegisterCommand): Promise<User>;
}
  • アプリケーションサービスを呼び出すクライアントは実体を呼び出すのでなくインターフェースを操作して処理を呼び出すようにします
class Client {
  constructor(private userRegisterService: IUserRegisterService) {}
  
  public async register(id: string, name: string): Promise<void> {
    const command = new UserRegisterCommand(id, name);
    this.userRegisterService.handle(command);
  }
}

クライアント側の利便性を高める効果があります

  • フロントエンドの実装が終わる前に、バックエンドの実装を行えるようにできます
    • 待つ必要がなくなり、開発効率が上がる
  • モックオブジェクトを利用してプロダクション(本番環境)用のアプリケーション用のアプリケーションサービスの実装を待たずに開発を進められる
  • アプリケーションサービスで例外が発生した時のフロントエンド側の処理を実際にテストしたいという要求にも応えられる
class ExceptionUserRegisterService implements IUserRegisterService {
  public async handle(command: UserRegisterCommand): Promise<void> {
    throw new Error();
  }
}
  • エラーを起こす整合性の取れたデータを準備するのは面倒なので、インターフェースがあると楽に行えます

サービスとは何か

  • クライアントのために何かを行うもの
  • サービスは自身の振る舞いを持たない
    • サービスは物事ではなく、活動や行動であることが多い
  • サービスはどんな領域にも存在する
    • ドメインにおける活動をドメインサービス
    • アプリケーションを成り立たせるためのサービスがアプリケーションサービス
  • アプリケーションサービスは利用者の問題解決のために作られる
    • 例ではユーザ機能が挙げられました
      • 登録・退会はアプリケーションを成り立たせるための操作である、ドメインに存在する概念ではない
        • ユーザ機能を新たに実現させるために作られたもの

サービスは状態を持たない

  • サービスは自身のふるまいを変化させる目的で状態を保持しない
  • サービスが状態を持ってしまうと、サービスが今どのような状態にあるのかを気にする必要が出てきてしまう
    • 状態を一切持っていないことを意味しない

自身のふるまいを変化させる目的で状態を持ってはいけないことをコードで例を挙げます

class UserAppicationService {
  constructor(private sendMail: boolean) {}

  public async register(): Promise<void> {
    // 省略
    if (sendMail) {
      // sendMailの真偽値によって、処理が分岐している
    }
  }
}
  • sendMailが直接的にサービスのふるまいを変更している悪い例です
  • registerメソッドを使用するときにインスタンスがどういった状態にあるかを気にする必要が出てきます
  • 状態を持たせる以外の方法を考えるべきです

参考

ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本