LoginSignup
9
3

【NestJS】API開発初心者がDDD勉強してみたいのでオニオンアーキテクチャやってみた

Last updated at Posted at 2023-09-27

はじめに

お疲れ様です。
ふぁるです。
趣味でフロントエンドを勉強しています。
本業ではオンラインMTGのコメント賑やかし部隊として、日々冷やかしに励んでおります。(雇用業種はサクラです。給与はどんぐりです)

フロントばかり触っていては幅が狭まっちゃうかと思い、個人開発でDDDっぽいやつやってみました。
リポジトリはPublicにしていますし、採用した各実装パターンについても掘り下げてみますので、ぜひ読んでってください。

うぇ~い(この先出てくる単語すべてが仰々しいので中和のための一文)

技術構成

実装パターン・アーキテクチャ

  • ドメイン駆動開発(DDD)
    • 言及しません。
  • オニオンアーキテクチャ
  • IoCパターン
    • 依存性逆転の法則
    • ハリーポッター読むみたいなテンションで公式ドキュメント読んで、「インセンディオ!🔥」ってユニバで買った杖振り回すみたいなノリで実装進めました
    • ユニットテストも現在(2023/09/27)実装出来ていないため、現時点では複雑度を増すばかりで旨味に与れていません。旨味に与りたいので、ユニットテストは書きます。コッカラッス
  • DTOパターン
    • POST,PUTリクエストのbodyのバリデーション
    • レイヤー間のデータ転送を効率的に行うため採用しました

ライブラリ

  • TypeORM
    • なんかどれ参考にしてもこの人使ってたので、そんなにいいものなのかなぁと採用しました
    • prismaよりはつらくなかったですがちゃんとつらかったです。
  • Swagger
    • @nestjs/swagger
    • ドキュメントとして見れるのオシャレだし......
  • JWT
    • @nestjs/passportpassport-jwtあたり
    • 大分変わった構成だと思いますが、
      ⦅twitter認証 → jwtトークンを発行し、フロントエンドとの認証認可⦆
      みたいにしているため、JWTの発行・管理を行っています。
  • eslint, prettier
    • とにかく動かしまくって理解したく、開発スピードを超担保したかったので、Huskyは最初入れませんでした
    • 普通に最初からHusky入れとけば良かったなー。

外部サービス

  • Heroku
    • ホスティングに用いています。→ ./Procfile
  • Twitter(x ...?)
    • ログインは現状、Twitterアカウントを用いたもののみです。

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

↓ よく見るあれ
image.png

直感的にこちらが、すごくわかりやすかったです。
DDDの一般的なアーキテクチャをまとめてみた

全ての依存が中心の層に向かい、逆に中心から外の層へは依存しません。
しっかり書くとボロが出そうなのと、書ききれる自信がないので、一旦この程度に留めます。

作ったもの

都々逸の投稿サイト作りました。

リポジトリです。

ついでにフロントのリポジトリです。
今度記事出しますが、Next.jsのAppRouter産です。

コード

全体的なディレクトリ構造
.
├── .eslintrc.js
├── .prettierrc
├── .vscode
│   └── settings.json
├── Dockerfile.dev
├── Procfile
├── README.md
├── docker-compose.dev.yaml
├── env
│   ├── .env.docker
│   └── .env.local
├── nest-cli.json
├── package.json
├── src
│   ├── app.module.ts
│   ├── application
│   │   ├── auth
│   │   │   ├── auth.controller.ts
│   │   │   ├── auth.module.ts
│   │   │   ├── dto
│   │   │   │   └── token-refresh.dto.ts
│   │   │   └── guards
│   │   │       └── optional-jwt-auth.guard.ts
│   │   ├── dodoitsu
│   │   │   ├── dodoitsu.controller.ts
│   │   │   ├── dodoitsu.module.ts
│   │   │   ├── dodoitsu.service.ts
│   │   │   └── dto
│   │   │       ├── create-dodoitsu.dto.ts
│   │   │       ├── response-dodoitsu-like.dto.ts
│   │   │       └── response-dodoitsu.dto.ts
│   │   └── user
│   │       ├── dto
│   │       │   └── response-user.dto.ts
│   │       ├── user.controller.ts
│   │       ├── user.module.ts
│   │       └── user.service.ts
│   ├── common
│   │   ├── ApiResponse.ts
│   │   ├── code
│   │   │   └── Code.ts
│   │   ├── exception-filter
│   │   │   └── ExceptionFilter.ts
│   │   └── type
│   │       └── Types.ts
│   ├── config
│   │   ├── app.config.ts
│   │   └── data-source.ts
│   ├── domain
│   │   ├── auth
│   │   │   ├── auth.repository.interface.ts
│   │   │   └── auth.service.ts
│   │   ├── dodoitsu
│   │   │   ├── dodoitsu-like.entity.ts
│   │   │   ├── dodoitsu.entity.ts
│   │   │   ├── dodoitsu.repository.interface.ts
│   │   │   └── dodoitsu.service.ts
│   │   └── user
│   │       ├── user.entity.ts
│   │       ├── user.repository.interface.ts
│   │       └── user.service.ts
│   ├── infrastructure
│   │   ├── auth
│   │   │   ├── jwt.strategy.ts
│   │   │   ├── optional-jwt.strategy.ts
│   │   │   └── twitter.strategy.ts
│   │   ├── orm
│   │   │   ├── dodoitsu
│   │   │   │   └── dodoitsu.repository.ts
│   │   │   ├── migrations
│   │   │   │   └── ...
│   │   │   ├── typeorm-util.ts
│   │   │   └── user
│   │   │       └── user.repository.ts
│   │   └── orm
│   └── main.ts
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock

各レイヤーについて

この章では、各レイヤーの実装に触れつつ、責務を書きます。

ドメイン層

扱うドメインに関連するビジネスロジックを処理します。
例に取り、dodoitsuのコードを見ていきます。

エンティティ
dodoitsu.entity.ts
import {
  Entity,
  PrimaryColumn,
  Column,
  OneToMany,
  ManyToOne,
  JoinColumn,
} from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { DodoitsuLike } from '@domain/dodoitsu/dodoitsu-like.entity';
import { User } from '@domain/user/user.entity';

@Entity()
export class Dodoitsu {
  @PrimaryColumn('uuid')
  id: string = uuidv4();

  @Column()
  content: string;

  @Column({ nullable: true })
  description: string;

  @ManyToOne(() => User, { nullable: true })
  @JoinColumn({ name: 'authorId' })
  author?: User;

  @OneToMany(() => DodoitsuLike, (like) => like.dodoitsu)
  likes?: DodoitsuLike[];

  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  createdAt;
}

エンティティは、データベースのテーブルとその構造を表現するクラスです。
@Entityをデコレータをつける事で、Typeormがこのクラスをデータベースのテーブルとして扱ってくれます。

リポジトリの抽象
dodoitsu.repository.interface.ts
import { Dodoitsu } from '@domain/dodoitsu/dodoitsu.entity';
import { CreateDodoitsuDto } from '@application/dodoitsu/dto/create-dodoitsu.dto';

import { User } from '@domain/user/user.entity';

export interface FindOptions {
  skip?: number;
  take?: number;
  order?: {
    [P in keyof Dodoitsu]?: 'ASC' | 'DESC';
  };
}

export interface IDodoitsuRepository {
  find(options: FindOptions): Promise<Dodoitsu[]>;
  findOne(id: string): Promise<Dodoitsu | null>;
  findWithLikesOrder(options: FindOptions): Promise<Dodoitsu[]>;
  count(): Promise<number>;
  findByUser(options: FindOptions, userId: string): Promise<Dodoitsu[]>;
  countByUser(userId: string): Promise<number>;
  findLikedByUser(options: FindOptions, userId: string): Promise<Dodoitsu[]>;
  countLikedByUser(userId: string): Promise<number>;
  create(dodoitsu: CreateDodoitsuDto, author?: User): Promise<Dodoitsu>;
  save(dodoitsu: Dodoitsu): Promise<Dodoitsu>;
  like(dodoitsu: Dodoitsu, user: User): void;
  unlike(dodoitsu: Dodoitsu, user: User): void;
  didUserLike(dodoitsuId: string, userId: string): Promise<boolean>;
}
export const SYMBOL = Symbol('IDodoitsuRepository');

Dodoitsuエンティティに関連するリポジトリのインターフェースを定義しています。
データの取得や保存など、データベースとのあれやこれやを抽象化するものです。

ソート条件、ページングをFindOptionsとして型定義し、汎用的にしています。
リポジトリで返却するのはエンティティです。

*SYMBOL 定数
... NestJSにおける依存性注入で、リポジトリを識別するために必要となるプロバイダトークンです。
定数として定義しておいて、呼び出せば済む形で扱いやすくしています。

ドメインサービス
dodoitsu.service.ts
import { Inject, Injectable, NotFoundException } from '@nestjs/common';

import {
  IDodoitsuRepository,
  SYMBOL,
} from '@domain/dodoitsu/dodoitsu.repository.interface';
import { CreateDodoitsuDto } from '@application/dodoitsu/dto/create-dodoitsu.dto';
import { Dodoitsu } from '@domain/dodoitsu/dodoitsu.entity';

import { User } from '@domain/user/user.entity';

@Injectable()
export class DodoitsuService {
  constructor(
    @Inject(SYMBOL)
    private readonly dodoitsuRepository: IDodoitsuRepository,
  ) {}

  async countAll(): Promise<number> {
    return this.dodoitsuRepository.count();
  }

  async findLatest(page: number, limit: number): Promise<Dodoitsu[]> {
    return this.findDodoitsuByOrder(page, limit, { createdAt: 'DESC' });
  }

  async findPopular(page: number, limit: number): Promise<Dodoitsu[]> {
    const totalCount = await this.dodoitsuRepository.count();
    const totalPages = Math.ceil(totalCount / limit);

    if (page > totalPages) {
      throw new NotFoundException(
        `Invalid page number. There are only ${totalPages} pages.`,
      );
    }
    return this.dodoitsuRepository.findWithLikesOrder({
      order: {
        likes: 'DESC',
      },
      take: limit,
      skip: (page - 1) * limit,
    });
  }

  async findOne(id: string): Promise<Dodoitsu | null> {
    const dodoitsu = await this.dodoitsuRepository.findOne(id);
    if (!dodoitsu) {
      throw new NotFoundException(`Dodoitsu with ID ${id} not found`);
    }
    return dodoitsu;
  }

  async create(dto: CreateDodoitsuDto, user?: User): Promise<Dodoitsu> {
    const dodoitsu = await this.dodoitsuRepository.create(dto, user);
    return this.dodoitsuRepository.save(dodoitsu);
  }

  async likeDodoitsu(dodoitsu: Dodoitsu, user: User): Promise<void> {
    return this.dodoitsuRepository.like(dodoitsu, user);
  }

  async unlikeDodoitsu(dodoitsu: Dodoitsu, user: User): Promise<void> {
    return this.dodoitsuRepository.unlike(dodoitsu, user);
  }

  async didUserLike(dodoitsuId: string, userId: string): Promise<boolean> {
    return this.dodoitsuRepository.didUserLike(dodoitsuId, userId);
  }

  async findByUserId(
    userId: string,
    page: number,
    limit: number,
  ): Promise<Dodoitsu[]> {
    return this.dodoitsuRepository.findByUser(
      {
        take: limit,
        skip: (page - 1) * limit,
        order: { createdAt: 'DESC' },
      },
      userId,
    );
  }

  async countByUserId(userId: string): Promise<number> {
    return this.dodoitsuRepository.countByUser(userId);
  }

  async findLikedByUserId(
    userId: string,
    page: number,
    limit: number,
  ): Promise<Dodoitsu[]> {
    return this.dodoitsuRepository.findLikedByUser(
      {
        take: limit,
        skip: (page - 1) * limit,
      },
      userId,
    );
  }

  async countLikedByUserId(userId: string): Promise<number> {
    return this.dodoitsuRepository.countLikedByUser(userId);
  }

  private async findDodoitsuByOrder(
    page: number,
    limit: number,
    order: { [P in keyof Dodoitsu]?: 'ASC' | 'DESC' },
  ): Promise<Dodoitsu[]> {
    const totalCount = await this.dodoitsuRepository.count();
    const totalPages = Math.ceil(totalCount / limit);

    if (page > totalPages) {
      throw new NotFoundException(
        `Invalid page number. There are only ${totalPages} pages.`,
      );
    }

    return this.dodoitsuRepository.find({
      order,
      take: limit,
      skip: (page - 1) * limit,
    });
  }
}

ドメインサービスでは、ビジネスロジックやルールをカプセル化します。
具体的には、DB等の永続化層へのアクセスを抽象化し、直接のデータベース操作を隠蔽します。
これにより、ビジネスロジックがクリーンな状態を保ち、アプリケーションの柔軟性とメンテナンス性が向上します。

まず、IoCパターンを導入した場合の特徴として、リポジトリの実体を呼び出しません。
以下です。

// リポジトリの実体は直接呼び出さず、抽象のみをimportする
import {
  IDodoitsuRepository,
  SYMBOL,
} from '@domain/dodoitsu/dodoitsu.repository.interface';

// 注入可能な事を示すデコレータ
@Injectable()
export class DodoitsuService {
  constructor(
    // トークンベースでIDodoitsuRepository型のリポジトリを注入可能にする
    @Inject(SYMBOL)
    private readonly dodoitsuRepository: IDodoitsuRepository,
  ) {}

ビジネスロジックのカプセル化という点では、以下のあたりがわかりやすそうです。

  // 最新の投稿を取得する
  async findLatest(page: number, limit: number): Promise<Dodoitsu[]> {
    return this.findDodoitsuByOrder(page, limit, { createdAt: 'DESC' });
  }

  // repositoryに渡しやすい形にデータを整形、エラーをthrowする便利関数
  private async findDodoitsuByOrder(
    page: number,
    limit: number,
    order: { [P in keyof Dodoitsu]?: 'ASC' | 'DESC' },
  ): Promise<Dodoitsu[]> {
    const totalCount = await this.dodoitsuRepository.count();
    const totalPages = Math.ceil(totalCount / limit);

    // throwしたエラーはcommonのExceptionFilterがキャッチしレスポンス型に成形する
    if (page > totalPages) {
      throw new NotFoundException(
        `Invalid page number. There are only ${totalPages} pages.`,
      );
    }

    // repositoryのfindメソッドを呼び出す
    return this.dodoitsuRepository.find({
      order,
      take: limit,
      skip: (page - 1) * limit,
    });
  }

また、ドメインサービスではレスポンス用にDTO(Data Transfer Object)を扱うことは一般的にありません。
ドメイン層で扱うべきはドメインであり、ビジネスロジックとエンティティの管理に集中するべきです。

アプリケーション層でデータの成形を行います。
アプリケーション層はシステムと外部のクライアント(例えば、ユーザーインターフェースや外部API)とのインターフェースを担当しているからです。
アプリケーション層は、ドメイン層から取得したデータを、クライアントが必要とする形式に変換する役割を果たします。これには、DTOの作成とマッピングが含まれます。

アプリケーション層

アプリケーション層は、以下の三つに関心を置きます。

  1. データの成形
  2. セキュリティとバリデーション
  3. クライアントとのインターフェース
DTO

リクエストボディのバリデーションと、レスポンスデータの成形を両方DTOで行っています。

create-dodoitsu.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';

export class CreateDodoitsuDto {
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  readonly content: string;

  @ApiProperty()
  @IsString()
  readonly description: string;
}
response-dodoitsu.dto.ts
import {
  IsString,
  IsNotEmpty,
  IsDate,
  IsBoolean,
  IsNumber,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

import { Dodoitsu } from '@domain/dodoitsu/dodoitsu.entity';
import { ResponseUserDto } from '@application/user/dto/response-user.dto';

export class ResponseDodoitsuDto {
  constructor(dodoitsu: Dodoitsu, isLiked: boolean) {
    this.id = dodoitsu.id;
    this.content = dodoitsu.content;
    this.description = dodoitsu.description;
    this.createdAt = dodoitsu.createdAt;

    this.likeCount = dodoitsu.likes ? dodoitsu.likes.length : 0;
    this.isLiked = isLiked;
    if (dodoitsu.author) {
      this.author = new ResponseUserDto(dodoitsu.author);
    }
  }

  @IsString()
  @IsNotEmpty()
  @ApiProperty()
  readonly id: string;

  @IsString()
  @IsNotEmpty()
  @ApiProperty()
  readonly content: string;

  @IsString()
  @ApiProperty()
  readonly description?: string;

  @IsDate()
  @IsNotEmpty()
  @ApiProperty()
  readonly createdAt: Date;

  @IsNumber()
  @IsNotEmpty()
  @ApiProperty()
  readonly likeCount: number;

  @IsBoolean()
  @IsNotEmpty()
  @ApiProperty()
  readonly isLiked: boolean;

  @ApiProperty()
  readonly author?: ResponseUserDto;
}

レスポンス用のDTOでは、constructorで受け取ったDodoitsu Entityに対し、「いいね数」をjoinされているDodoitsuLike Entityの数から集計したり、ResponseUserDtoを使用し、不要なパラメータを削除したりしています。

response-user.dto.ts
import { IsString, IsNotEmpty, IsDate } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { User } from '@domain/user/user.entity';

export class ResponseUserDto {
  constructor(user: User) {
    this.id = user.id;
    this.name = user.name;
    this.photo = user.photo;
    this.twitterId = user.twitterId;
    this.createdAt = user.createdAt;
  }

  @IsString()
  @IsNotEmpty()
  @ApiProperty()
  readonly id: string;

  @IsString()
  @IsNotEmpty()
  @ApiProperty()
  readonly name: string;

  @IsString()
  @ApiProperty()
  readonly photo: string;

  @IsString()
  @IsNotEmpty()
  @ApiProperty()
  readonly twitterId: string;

  @IsDate()
  @ApiProperty()
  readonly createdAt: Date;
}

Userテーブルには実際にはリフレッシュトークン等が保存されていますが、DTOを使用し削っています。
typeormにはデフォルトで特定のカラムを返さないようにするデコレータもありますが、今後の汎用性・明示的にリフレッシュトークンを削る実装の方が可読性を向上させると考え、このような実装パターンを取っています。

コントローラー
dodoitsu.controller.ts
import {
  Req,
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  Query,
  Post,
  Delete,
  DefaultValuePipe,
  ParseIntPipe,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiResponse } from '@common/ApiResponse';

import { DodoitsuApplicationService } from '@application/dodoitsu/dodoitsu.service';
import { CreateDodoitsuDto } from '@application/dodoitsu/dto/create-dodoitsu.dto';
import { ResponseDodoitsuDto } from '@application/dodoitsu/dto/response-dodoitsu.dto';

import { OptionalJwtAuthGuard } from '@application/auth/guards/optional-jwt-auth.guard';

@Controller('dodoitsu')
export class DodoitsuController {
  constructor(
    private readonly dodoitsuApplicationService: DodoitsuApplicationService,
  ) {}

  @UseGuards(OptionalJwtAuthGuard)
  @Get('latest')
  @HttpCode(HttpStatus.OK)
  async findLatest(
    @Req() req,
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ): Promise<ApiResponse<ResponseDodoitsuDto[]>> {
    const [dodoitsuList, allCount] = await Promise.all([
      this.dodoitsuApplicationService.findLatestDodoitsu(page, limit, req.user),
      this.dodoitsuApplicationService.countAllDodoitsu(),
    ]);
    return ApiResponse.success(dodoitsuList, allCount);
  }

  @UseGuards(OptionalJwtAuthGuard)
  @Get('popular')
  @HttpCode(HttpStatus.OK)
  async findPopular(
    @Req() req,
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ): Promise<ApiResponse<ResponseDodoitsuDto[]>> {
    const [dodoitsuList, allCount] = await Promise.all([
      this.dodoitsuApplicationService.findPopularDodoitsu(
        page,
        limit,
        req.user,
      ),
      this.dodoitsuApplicationService.countAllDodoitsu(),
    ]);
    return ApiResponse.success(dodoitsuList, allCount);
  }

  @UseGuards(OptionalJwtAuthGuard)
  @Get(':id')
  @HttpCode(HttpStatus.OK)
  async findOne(
    @Req() req,
    @Param('id') id: string,
  ): Promise<ApiResponse<ResponseDodoitsuDto | null>> {
    const dodoitsu = await this.dodoitsuApplicationService.findOneDodoitsu(
      id,
      req.user,
    );
    return ApiResponse.success(dodoitsu);
  }

  @UseGuards(OptionalJwtAuthGuard)
  @Post()
  @HttpCode(HttpStatus.OK)
  async create(
    @Req() req,
    @Body() createDodoitsuDto: CreateDodoitsuDto,
  ): Promise<ApiResponse<ResponseDodoitsuDto>> {
    const dodoitsu = await this.dodoitsuApplicationService.createDodoitsu(
      createDodoitsuDto,
      req.user,
    );
    return ApiResponse.success(dodoitsu);
  }

  @UseGuards(AuthGuard('jwt'))
  @Post(':id/like')
  async like(@Param('id') id: string, @Req() req): Promise<ApiResponse<any>> {
    await this.dodoitsuApplicationService.likeDodoitsu(id, req.user);
    return ApiResponse.success(null);
  }

  @UseGuards(AuthGuard('jwt'))
  @Delete(':id/unlike')
  @HttpCode(HttpStatus.OK)
  async unlike(@Param('id') id: string, @Req() req): Promise<ApiResponse<any>> {
    await this.dodoitsuApplicationService.unlikeDodoitsu(id, req.user.id);
    return ApiResponse.success(null);
  }

  @UseGuards(OptionalJwtAuthGuard)
  @Get('user/:userId')
  @HttpCode(HttpStatus.OK)
  async findByUserId(
    @Param('userId') userId: string,
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ): Promise<ApiResponse<ResponseDodoitsuDto[]>> {
    const [dodoitsuList, allCount] = await Promise.all([
      this.dodoitsuApplicationService.findDodoitsuByUserId(userId, page, limit),
      this.dodoitsuApplicationService.countDodoitsuByUserId(userId),
    ]);
    return ApiResponse.success(dodoitsuList, allCount);
  }

  @UseGuards(OptionalJwtAuthGuard)
  @Get('user/:userId/liked')
  @HttpCode(HttpStatus.OK)
  async findLikedByUserId(
    @Param('userId') userId: string,
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ): Promise<ApiResponse<ResponseDodoitsuDto[]>> {
    const [dodoitsuList, allCount] = await Promise.all([
      this.dodoitsuApplicationService.findLikedDodoitsuByUserId(
        userId,
        page,
        limit,
      ),
      this.dodoitsuApplicationService.countLikedDodoitsuByUserId(userId),
    ]);
    return ApiResponse.success(dodoitsuList, allCount);
  }
}

Web APIなので、皆大好きControllerです。

controllerは、リクエストを受け取り、アプリケーションサービスに渡す、レスポンスするのみの役割となっています。

アプリケーションサービス
dodoitsu.service.ts
import { Injectable } from '@nestjs/common';
import { CreateDodoitsuDto } from '@application/dodoitsu/dto/create-dodoitsu.dto';
import { ResponseDodoitsuDto } from '@application/dodoitsu/dto/response-dodoitsu.dto';
import { DodoitsuService } from '@domain/dodoitsu/dodoitsu.service';
import { User } from '@domain/user/user.entity';

@Injectable()
export class DodoitsuApplicationService {
  constructor(private readonly dodoitsuDomainService: DodoitsuService) {}

  async countAllDodoitsu(): Promise<number> {
    return this.dodoitsuDomainService.countAll();
  }

  async findLatestDodoitsu(
    page: number,
    limit: number,
    user?: User,
  ): Promise<ResponseDodoitsuDto[]> {
    const dodoitsuList = await this.dodoitsuDomainService.findLatest(
      page,
      limit,
    );

    const responseDodoitsuList = await Promise.all(
      dodoitsuList.map(async (dodoitsu) => {
        const isLiked = user
          ? await this.dodoitsuDomainService.didUserLike(dodoitsu.id, user.id)
          : false;
        return new ResponseDodoitsuDto(dodoitsu, isLiked);
      }),
    );
    return responseDodoitsuList;
  }

  async findPopularDodoitsu(
    page: number,
    limit: number,
    user?: User,
  ): Promise<ResponseDodoitsuDto[]> {
    const dodoitsuList = await this.dodoitsuDomainService.findPopular(
      page,
      limit,
    );

    const responseDodoitsuList = await Promise.all(
      dodoitsuList.map(async (dodoitsu) => {
        const isLiked = user
          ? await this.dodoitsuDomainService.didUserLike(dodoitsu.id, user.id)
          : false;
        return new ResponseDodoitsuDto(dodoitsu, isLiked);
      }),
    );
    return responseDodoitsuList;
  }

  async findOneDodoitsu(
    id: string,
    user?: User,
  ): Promise<ResponseDodoitsuDto | null> {
    const dodoitsu = await this.dodoitsuDomainService.findOne(id);
    const isLiked = user
      ? await this.dodoitsuDomainService.didUserLike(id, user.id)
      : false;
    return new ResponseDodoitsuDto(dodoitsu, isLiked);
  }

  async createDodoitsu(
    dto: CreateDodoitsuDto,
    user?: User,
  ): Promise<ResponseDodoitsuDto> {
    const dodoitsu = await this.dodoitsuDomainService.create(dto, user);
    return new ResponseDodoitsuDto(dodoitsu, false);
  }

  async likeDodoitsu(id: string, user: User): Promise<void> {
    const isLiked = await this.dodoitsuDomainService.didUserLike(id, user.id);
    if (isLiked) {
      throw new Error('Already liked');
    }
    const dodoitsu = await this.dodoitsuDomainService.findOne(id);
    return this.dodoitsuDomainService.likeDodoitsu(dodoitsu, user);
  }

  async unlikeDodoitsu(id: string, user: User): Promise<void> {
    const dodoitsu = await this.dodoitsuDomainService.findOne(id);
    return this.dodoitsuDomainService.unlikeDodoitsu(dodoitsu, user);
  }

  async findDodoitsuByUserId(
    userId: string,
    page: number,
    limit: number,
  ): Promise<ResponseDodoitsuDto[]> {
    const dodoitsuList = await this.dodoitsuDomainService.findByUserId(
      userId,
      page,
      limit,
    );

    const responseDodoitsuList = await Promise.all(
      dodoitsuList.map(async (dodoitsu) => {
        const isLiked = await this.dodoitsuDomainService.didUserLike(
          dodoitsu.id,
          userId,
        );
        return new ResponseDodoitsuDto(dodoitsu, isLiked);
      }),
    );
    return responseDodoitsuList;
  }

  async countDodoitsuByUserId(userId: string): Promise<number> {
    return this.dodoitsuDomainService.countByUserId(userId);
  }

  async findLikedDodoitsuByUserId(
    userId: string,
    page: number,
    limit: number,
  ): Promise<ResponseDodoitsuDto[]> {
    const dodoitsuList = await this.dodoitsuDomainService.findLikedByUserId(
      userId,
      page,
      limit,
    );

    const responseDodoitsuList = await Promise.all(
      dodoitsuList.map((dodoitsu) => {
        return new ResponseDodoitsuDto(dodoitsu, true);
      }),
    );
    return responseDodoitsuList;
  }

  async countLikedDodoitsuByUserId(userId: string): Promise<number> {
    return this.dodoitsuDomainService.countLikedByUserId(userId);
  }
}

ドメインではなく、アプリケーションそのもののビジネスロジックをカプセル化する層です。
特定のビジネスプロセスやユースケースを実装するためのメソッドを提供します。
また、DTOを使用してのデータの成形も行っています。
トランザクションの管理等もアプリケーションサービスで行うものとなります。

.module.ts(機能のカプセル化)
dodoitsu.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';

import { Dodoitsu } from '@domain/dodoitsu/dodoitsu.entity';
import { DodoitsuLike } from '@domain/dodoitsu/dodoitsu-like.entity';
import { SYMBOL as DODOITSU_SYMBOL } from '@domain/dodoitsu/dodoitsu.repository.interface';

import { User } from '@domain/user/user.entity';

import { DodoitsuController } from '@application/dodoitsu/dodoitsu.controller';
import { DodoitsuApplicationService } from '@application/dodoitsu/dodoitsu.service';
import { DodoitsuService } from '@domain/dodoitsu/dodoitsu.service';
import { UserModule } from '@application/user/user.module';

import { DodoitsuRepository } from '@infrastructure/orm/dodoitsu/dodoitsu.repository';
import { OptionalJwtStrategy } from '@infrastructure/auth/optional-jwt.strategy';

@Module({
  imports: [
    TypeOrmModule.forFeature([Dodoitsu, User, DodoitsuLike]),
    JwtModule,
    ConfigModule,
    UserModule,
  ],
  controllers: [DodoitsuController],
  providers: [
    DodoitsuApplicationService,
    DodoitsuService,
    {
      provide: DODOITSU_SYMBOL,
      useClass: DodoitsuRepository,
    },
    OptionalJwtStrategy,
  ],
  exports: [],
})
export class DodoitsuModule {}

Dodoitsu関連の機能をカプセル化するファイルです。
そろそろ、記述量が多くなってきて、Qiitaの編集画面が重いです。
コードは後から貼り付けるべきでした。

.module.tsはセクションに分かれています。
それぞれを掘り下げてみます。

imports
  imports: [
    TypeOrmModule.forFeature([Dodoitsu, User, DodoitsuLike]),
    JwtModule,
    ConfigModule,
    UserModule,
  ],

imports セクションでは、モジュールが依存する他のモジュールやライブラリをインポートしています。これにより、DodoitsuModule 内でこれらのモジュールやライブラリの機能を利用することができます。

  • TypeOrmModule.forFeature([Dodoitsu, User, DodoitsuLike]): TypeORM を使用して、特定のエンティティ(ここでは Dodoitsu, User, DodoitsuLike)に対するリポジトリを提供します。これにより、これらのエンティティに対するデータベース操作が可能になります。
  • JwtModule: JWT (JSON Web Tokens) 認証を利用するためのモジュールをインポートしています。これにより、JWT を使用した認証機能を実装することができます。
  • ConfigModule: アプリケーションの設定を管理するためのモジュールをインポートしています。これにより、アプリケーションの設定情報を効率的に管理することができます。
  • UserModule: User 関連の機能をカプセル化したモジュールをインポートしています。これにより、User 関連の機能を DodoitsuModule 内で利用することができます。
controllers
  controllers: [DodoitsuController],

controllers セクションでは、HTTP リクエストをハンドリングするコントローラを定義しています。ここでは DodoitsuController が指定されており、Dodoitsu 関連のリクエストを処理する責務を持っています。

providers
  providers: [
    DodoitsuApplicationService,
    DodoitsuService,
    {
      provide: DODOITSU_SYMBOL,
      useClass: DodoitsuRepository,
    },
    OptionalJwtStrategy,
  ],

providers セクションでは、モジュール内で使用するサービスやリポジトリなどのプロバイダを定義しています。

  • DodoitsuApplicationService: アプリケーションサービスを提供しており、アプリケーションのビジネスロジックをカプセル化しています。
  • DodoitsuService: ドメインサービスを提供しており、ドメインのビジネスロジックをカプセル化しています。
  • provide: DODOITSU_SYMBOL, useClass: DodoitsuRepository: カスタムプロバイダを使用して、DodoitsuRepository を DODOITSU_SYMBOL というトークンで注入できるようにしています。
  • OptionalJwtStrategy: JWT 認証のストラテジーを提供しています。
exports

このコードでは何もexportしていませんが、exportすれば、userModuleのように、importsすればほかのモジュールの機能が使えるようになります。

インフラストラクチャ層

インフラストラクチャ層では、データベースや、外部APIへのアクセスを主に扱います。
ロギング、セキュリティ、通知、エラーハンドリングなど、アプリケーション全体にわたる懸念をサポートします。

ストラテジ
jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { ExtractJwt, Strategy } from 'passport-jwt';

import { UserService } from '@domain/user/user.service';
import { User } from '@domain/user/user.entity';

export interface JwtPayload {
  userId: string;
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    private readonly userService: UserService,
    private readonly configService: ConfigService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: true,
      secretOrKey: configService.get<string>('auth.jwt.secret'),
    });
  }

  async validate(payload: JwtPayload): Promise<User> {
    const user = await this.userService.findOne({ id: payload.userId });
    if (!user) {
      throw new UnauthorizedException('User does not exist');
    }

    return user;
  }
}

こんな感じのやつです。
passportを使用して、ユーザーの認証を行うためのやつです

ormを使用したDBへのアクセス

repositoryの実体です。
超ベーシックです。

dodoitsu.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';

import {
  IDodoitsuRepository,
  FindOptions,
} from '@domain/dodoitsu/dodoitsu.repository.interface';
import { Dodoitsu } from '@domain/dodoitsu/dodoitsu.entity';
import { DodoitsuLike } from '@domain/dodoitsu/dodoitsu-like.entity';

import { User } from '@domain/user/user.entity';
import { CreateDodoitsuDto } from '@application/dodoitsu/dto/create-dodoitsu.dto';

@Injectable()
export class DodoitsuRepository implements IDodoitsuRepository {
  constructor(
    @InjectEntityManager()
    private readonly entityManager: EntityManager,
  ) {}

  async find(options: FindOptions): Promise<Dodoitsu[]> {
    const dodoitsuList = await this.entityManager.find(Dodoitsu, {
      ...options,
      relations: ['author', 'likes'],
    });
    return dodoitsuList;
  }

  async findWithLikesOrder(options: FindOptions): Promise<Dodoitsu[]> {
    const dodoitsuList = await this.entityManager
      .getRepository(Dodoitsu)
      .createQueryBuilder('dodoitsu')
      .leftJoinAndSelect('dodoitsu.likes', 'dodoitsuLike')
      .leftJoinAndSelect('dodoitsu.author', 'user')
      .groupBy('dodoitsu.id, user.id, dodoitsuLike.id')
      .addSelect('COUNT(dodoitsuLike.id)', 'likes_count')
      .orderBy('likes_count', 'DESC')
      .skip(options.skip)
      .take(options.take)
      .getMany();

    return dodoitsuList;
  }

  async findOne(id: string): Promise<Dodoitsu> {
    const dodoitsu = await this.entityManager.findOne(Dodoitsu, {
      where: { id },
      relations: ['author', 'likes'],
    });

    return dodoitsu;
  }

  async findByUser(options: FindOptions, userId: string): Promise<Dodoitsu[]> {
    const dodoitsuList = await this.entityManager.find(Dodoitsu, {
      where: { author: { id: userId } },
      ...options,
      relations: ['author', 'likes'],
    });
    return dodoitsuList;
  }

  async countByUser(userId: string): Promise<number> {
    return await this.entityManager.count(Dodoitsu, {
      where: { author: { id: userId } },
    });
  }

  async findLikedByUser(
    options: FindOptions,
    userId: string,
  ): Promise<Dodoitsu[]> {
    const likedDodoitsuList = await this.entityManager
      .getRepository(DodoitsuLike)
      .createQueryBuilder('dodoitsuLike')
      .leftJoinAndSelect('dodoitsuLike.dodoitsu', 'dodoitsu')
      .leftJoinAndSelect('dodoitsu.author', 'user')
      .leftJoinAndSelect('dodoitsu.likes', 'dodoitsu_like')
      .where('dodoitsuLike.user.id = :userId', { userId })
      .skip(options.skip)
      .take(options.take)
      .getMany();

    return likedDodoitsuList.map((like) => like.dodoitsu);
  }

  async countLikedByUser(userId: string): Promise<number> {
    return await this.entityManager.count(DodoitsuLike, {
      where: { user: { id: userId } },
    });
  }

  async count(): Promise<number> {
    return await this.entityManager.count(Dodoitsu);
  }

  async create(
    createDodoitsuDto: CreateDodoitsuDto,
    author?: User,
  ): Promise<Dodoitsu> {
    const dodoitsu = new Dodoitsu();
    dodoitsu.content = createDodoitsuDto.content;
    dodoitsu.description = createDodoitsuDto.description;
    if (author) {
      dodoitsu.author = author;
    }
    return await dodoitsu;
  }

  async save(dodoitsu: Dodoitsu): Promise<Dodoitsu> {
    const newDodoitsu = await this.entityManager.save(dodoitsu);
    return newDodoitsu;
  }

  like(dodoitsu: Dodoitsu, user: User): void {
    const newLike = new DodoitsuLike();
    newLike.dodoitsu = dodoitsu;
    newLike.user = user;
    this.entityManager.save(DodoitsuLike, newLike);
  }

  unlike(dodoitsu: Dodoitsu, user: User): void {
    this.entityManager.delete(DodoitsuLike, { dodoitsu, user });
  }

  async didUserLike(dodoitsuId: string, userId: string): Promise<boolean> {
    const like = await this.entityManager.findOne(DodoitsuLike, {
      where: {
        dodoitsu: { id: dodoitsuId },
        user: { id: userId },
      },
    });

    return !!like;
  }
}

DB接続設定、マイグレーションとか
typeorm-util.ts
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';

import { Dodoitsu } from '@domain/dodoitsu/dodoitsu.entity';
import { DodoitsuLike } from '@/domain/dodoitsu/dodoitsu-like.entity';
import { User } from '@domain/user/user.entity';

export const createTypeOrmOptions = async (
  configService: ConfigService,
): Promise<TypeOrmModuleOptions> => {
  const ssl = configService.get('isLocal')
    ? false
    : {
        rejectUnauthorized: false,
      };

  return {
    name: 'default',
    type: 'postgres',
    host: configService.get('database.host'),
    port: configService.get('database.port'),
    username: configService.get('database.username'),
    password: configService.get('database.password'),
    database: configService.get('database.database'),
    logging: configService.get('database.logging'),
    ssl,
    synchronize: configService.get('database.synchronize'),
    entities: [Dodoitsu, DodoitsuLike, User],
    migrations: ['dist/migration/*.js'],
  };
};

記事書いてて思いました。ファイル名が変な気がします。
connection-options.tsみたいな感じに脳内変換いただけると嬉しいです。

おわりに

以上、フロント畑ながらも、API開発のアーキテクチャを頑張ってみた話でした。

良ければtwitterフォロー、Qiitaのフォロー等よろしくお願いします~

9
3
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
9
3