🐱

[DDD] 戦術的設計パターン Part 2 アプリケーションレイヤー

2024/03/12に公開

はじめに

レバテック開発部テクニカルイネイブリンググループ所属の赤尾と申します。
本記事は以前投稿した、 [DDD] 戦術的設計パターン Part 1 ドメインレイヤー の続きになります。

  1. ドメインレイヤー
  2. アプリケーションレイヤー (本記事)
  3. プレゼンテーション / インフラストラクチャー レイヤー

※ なお、 DDD における各種 パターン名 の意味については説明しておりません。

(元資料)

本記事は短縮版となります。
細かい実装意図の説明などは、 元記事 をご参照ください。

言語 ・ フレームワーク

TypeScript ・ NestJS

採用アーキテクチャー

オニオンアーキテクチャー

リポジトリ

https://github.com/minericefield/ddd-onion-lit

アプリケーションレイヤー

アプリケーション層のメインの責務はドメインオブジェクトにビジネスロジックを委託しながらシナリオの流れを調整することです。

例外

前回同様ボトムアップで実装していく都合上、共有オブジェクトとなる例外クラスを先に定義します。

application/shared/application-exception.ts
export class NotFoundApplicationException extends Error {}

export class AuthenticationFailedApplicationException extends Error {}

export class UnexpectedApplicationException extends Error {}

ドメイン層例外と同様に、特定のプロトコルなどには依存しない概念となっております。
アプリケーション層で発生し得る抽象的な例外を用意しただけになります。

ユーザーセッション

application/shared/user-session.ts
export type SessionId = string;
export type UserSession = {
  readonly userId: UserId;
};

export abstract class UserSessionStorage {
  abstract get(sessionId: SessionId): Promise<UserSession | undefined>;
  abstract set(userSession: UserSession): Promise<SessionId>;
}

ユースケースを実現する上で必要になってくるセッション周りのリソースを定義していきます。
保持したいセッションの型やセッションを永続化するための振る舞いを用意しました。
なお今回は、ログイン済みであることが保証されている前提で、ユースケースを実装していきます。

アプリケーションサービス

共有オブジェクトを用意できたので、個々のアプリケーションサービスを実装していきます。
似たような実装の反復も多いので、一部の紹介のみとします。

ユーザー一覧取得 ユースケース

application/user/find-users.usecase.dto.ts
export class FindUsersUseCaseResponseDto {
  readonly users: {
    id: string;
    name: string;
    emailAddress: string;
  }[];

  constructor(users: User[]) {
    this.users = users.map(({ id, name, emailAddress }) => ({
      id: id.value,
      name: name,
      emailAddress: emailAddress.value,
    }));
  }
}
application/user/find-users.usecase.ts
export class FindUsersUseCase {
  constructor(private readonly userRepository: UserRepository) {}

  async handle(): Promise<FindUsersUseCaseResponseDto> {
    const users = await this.userRepository.find();

    return new FindUsersUseCaseResponseDto(users);
  }
}

比較的シンプルだったユーザー一覧取得から実装しました。
ユーザーの集合を取得してDTOに詰め替えて返しているだけです。
今回はユースケースからの戻り値は必ずDTOに詰め替えるという方針にします。

  • ドメイン層の変更が予期せぬ形で流出し不具合等が発生する可能性を防ぐ
  • アプリケーションサービスのクライアントがドメインオブジェクトの振る舞いを呼び出す、等の深刻なアンチパターンに陥る可能性を低下させる

などの意図があります。

ユーザー作成 ユースケース

application/user/create-user.usecase.dto.ts
export interface CreateUserUseCaseRequestDto {
  readonly name: string;
  readonly emailAddress: string;
}

export class CreateUserUseCaseResponseDto {
  readonly id: string;

  constructor(user: User) {
    this.id = user.id.value;
  }
}

ユーザー一覧取得と同じようにDTOを定義しています。
レスポンスは適当に作成したユーザーのIDを返すようにしています。

ログイン ユースケース

application/auth/login.usecase.dto.ts
export interface LoginUseCaseRequestDto {
  readonly emailAddress: string;
}

export class LoginUseCaseResponseDto {
  constructor(readonly sessionId: SessionId) {}
}

あり得ないですが、現状メールアドレスのみでのログインです。
パスワードについてはフォーマットや暗号化等の仕様を決めきれておらず、モデリングを先送りしているようなシチュエーションになります。

application/auth/login.usecase.ts
export class LoginUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly userSessionStorage: UserSessionStorage,
  ) {}

  /**
   * @throws {AuthenticationFailedApplicationException}
   */
  async handle(
    requestDto: LoginUseCaseRequestDto,
  ): Promise<LoginUseCaseResponseDto> {
    /**
     * Create userEmailAddress.
     */
    let userEmailAddress: UserEmailAddress;
    try {
      userEmailAddress = new UserEmailAddress(requestDto.emailAddress);
    } catch (error: unknown) {
      if (error instanceof InvalidUserEmailAddressFormatException) {
        throw new AuthenticationFailedApplicationException('Login failed.', {
          cause: error,
        });
      }

      throw error;
    }

    /**
     * Find user.
     */
    const user =
      await this.userRepository.findOneByEmailAddress(userEmailAddress);
    if (!user) {
      throw new AuthenticationFailedApplicationException('Login failed.');
    }

    /**
     * Create session.
     */
    const sessionId = await this.userSessionStorage.set({
      userId: user.id,
    });

    return new LoginUseCaseResponseDto(sessionId);
  }
}

大まかな流れとしては単純で、ログインが成功したらセッションを生成し、セッションIDをユースケースのクライアントに返します。

分かりづらいのは、メールアドレスのバリューオブジェクトを生成しているところです。
ログインのシナリオにおいてメールアドレスのフォーマット違反という例外は不自然なため、メールアドレスのフォーマット違反を認証失敗例外に詰め替えてthrowしています。

タスク作成 ユースケース

application/task/create-task.usecase.dto.ts
export interface CreateTaskUseCaseRequestDto {
  readonly taskName: string;
}

export class CreateTaskUseCaseResponseDto {
  readonly id: string;

  constructor(task: Task) {
    this.id = task.id.value;
  }
}
application/task/create-task.usecase.ts
export class CreateTaskUseCase {
  constructor(
    private readonly taskRepository: TaskRepository,
    private readonly taskIdFactory: TaskIdFactory,
  ) {}

  /**
   * @throws {TaskNameCharactersExceededException}
   */
  async handle(
    requestDto: CreateTaskUseCaseRequestDto,
  ): Promise<CreateTaskUseCaseResponseDto> {
    /**
     * Create task.
     */
    const task = Task.create(
      await this.taskIdFactory.handle(),
      new TaskName(requestDto.taskName),
    );

    /**
     * Store it.
     */
    await this.taskRepository.insert(task);

    return new CreateTaskUseCaseResponseDto(task);
  }
}

タスク名からタスクを新規生成(Task.create)し、永続化しています。

コメント追加 ユースケース

application/task/add-comment.usecase.dto.ts
export interface AddCommentUseCaseRequestDto {
  readonly taskId: string;
  readonly userSession: UserSession;
  readonly comment: string;
}

export class AddCommentUseCaseResponseDto {
  readonly id: string;

  constructor(comment: Comment) {
    this.id = comment.id.value;
  }
}

こちらはコメント主を特定するために userSession をinputとして受け取るようになっています。

application/task/add-comment.usecase.ts
export class AddCommentUseCase {
  constructor(
    private readonly taskRepository: TaskRepository,
    private readonly commentIdFactory: CommentIdFactory,
  ) {}

  /**
   * @throws {NotFoundApplicationException}
   * @throws {CommentNumberExceededException}
   */
  async handle(
    requestDto: AddCommentUseCaseRequestDto,
  ): Promise<AddCommentUseCaseResponseDto> {
    /**
     * Find task.
     */
    const task = await this.taskRepository.findOneById(
      new TaskId(requestDto.taskId),
    );
    if (!task) {
      throw new NotFoundApplicationException('Task not found.');
    }

    /**
     * Create comment.
     */
    const comment = new Comment(
      await this.commentIdFactory.handle(),
      requestDto.userSession.userId,
      requestDto.comment,
      new Date(),
    );

    /**
     * Add comment to task.
     */
    task.addComment(comment);

    /**
     * Store it.
     */
    await this.taskRepository.update(task);

    return new AddCommentUseCaseResponseDto(comment);
  }
}

対象タスクの存在確認をしてからコメントを追加し、永続化します。

ユーザーセッション プロバイダー

application/auth/available-user-session.provider.ts
export class AvailableUserSessionProvider {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly userSessionStorage: UserSessionStorage,
  ) {}

  async handle(sessionId: SessionId): Promise<UserSession | undefined> {
    const userSession = await this.userSessionStorage.get(sessionId);
    if (!userSession) {
      return;
    }

    if (!(await this.userRepository.findOneById(userSession.userId))) {
      return;
    }

    return userSession;
  }
}

セッションIDからユーザーセッションを取得します。
また、妥当なユーザーIDとしての存在確認を挟んでおります。
こちらは、特定のユースケースを示すクラスではありませんが、必要なアプリケーションサービスとして判断しました。

今回はアプリケーション層のシンプルさを優先して、認証の必要性を各ユースケースの実装レベルでは暗黙知としています。
ユースケースのクライアント(プレゼンテーション層)には、こちらのユーザーセッションプロバイダーを使用してユースケースや公開インターフェースを保護してもらう想定です。

おわりに

読んでくださりありがとうございました。
次回、プレゼンテーション / インフラストラクチャー レイヤーについてまとめます。
ユーザーインターフェースの公開や永続化層の実装を経てアプリケーションの全体が完成します。

参考文献

レバテック開発部

Discussion