はじめに
お疲れ様です。
ふぁるです。
趣味でフロントエンドを勉強しています。
本業ではオンラインMTGのコメント賑やかし部隊として、日々冷やかしに励んでおります。(雇用業種はサクラです。給与はどんぐりです)
フロントばかり触っていては幅が狭まっちゃうかと思い、個人開発でDDDっぽいやつやってみました。
リポジトリはPublicにしていますし、採用した各実装パターンについても掘り下げてみますので、ぜひ読んでってください。
うぇ~い(この先出てくる単語すべてが仰々しいので中和のための一文)
技術構成
実装パターン・アーキテクチャ
- ドメイン駆動開発(DDD)
- 言及しません。
- オニオンアーキテクチャ
- 層の責務が明確であるのと、名著「ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本」で学んだ内容と比較し、各層の名称付けや責務が直感的に被る部分が多く感じました。学習がてら実装を進めるには良いのではないかと採用しました
- 玉ねぎ好きなんですよ
- IoCパターン
- 依存性逆転の法則
- ハリーポッター読むみたいなテンションで公式ドキュメント読んで、「インセンディオ!🔥」ってユニバで買った杖振り回すみたいなノリで実装進めました
- ユニットテストも現在(2023/09/27)実装出来ていないため、現時点では複雑度を増すばかりで旨味に与れていません。旨味に与りたいので、ユニットテストは書きます。コッカラッス
- DTOパターン
- POST,PUTリクエストのbodyのバリデーション
- レイヤー間のデータ転送を効率的に行うため採用しました
ライブラリ
- TypeORM
- なんかどれ参考にしてもこの人使ってたので、そんなにいいものなのかなぁと採用しました
- prismaよりはつらくなかったですがちゃんとつらかったです。
- Swagger
@nestjs/swagger
- ドキュメントとして見れるのオシャレだし......
- JWT
-
@nestjs/passport
、passport-jwt
あたり - 大分変わった構成だと思いますが、
⦅twitter認証 → jwtトークンを発行し、フロントエンドとの認証認可⦆
みたいにしているため、JWTの発行・管理を行っています。
-
- eslint, prettier
- とにかく動かしまくって理解したく、開発スピードを超担保したかったので、Huskyは最初入れませんでした
- 普通に最初からHusky入れとけば良かったなー。
外部サービス
- Heroku
- ホスティングに用いています。→
./Procfile
- ホスティングに用いています。→
- Twitter(x ...?)
- ログインは現状、Twitterアカウントを用いたもののみです。
オニオンアーキテクチャとは
直感的にこちらが、すごくわかりやすかったです。
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の作成とマッピングが含まれます。
アプリケーション層
アプリケーション層は、以下の三つに関心を置きます。
- データの成形
- セキュリティとバリデーション
- クライアントとのインターフェース
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のフォロー等よろしくお願いします~