りゅうじの学習blog

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

5/19 ドメイン駆動設計Chapter6 ユーザ情報取得処理まで

アプリケーションサービスとは何か

例としてユーザ機能では、CRUD処理がアプリケーションサービスです。

  • ユーザを登録する
  • ユーザ情報を確認する
  • ユーザ情報を更新する
  • 退会する

ドメインオブジェクトから準備する

アプリケーションサービスで取り扱うドメインオブジェクトを準備します。ユーザの概念はライフサイクルがあるモデルなので、エンティティとして実装します。

UserクラスはUserId・UserNameを値オブジェクトとして定義したものを引数に受け取ります。

class UserId {
  constructor(private readonly value: string) {}

  public getValue(): string {
    return this.value;
  }
}

class UserName {
  constructor(private readonly value: string) {}

  public getValue(): string {
    return this.value;
  }
  public getLength() {
    return this.value.length;
  }
}

class User {
  constructor(public readonly id: UserId, public name: UserName) {
    if (name.getLength() < 3) throw new Error('ユーザ名は3文字以上です');
  }

  public getId(): UserId {
    return this.id;
  }

  public getName(): UserName {
    return this.name;
  }

  public changeName(newName: UserName): void {
    if (newName.getLength() < 3) throw new Error('ユーザ名は3文字以上です');
    this.name = newName;
  }
  
  public equals(other: User): boolean {
    const { id: otherId } = other;
    return this.id.getValue() === otherId.getValue();
  }
}

ユーザの重複確認する必要があるので、ドメインサービスを用意します。

class UserService {
  constructor(private readonly userId: UserId, private readonly userRepository: IUserRepository) {}
  public exists(user: User): boolean {
    const duplicatedUser = this.userRepository.find(this.userId);
    return duplicatedUser !== null;
  }
}

ユーザの永続化や再構築を行うための、ドメインモデルを表現するドメインオブジェクトではないが、リポジトリを用意します。

interface IUserRepository {
  save(user: User): void;
  find(id: UserId): Promise<User | null>;
}

ここでは、ロジックを組み立てるだけなので、リポジトリの実装クラスは用意する必要はありません。

これからユーザ機能を作っていきます。

ユーザ登録処理を作成する

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

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

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

ユーザ情報取得処理を作成する

登録処理と異なり、結果を返却する必要があります。ドメインオブジェクトをそのまま戻り値にするか否かの選択が、とても重要です。

問題があるやり方、戻り値をドメインオブジェクトとして公開したパターン

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

  async getUser(userId: string): Promise<User | null> {
    const targetId = new UserId(userId);
    const user = await this.userRepository.find(targetId);
    return user;
  }
}
  • シンプルになるが、わずかな危険性を孕んでいる
    • アプリケーションサービスを利用するクライアントは、結果として受け取ったドメインオブジェクトの属性を取得してファイルや画面などに出力する、このこと自体には問題はない
      • 問題なのは、ドメインオブジェクトのメソッドの意図せぬ呼び出しを可能にしてしまうことです

ドメインオブジェクトのメソッドの意図せぬ呼び出し

class Client {
  constructor(private userApplicationService: UserApplicationService) {}
  public async changeName(id: string, name: string): Promise<void> {
    const target = await this.userApplicationService.getUser(id);
    const newName = new UserName(name);
    target?.changeName(newName);
  }
}
  • ユーザ名を変更を目的にしたコードである
    • このコードを実行してもデータの永続化は行なっていないため目的は達成されない
  • 問題なのは、コードの無意味さではなく、アプリケーションサービス以外のオブジェクトがドメインオブジェクトの直接のクライアントとなって自由に操作できてしまう事です
    • ドメインオブジェクトのメソッドを呼び出すのはアプリケーションサービスの役目です
      • 今回作成したClientクラスで呼び出し可能にしてはいけない
        • 複雑な依存関係を生んでしまう
        • 本来アプリケーションとして提供されるべきだったコードが各所に散りばめられてしまう可能性が生まれる

ドメインオブジェクトを非公開にすることで問題解決する

クライアントにはデータ転送用オブジェクト(DTO、Data Transfer Object)にデータを移し替えて返却する

interface UserData {
  id: string;
  name: string;
}

DTOに対するデータの移し替え処理はアプリケーションサービスの処理場に記述される

ドメインオブジェクトからDTOへのデータ移し替え処理

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

  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 = {
      id: user.getId().getValue(),
      name: user.getName().getValue()
    };

    return userData;
  }
}
  • Userのインスタンスは外部に引き渡されないため、UserApplicationServiceのクライアントはUserのメソッドは使えません
  • この書き方だとプロパティが後から追加された場合(emailプロパティなど)userDataで移し替えしているコード全てを修正しなくてはならないので一箇所にまとめたいところです

ファクトリ関数を定義

function createUserDTO(user: User): UserData {
    return {
      id: user.getId().getValue(),
      name: user.getName().getValue()
    };
}

ファクトリ関数とは

ファクトリ関数は、特定のオブジェクトや値を生成するための関数のことを指します。

ファクトリ関数は、特定のクラスのインスタンスを生成したり、特定の形状を持つオブジェクトを生成したりします。特に、オブジェクトの生成が複雑な場合や、同じようなオブジェクトを何度も生成する必要がある場合によく使われます。

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

  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;
  }
}
  • userDataに代入する際に、createUserDTOメソッドを呼び出して、userを渡しています
  • これにより、今後プロパティが追加されたとしても、createUserDTOとUserDataインターフェースの修正のみですみます

次回、ユーザ情報更新処理を作成するからアウトプットします。

参考

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