🐱

[DDD] 戦術的設計パターン Part 3 プレゼンテーション / インフラストラクチャー レイヤー

2024/03/22に公開

はじめに

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

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

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

(元資料)

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

言語 ・ フレームワーク

TypeScript ・ NestJS

採用アーキテクチャー

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

リポジトリ

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

プレゼンテーション / インフラストラクチャー レイヤー

これらのレイヤーの性質上、特定のフレームワークに依存した実装が主に取り上げられますが、 DDD において特定のフレームワークの機能や使い方などは、重要ではありません(所謂非機能要件)。
ざっくりかいつまんで確認していく程度にします。

プレゼンテーションレイヤー (ユーザーインターフェースレイヤー)

HTTPのRESTライクなインターフェースを公開することになりました。

例外フィルター

エンドユーザーへ例外を見せる責務はプレゼンテーション層にあります。
NestJS には例外を受け取り、ユーザーに最適な応答をするための Exception filters があります。
アプリケーションサービスから受け取った例外を個別に扱うケースもあるかもしれませんが、こちらを使用していくつか共通の振る舞いを定義していきます。

presentation/http/filters/domain-exception.filter.ts
@Catch(ValidationDomainException)
export class ValidationDomainExceptionFilter implements ExceptionFilter {
  catch(exception: ValidationDomainException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const statusCode = HttpStatus.BAD_REQUEST;

    response
      .status(statusCode)
      .json({ statusCode, message: exception.message });
  }
}

@Catch(UnexpectedDomainException)
export class UnexpectedDomainExceptionFilter implements ExceptionFilter {
  catch(exception: UnexpectedDomainException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;

    Logger.error(exception.message, exception.stack, exception.cause);

    response
      .status(statusCode)
      .json({ statusCode, message: 'An unexpected error ocurred.' });
  }
}

ドメイン層の例外を扱う例外フィルターを用意しました。
これらの例外フィルターではまず、例外をHttpStatusにマッピングしています。
ValidationDomainException をそのまま受け取った場合は、HTTPの世界での BAD_REQUEST(400) にマッピングしていいと判断した、ということになります。
また、エラーメッセージもそのままの形で公開して良いと判断され、レスポンスに詰め込んでいます。

特定のエンドポイントや例外に応じて異なるステータスコードを割り当てたい場合や、エラーメッセージをそのままの形で見せたく無い場合などもあるかと思います
(エンドユーザーに対して再入力を促すようなメッセージを追加したい 、など)。
基本的にドメイン層例外はドメイン層の表現上必要なエラーメッセージを用意しているだけで、特定のエンドユーザーのことなどは意識しておりません。
そういった場合は、それぞれのエンドポイントの実装でインラインで例外キャッチしたり、個別のフィルターを用意してレスポンスを作ることになります。

クッキーを利用したセッションの保持

HTTPクライアントとのセッションのやり取りにクッキーを使用することになりました。

presentation/http/cookie/user-session-cookie.ts
export class UserSessionCookie {
  private static readonly COOKIE_NAME = 'ddd-onion-lit_usid';
  private static readonly COOKIE_MAX_AGE = 1000 * 60 * 60 * 24 * 30;

  constructor(private readonly configService: ConfigService) {}

  get(request: Request): SessionId | undefined {
    return request.cookies[UserSessionCookie.COOKIE_NAME];
  }

  set(response: Response, sessionId: SessionId) {
    response.cookie(UserSessionCookie.COOKIE_NAME, sessionId, {
      httpOnly: true,
      secure: this.configService.get('NODE_ENV') === 'production',
      maxAge: UserSessionCookie.COOKIE_MAX_AGE,
    });
  }
}

クッキーに対する getset ができれば大丈夫でしょう。

認可ガード

presentation/http/guards/auth.guard.ts
export class AuthGuard implements CanActivate {
  constructor(
    private readonly userSessionCookie: UserSessionCookie,
    private readonly availableUserSessionProvider: AvailableUserSessionProvider,
  ) {}

  async canActivate(context: ExecutionContext) {
    const httpContext = context.switchToHttp();
    const req = httpContext.getRequest<Request>();

    const sessionId = this.userSessionCookie.get(req);
    if (!sessionId) throw new UnauthorizedException('Authentication required.');

    const userSession =
      await this.availableUserSessionProvider.handle(sessionId);
    if (!userSession)
      throw new UnauthorizedException('Authentication required.');

    req.userSession = userSession;

    return true;
  }
}

NestJS ではルートを保護する Guards が提供されています。
DDD の文脈的に厳密には、個々のルートよりもアプリケーションサービスを保護する、という表現の方が正しいかもしれませんが、やはりこちらは便利です。

  1. cookieからセッションIDを取得
  2. セッションIDから利用可能なユーザーセッション情報を取得
  3. ユーザーセッション情報をリクエストのコンテキストに詰める

いずれかのフローが失敗した場合、 UnauthorizedException がthrowされます。

ログインコントローラー

必要な共有リソースが揃ったのでパス単位でコントローラーを作っていきます。

presentation/http/controllers/login/login.controller.ts
@UseFilters(...filters)
@Controller('login')
export class LoginController {
  constructor(
    private readonly loginUseCase: LoginUseCase,
    private readonly userSessionCookie: UserSessionCookie,
  ) {}

  @ApiOkResponse()
  @Post()
  @HttpCode(200)
  async login(
    @Body()
    request: LoginRequest,
    @Res()
    response: Response,
  ) {
    const { sessionId } = await this.loginUseCase.handle({
      emailAddress: request.emailAddress,
    });

    this.userSessionCookie.set(response, sessionId);

    response.send();
  }
}

ログインのユースケースが成功したらセッションIDをクッキーに詰めます。

タスクコントローラー

presentation/http/controllers/task/task.controller.ts
@UseFilters(...filters)
@UseGuards(AuthGuard)
@Controller('tasks')
export class TaskController {
  constructor(
    private readonly findTasksUseCase: FindTasksUseCase,
    private readonly findTaskUseCase: FindTaskUseCase,
    private readonly createTaskUseCase: CreateTaskUseCase,
    private readonly addCommentUseCase: AddCommentUseCase,
    private readonly assignUserUseCase: AssignUserUseCase,
  ) {}

  @ApiOkResponse({ type: [TaskListItem] })
  @Get()
  async find(): Promise<TaskListItem[]> {
    const { tasks } = await this.findTasksUseCase.handle();

    return tasks;
  }

  @ApiOkResponse({ type: TaskDetails })
  @Get(':id')
  async findOne(@Param('id') id: string): Promise<TaskDetails> {
    const { task } = await this.findTaskUseCase.handle({ id });

    return {
      ...task,
      comments: task.comments.map((comment) => ({
        ...comment,
        postedAt: new Date(
          comment.postedAt.year,
          comment.postedAt.month - 1,
          comment.postedAt.date,
          comment.postedAt.hours,
          comment.postedAt.minutes,
        ).toLocaleString(),
      })),
    };
  }

  @ApiCreatedResponse({ type: TaskCreatedId })
  @Post()
  async create(
    @Body()
    request: CreateTaskRequest,
  ): Promise<TaskCreatedId> {
    const { id } = await this.createTaskUseCase.handle({
      taskName: request.name,
    });

    return {
      id,
    };
  }

  @ApiNoContentResponse()
  @Put(':id/comment')
  @HttpCode(204)
  async addComment(
    @Param('id') id: string,
    @Body()
    request: AddCommentRequest,
    @Req()
    { userSession }: Request,
  ) {
    await this.addCommentUseCase.handle({
      taskId: id,
      userSession: userSession,
      comment: request.comment,
    });
  }

  @ApiNoContentResponse()
  @Put(':id/assign')
  @HttpCode(204)
  async assignUser(
    @Param('id') id: string,
    @Body()
    request: AssignUserRequest,
  ) {
    await this.assignUserUseCase.handle({
      taskId: id,
      userId: request.userId,
    });
  }
}

/tasks 配下の全てのエンドポイントが AuthGurad で保護されております。

やっていることは、パスやHTTPメソッドに応じてユースケースを実行し、ユーザーに適切なレスポンスを返しているだけです。

一応ユーザーインターフェース観点でのバリデーションも実装しています。
以下はタスクを新規作成する際のリクエストボディです。

presentation/http/controllers/task/messages/create.message.ts
export class CreateTaskRequest {
  @IsString()
  @MinLength(1)
  @ApiProperty()
  readonly name!: string;
}

ユーザーインターフェイスではあくまでも粗いレベルのバリデーションにとどめ、業務に関する深い知識はモデルの中だけで表現するようにしたい。
(実践ドメイン駆動設計)

こちらのレイヤーでは粗いレベルのバリデーションのみを行なっています。

ユーザー作成 コマンダー

ユーザーを作成する権限や方法についてはまだ決められていませんでしたが、一旦、

  • アプリケーションは一部の管理者のみがログインできるプライベートなサーバーで稼働する
  • そのことを利用し、管理者がサーバーにログインし Nest Commander を使用したコマンドライン経由でユーザーを作成する

という方針になりました。

presentation/nest-commander/create-user.commander.ts
@Command({
  name: 'CreateUser',
  description: 'Create user by name and email address.',
})
export class CreateUserCommander extends CommandRunner {
  constructor(private readonly createUserUseCase: CreateUserUseCase) {
    super();
  }

  async run(nameAndEmailAddress: string[]) {
    const [name, emailAddress] = nameAndEmailAddress;

    const { id } = await this.createUserUseCase.handle({
      name,
      emailAddress,
    });
    Logger.log(`User successfully created. id: ${id}`);
  }
}

入力ストリームから受け取ったパラメーターをユーザー作成ユースケースに渡しています。

(コマンド例)

yarn start:commander CreateUser Michael test@example.com

アプリケーションサービスは特定のユーザーインターフェースに依存しない作りになっているので、要求に応じた柔軟な対応が可能です。

インフラストラクチャーレイヤー

こちらのレイヤーでドメイン層やアプリケーション層が要求している具象を作っていきます。

IDファクトリー

infrastructure/uuid/task-id.value-object.uuid-v4-factory.ts
export class TaskIdUuidV4Factory implements TaskIdFactory {
  handle() {
    return new TaskId(v4());
  }
}
infrastructure/uuid/user-id.value-object.uuid-v4-factory.ts
export class UserIdUuidV4Factory implements UserIdFactory {
  handle() {
    return new UserId(v4());
  }
}
infrastructure/uuid/comment-id.value-object.uuid-v4-factory.ts
export class CommentIdUuidV4Factory implements CommentIdFactory {
  handle() {
    return new CommentId(v4());
  }
}

全て、 uuid の Version 4 を使用するという方針になりました。

ユーザーセッションインメモリーストレージ

infrastructure/in-memory/user-session.in-memory-storage.ts
export class UserSessionInMemoryStorage implements UserSessionStorage {
  private readonly value: Map<SessionId, UserSession> = new Map();

  async get(sessionId: SessionId) {
    const userSession = this.value.get(sessionId);

    return userSession;
  }

  async set(userSession: UserSession) {
    const sessionId = Math.random().toString();
    this.value.set(sessionId, userSession);

    return sessionId;
  }
}

ユーザーセッションストレージの実装クラスになります。
現時点では実際に使用するストレージを決めきれておらず、とりあえずデバックやテスト用のインメモリーストレージを定義した、というシチュエーションになります。

データモデル ・ ORマッパー

データベースは mysql 、ORマッパーは TypeOrm を使用することになりました。
ER図は以下になります。

database er.png

一応、TypeOrmモデルも示しておきます、が、これもただのTypeOrmの使い方に過ぎないので重要ではありません。

タスクリポジトリ

infrastructure/mysql/typeorm/repositories/task.typeorm-repository.ts
export class TaskTypeormRepository implements TaskRepository {
  constructor(
    @InjectRepository(TaskTypeormModel)
    private readonly taskRepository: Repository<TaskTypeormModel>,
    @InjectRepository(TaskAssignmentTypeormModel)
    private readonly taskAssignmentRepository: Repository<TaskAssignmentTypeormModel>,
    @InjectRepository(TaskCommentTypeormModel)
    private readonly taskCommentRepository: Repository<TaskCommentTypeormModel>,
  ) {}

  async insert(task: Task) {
    await this.taskRepository.save({
      id: task.id.value,
      name: task.name.value,
      taskAssignment: task.userId && {
        taskId: task.id.value,
        userId: task.userId.value,
      },
      taskComments: task.comments.value.map((comment) => ({
        id: comment.id.value,
        userId: comment.userId.value,
        content: comment.content,
        postedAt: comment.postedAt,
      })),
    });
  }

  async update(task: Task) {
    await this.taskRepository.update(task.id.value, { name: task.name.value });

    await this.taskAssignmentRepository.delete({ taskId: task.id.value });
    task.userId &&
      (await this.taskAssignmentRepository.save({
        taskId: task.id.value,
        userId: task.userId.value,
      }));

    await this.taskCommentRepository.delete({ taskId: task.id.value });
    await this.taskCommentRepository.save(
      task.comments.value.map((comment) => ({
        id: comment.id.value,
        userId: comment.userId.value,
        content: comment.content,
        postedAt: comment.postedAt,
        taskId: task.id.value,
      })),
    );
  }

  async find() {
    const tasks = await this.taskRepository.find({
      relations: {
        taskAssignment: true,
        taskComments: true,
      },
    });
    return tasks.map((task) =>
      Task.reconstitute(
        new TaskId(task.id),
        new TaskName(task.name),
        task.taskComments.map(
          (taskComment) =>
            new Comment(
              new CommentId(taskComment.id),
              new UserId(taskComment.userId),
              taskComment.content,
              taskComment.postedAt,
            ),
        ),
        task.taskAssignment?.userId && new UserId(task.taskAssignment.userId),
      ),
    );
  }

  findOneById () ...
}

タスク集約ルートの永続化と再構成をしています。

おわりに

読んでくださりありがとうございました。
次回があれば、トランザクションや整合性周りについてまとめたいと思います。

参考文献

レバテック開発部

Discussion