LoginSignup
0
0

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

Posted at

DDD の戦術的設計パターンを実践します。
以下のセクションに分かれております。

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

記事内のソースコードの記述等をなるべくコンパクトにするために、完全にボトムアップで実装していきます。

リポジトリ

その他、採用アーキテクチャーやテーマについては Part 1 冒頭をご参照ください。
一応アプリケーション層の本題であるユースケース図っぽいものだけこちらにも貼っておきます。

usecase.png

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

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

例外

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

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/shared/user-session になります。
なお、こちらのセッションは認証が成功してから初めて生成されるものとします。

その上で認証が必要なユースケースの実装イメージは、セッションIDを受け取るパターンと、ユーザーセッションを直接受け取るパターンの2通りあるかと思います。

example.usecases.ts
UseCaseWithSessionId(sessionId?: SessionId) {
  const authenticatedUserSession = AvailableUserSessionAdapter.get(sessionId);

  if (authenticatedUserSession) {
    // do something
  } else {
    // do something
  }
}

UseCaseWithAuthenticatedUserSession(authenticatedUserSession: UserSession) {
  // do something
}

今回は後者の、ログイン済みであることが保証されている前提で、妥当なユーザーセッションをそのまま渡してもらえるパターンでユースケースを実装していきます。
理由としては以下になります。

  • このアプリケーションのコアな関心ごとは、ユーザーのタスクに対する活動であって、対して認証周りの要求は至って単純
    • ロールとか、非ログイン状態の特異な挙動とかも特にない
    • ログインしてなかったらいかなる操作もできない、ただそれだけの仕様
  • たったのこれだけのことで、ユースケースが複雑になるのは嬉しくない

なお、そもそも Session という概念はアプリケーション層にとって不純なのではないか、という考えもあるかもしれませんが、
Session そのものは、一連の接続を表す抽象的な概念にすぎないので、アプリケーション層に現れても問題ないでしょう。

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

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

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

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に詰め替えるという方針にします
(ドメインオブジェクトをユースケースのクライアントに公開しない)。

(エンティティ 単位でDTOを切る) DTOは特定のユースケースのインターフェース単位ではなく、エンティティなどのドメインオブジェクト単位で切ることも多いかと思われます(TaskDto とか UserDto みたいな)。
こちらの方が一定ソースがDRYになり、変更箇所をまとめあげることができます。
違いをまとめると以下になります。
  • エンティティ単位のDTO
    • ドメイン層で起きた変更をどう反映するかを、アプリケーション層全体として決定する という考え方
    • ドメイン層で起きた変更の反映漏れを防ぎやすい
  • ユースケースのインターフェース単位のDTO
    • ドメイン層で起きた変更をどう反映するかを、個々のユースケースが決定する という考え方
    • 柔軟性が高い
どちらも、ドメイン層で起きた変更が予期せぬ形で外部のレイヤーに流出し不具合等が発生することを抑える、という点は同じですが、
私は後者の、個々のユースケースごとにそれぞれの要求に応じて変更を反映する、という形を好み今回のような実装を採用しました。

レイヤーの凝集度を高める

DTOに詰め替えるというルールを導入した一番の理由は、 アプリケーションサービスのクライアントがドメインオブジェクトの振る舞いを呼び出すことを避けたい からです。
ドメインオブジェクトの振る舞いを呼び出す責務はアプリケーション層にあります。
このように厳密にレイヤーごとの責務を切り分け、レイヤーの凝集度を高めることは非常に重要です。

以下は、ドメインオブジェクトをそのまま返すという運用がきっかけで、不適切な実装をしてしまう例です。

"プレゼンテーション層で呼び出せる、投稿日時をフォーマットする振る舞いがあったら便利そう" みたいな発想が生まれてしまう。 ただしこれはドメイン層の責務に違反している。

example.entity.ts
class Comment {
  constructor(
    readonly postedAt: Date,
  ) {}

  get postedAtForEndUser() {
    return this.postedAt.toLocaleString();
  }
}

プレゼンテーション層でドメインオブジェクトの振る舞いを呼び出すのはプレゼンテーション層の責務に違反している。

example.api.ts
@Get()
async getComment() {
  const comment = GetCommentUseCase.handle();

  return {
    ...comment,
    postedAt: comment.postedAtForEndUser
  };
}

本来ドメイン層はビジネスロジックの表現に注力すべきですが、このような誤った実装をしたくなってしまう方向に、いざなわれてしまう可能性があります
(逆に、開発チームがこのような深刻なアンチパターンに陥らない自己規律力を持っていると判断し、ドメインオブジェクトをそのまま返す運用にするというのも可能)。

もう一つ極端な例も確認してみます。

example.application-service-and-client.ts
// application layer
ApplicationService () {
  domainObject.doA();

  return domainObject;
}

// presentation layer
ApplicationServiceClient () {
  domainObject = ApplicationService();

  domainObject.doB();

  return Response.NoContent
}

(コレクション指向のリポジトリを採用し、振る舞いの呼び出しによる domainObject の変化はそのまま永続化されるとする)

こちらは所謂 "低凝集・高結合" と言われる状態の一種です。
doAdoB によって実現される単一のシナリオがアプリケーションサービスで完結できなくなっています。
高結合という観点だと、とりわけ ApplicationServiceClient の置き換えのし辛さに注目がいきがちですが、
それ以上にこのような実装をしてしまうと、単純にシステムとして理解しづらく、維持管理が困難になってしまいます。

凝集度というと、特定のクラスや実行ファイルなどの凝集度に焦点が行きがちです。
もちろん、クラスを特定の関心ごとに特化させ小さくまとめあげるというのは、一般的なオブジェクト指向のベストプラクティスとして目指すべきところでしょう。
ただし、 DDD や レイヤー化アーキテクチャー(オニオンやヘキサゴナルなど) においてはさらに、レイヤー(モジュール)の凝集度を高める、というのが非常に重要になってきます。

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

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を返すようにしています。

少し気になる点としては、 CreateUserUseCaseRequestDto という命名があります。
リクエストにDTOは違和感を感じるかもしれません。
これは単純に、inputとoutputを create-user.usecase.dto.ts というファイルでまとめて管理したいと思い、リクエストにもDTOというフレーズを採用させていただいてるというだけです。
他には、 CreateUserCommandCreateUserUseCaseRequestParams、 などの命名も良さそうです
(Command にしてしまうとCQRSっぽく見えてしまう?)。

application/user/create-user.usecase.ts
export class CreateUserUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly userIdFactory: UserIdFactory,
    private readonly userEmailAddressIsNotDuplicated: UserEmailAddressIsNotDuplicated,
  ) {}

  /**
   * @throws {InvalidUserEmailAddressFormatException}
   * @throws {DuplicatedUserEmailAddressException}
   */
  async handle(
    requestDto: CreateUserUseCaseRequestDto,
  ): Promise<CreateUserUseCaseResponseDto> {
    /**
     * Create userEmailAddress.
     */
    const userEmailAddress = new UserEmailAddress(requestDto.emailAddress);
    await this.userEmailAddressIsNotDuplicated.handle(userEmailAddress);

    /**
     * Create user.
     */
    const user = new User(
      await this.userIdFactory.handle(),
      requestDto.name,
      userEmailAddress,
    );

    /**
     * Store it.
     */
    await this.userRepository.insert(user);

    return new CreateUserUseCaseResponseDto(user);
  }
}

ユースケース本体は、メールアドレスの重複確認をしてから作成したユーザーを永続化しているだけです。

少し気になるのは、ドメイン層で発生した例外の扱いです。
ドメイン層で発生した例外の扱いはアプリケーションサービスに委ねられます。

  • そのままthrowする
  • アプリケーション層の例外に詰め替えてthrowする
  • 何らかの形で処理を続行する
    • (最終的な結果はほとんどの場合異常系に至りそう)

など、、
今回は、そのままビジネスルール違反の形でthrowしました。
明示的に try catch するか悩んだのですが、サンプルコードとしてのシンプルさを重視して省略しました。
代わりに JsDoc で投げれる例外を記載するようにしています
(throwされる例外はクライアントからは見えづらいところなので)。

ログイン ユースケース

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をユースケースのクライアントに返します。
セッションIDそのものは、いかなる技術的詳細にも依存しない一般的な概念で、ユースケースのクライアントは自身の実装内容に応じて好きなようにセッションIDを扱えば良いだけです。

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

一方で、この責務をユースケースのクライアントに任せることもできます
(ユースケースのクライアントがメールアドレスのフォーマット違反をcatchし、妥当な例外に変換する)。
本件をエンドユーザーへのエラーの見せ方の問題として捉え、プレゼンテーション層などの関心事とする、というのも考え方として妥当な気がします。

悩ましいところですが、
"エラーをどうエンドユーザーに見せるか以前にそもそもログインのシナリオにおいては、メールアドレスのフォーマット違反はただの認証失敗である"
と解釈し、ユースケース内で認証失敗例外に詰め替えました。

タスク作成 ユースケース

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);
  }
}

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

タスク一覧取得 ユースケース

タスク一覧には、 ID タスク名 アサインされたユーザー名 のみを表示するとします。

application/task/find-tasks.usecase.dto.ts
export interface FindTasksUseCaseResponseDto {
  readonly tasks: {
    id: string;
    name: string;
    userName?: string;
  }[];
}

DTOを定義したのでユースケースを実装していきたいところですが、リポジトリのみを使用する場合、
わざわざタスクとユーザーそれぞれを取得してから、タスクに対してアサインされているユーザー名をマッピングしなければなりません。
ループ処理による可読性の低下や、不要なデータ取得によるパフォーマンスの悪化などが懸念されます。

なので、クエリーサービスを導入し必要なデータのみを取得するようにします。

タスク一覧クエリーサービス

application/task/find-tasks.query-service.ts
export abstract class FindTasksQueryService {
  abstract handle(): Promise<FindTasksQueryServiceResponseDto>;
}

export interface FindTasksQueryServiceResponseDto {
  readonly tasks: {
    id: string;
    name: string;
    userName?: string;
  }[];
}

参照モデルは個々のユースケースによって要求されるため、こちらはアプリケーション層に定義されます
(ユースケースと同じファイルに定義するのも良さそうです)。
なお今回は、クエリーサービスに対するinputは定義していませんが、クエリーサービスはとりわけ複数集約に跨った複雑な検索要件やページネーションの実装などに特に重宝されることが多いです。

application/task/find-tasks.usecase.ts
export class FindTasksUseCase {
  constructor(private readonly findTasksQueryService: FindTasksQueryService) {}

  async handle(): Promise<FindTasksUseCaseResponseDto> {
    const { tasks } = await this.findTasksQueryService.handle();

    return { tasks };
  }
}

ユースケースはクエリーサービスを呼び出すだけです。

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

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としての存在確認を挟んでおります。
こちらは、特定のユースケースを示すクラスではありませんが、必要なアプリケーションサービスとして判断しました。

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

(プレゼンテーション層にユーザーセッションプロバイダーを実装する) 逆に思い切って認可周りの責務をプレゼンテーション層に集約するという意味で、上記実装をプレゼンテーション層にまとめあげた方が分かりやすいかもしれません。
一応、 オニオンアーキテクチャー そのものは所謂 "緩やかなレイヤー化" に分類されるので、プレゼンテーション層は、アプリケーション層を飛び越えて UserRepository を参照することが可能です。

ただ、やはりレイヤーを飛び越えて参照するリソースはなるべく抑えた方が保守性が向上します。
レイヤーの凝集度の観点でも、リポジトリのクライアントが絞り込まれていた方がシステムとして理解しやすいです (例: アプリケーションサービス ドメインサービス ファクトリー のみにする、など)。
さらに、ユーザーの妥当性の判断で複雑なビジネスロジックが絡み、ドメインオブジェクトの振る舞いを呼び出す必要などが出てくるといよいよプレゼンテーション層の責務違反が発生してしまいそうです。
などなどを加味して、ユーザーセッションプロバイダーをアプリケーションサービスとして実装しました。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0