4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発者が実践したNext.js - T3stack - DDD な感じのアーキテクチャ 【その2 APIのデータ取得フローと型】

Last updated at Posted at 2024-06-09

この記事は

個人開発者が実践したNext.js - T3stack - DDD な感じのアーキテクチャ 【その1 使用技術と全体の構成】
の続きになっています。

この記事では、バックエンドのinfrastructure層からinterfaces層までの、データの流れと、そのデータ型について、具体的なコードと共に紹介したいと思います。

この記事は、あくまでデータ取得(SELECT)のフローに焦点を当てています。
CQRSではないですが、データのINSERT, UPDATEの場合は登場するクラスが変わってくるので、別の記事で紹介します。
ーーーーーーーーー

[追記] 後から調べると、私の実装方法は明確にClassを分割していませんが、以下の記事のようなCQRSの考えに近いことが分かりました。
この記事のデータ取得(Query)処理では、"複数集約の値を組み合わせた"データ取得を許容し、取得したデータをapplication層のDTOとして扱い、そのままユースケースごとのレスポンスとして利用できます。
この記事では扱わない更新系(Command)処理では、Domain層のEntity等のクラスが登場し、データ整合性を保つ役割を担います。

ーーーーーーーーー

infrastructure層

  • images.repository.ts

infrastructure層では、xx.repository.ts、データベースとの入出力を行います。(ドメインに依っては、storageのファイル操作等も行います。)
ORMには、prismaを使用しています。

例として、imageテーブルのデータをid指定で取得するコードを以下に紹介します。

// images.repository.ts
import { injectable } from 'inversify';
import prisma, { Prisma } from 'src/lib/prisma/prismaClient';
import { ImagesRepositoryInterface } from 'src/server/domains/images/domain/images.repository.interface';

@injectable()
export class ImagesRepository implements ImagesRepositoryInterface {

  async findUnique(imageId: number, transaction?: Prisma.TransactionClient) {
    const images = transaction ? transaction.images : prisma.images;
    return await images.findUnique({
      where: {
        imageId,
      },
    });
  }
  // ...etc
}

(※transaction内で取得したい場合があるので、transactionを受け取れるようにしています。)

このメソッドでreturnされる型を確認すると、prisma内で定義された以下のような型になっています。

Promise<(GetResult<{
    delFlg: boolean;
    regDate: Date;
    updDate: Date | null;
    imageId: number;
    imagePath: string;
    // ...etc
}, unknown> & {}) | null>

↑後で関係します。

依存関係としては
infrastructure層は、domain層のimages.repository.interfaceにのみ依存している状態です。
infrastructure層からdomain層への依存は、レイヤードアーキテクチャだと依存性が逆転しているとされますが、クリーンアーキテクチャでは、正しい依存方向です。

また、仮にprismaをやめて他のORMに変更しても、この層のみの変更で済むようにモジュール化されています。
(transactionは仕方ない。)

domain層

  • image.dto.ts
  • images.repository.interface.ts
  • images.service.ts(次のapplication層で説明します)
  • image.entity.ts(今回紹介しません)

xx.repository.interface.tsは、infrastructure層のrepositoryクラスでimplementsしている型定義のファイルです。
(ちなみに、domain層に置く派とapplication層に置く派があるみたいです。)

実装としては、以下のようになっています。

// images.repository.interface.ts
import { Prisma } from 'src/lib/prisma/prismaClient';
import { ImageDto } from 'src/server/domains/images/domain/image.dto';

export interface ImagesRepositoryInterface {
  findUnique(imageId: number, transaction?: Prisma.TransactionClient): Promise<ImageDto | null>;
  // ...etc
}

このrepository.interface.ts内で扱われる、ImageDtoの実装は以下のように、クラスでなくinterfaceとして定義しています。

// image.dto.ts
export interface ImageDto {
  delFlg: boolean;
  regDate: Date;
  updDate: Date | null;
  imageId: number;
  imagePath: string;
    // ...etc
}

この辺りは、このプロジェクト独自の工夫した点になります。

注目すべきは、
repository.interface.tsでは、findUniqueImageDto | null型を返していますが、
repository.tsでは、prisma内で定義されたGetResult<...>型を返している点です。

一見違う型に見えますが、prismaでfindUniqueから取得したデータは、ImageDto | nullとなる為、エラーは発生しません。

このような実装にしている理由
としては、

  1. prismaのデータ型を扱っている層を、infrastructure層のみに抑えたかった。(transactionは仕方ない。。。)
  2. ImageDtoをinterfaceでなく、一般的なクラスにすることもできたが、クラスだと
    2-1. データの詰め替え処理が冗長。
    2-2. prismaが提供するinclude(join句)が使いづらくなる

です。

2-2. に関して具体的に記述すると、
以下のようなImageUserをjoinして取得するメソッドがあったとき、

// images.repository.ts
  async findUniqueWithUser(imageId: number, transaction?: Prisma.TransactionClient | undefined) {
    const images = transaction ? transaction.images : prisma.images;
    return await images.findUnique({
      where: {
        imageId,
      },
      include: {
        User: true,
      },
    });
  }

もしImageDtoがクラスだった場合
クラス内プロパティにimageテーブルのカラム以外に、Userプロパティを持つ必要がでてきます。
ImageDtoが、Userを含む場合・含まない場合の両方に利用されると仮定すると、ImageDtoクラスの型としては、Userはオプショナルなプロパティになるので、
findUniqueWithUserで取得したデータにも関わらず、Userプロパティにアクセスする度に、Userがundefinedでない確認を行う必要が出てきてしまいます。

もしくは、repositoryでの取得系メソッド全てに対応するDtoクラスを作ることになります。

なので
ImageDtoなど、各ドメインのDtoをinterfaceとして扱うことで、Dtoクラスへの詰め替えをなくし、自由にjoinでき、prismaでデータを柔軟に扱えるようにしています。

実際のfindUniqueWithUserの型定義は以下のようになります。

// images.repository.interface.ts
  findUniqueWithUser(
    imageId: number,
    transaction?: Prisma.TransactionClient,
  ): Promise<(ImageDto & { User: UserDto }) | null>;
  // joinしたドメインのDtoを追加するだけ。

他のプロジェクトでは
各ドメイン層でアクセスするのは、そのドメインだけにする実装方針もあると思いますが(join禁止で、image.repository.tsなら、imageのデータしか取得しない)、
個人的には、prismaのincludeの機能を殺してしまうのは勿体ないという判断で、find時には自由にincludeできるような設計にしています。

依存関係としては
domain層は、他の層に依存しません。
infrastructure層にも依存しないので、やはりレイヤードよりも、クリーンアーキテクチャに近いです。

application層

  • /dtos
     imageWithFileUrl.dto.ts
     userUploadImages.dto.ts(今回紹介しません。)
  • images.usecase.ts

usecase.tsのシンプルなメソッドだと以下のような、実装です。

// images.usecase.ts
@injectable()
export class ImagesUsecase {
  constructor(
    @inject('ImagesRepositoryInterface')
    private readonly imagesRepository: ImagesRepositoryInterface,
  ) {}

  async findUnique(imageId: number) {
    const image = await this.imagesRepository.findUnique(imageId);
    if (isNil(image)) {
      throw new TRPCError({
        code: 'NOT_FOUND',
        message: `imageId: ${imageId}`,
      });
    }
    return image;
  }
  // ...etc
}

※useCaseは、repositoryの実装クラスでなく、repository.interface.tsに依存します。

このメソッドでの戻り値は、repository.interface.tsと同じくimageDtoになります。
useCaseのメソッドは、interface層や、他のuseCaseで呼び出される想定です。

次は少し複雑なメソッドの例として
imageにFireBase Storageの画像Urlと、画像が樹木画像か木材画像かの判定を追加して返すメソッドを紹介します。

// images.usecase.ts
@injectable()
export class ImagesUsecase {
  constructor(
    @inject('ImagesRepositoryInterface')
    private readonly imagesRepository: ImagesRepositoryInterface,
    @inject(ImagesService) private readonly imagesService: ImagesService,
    @inject(FireStorageUsecase) private readonly fireStorageUsecase: FireStorageUsecase,
  ) {}

  async findUniqueWithFileUrl(imageId: number) {
    const image = await this.imagesRepository.findUnique(imageId);
    if (isNil(image)) {
      throw new TRPCError({
        code: 'NOT_FOUND',
        message: `imageId: ${imageId}`,
      });
    }
    return await this.joinFileUrlAndTreeOrWood(image);
  }
  
  /**
   * fileのURL・画像が樹木か木材かを付与
   */
  async joinFileUrlAndTreeOrWood<T extends ImageDto>(image: T): Promise<T & ImageWithFileUrlDto> {
    const fileUrl = await this.fireStorageUsecase.getFileUrl(image.imagePath);
    return {
      ...image,
      fileUrl,
      treeOrWood: this.imagesService.getTreeOrWood(image.treeWoodDiv),
    };
  }
  // ...etc
}

imagesRepositoryからfindUniqueして、存在チェックするところまではシンプルですが、
そこから、joinFileUrlAndTreeOrWoodメソッドを使い、imageに追加の情報を持たせています。

joinFileUrlAndTreeOrWoodでは、

  • fireStorageUsecase(別ドメインのuseCase)からfileUrlを取得し、付与
  • imagesService(domain層)からtreeOrWoodを取得し、付与

しています。
(※T extends ImageDtoとしているのは、ImageDtoにUserが付与されている場合等にも対応する為です)

このようにuseCaseでは、他ドメインのuseCaseや、同ドメインのdomain層のロジックを使って、実装したいユースケースを満たしていきます。

imagesService(domain層)については
そのドメイン固有のロジックを持つクラスです。
imageDto内のデータで完結するロジックや、entityと協力して入力データを制約するロジックが格納されます。
今回のgetTreeOrWoodメソッドでは、そのimageが樹木の画像か木材も画像かを判断して区分を渡します。

findUniqueWithFileUrlの戻り値は
ImageWithFileUrlDtoを満たす値となります。
このDtoは、見ての通りImageDtoに追加の情報を付与したデータ型になります。
このような、domain層のDtoだけでは満たせないデータ型を扱いたい場合は、application層のDtosディレクトリにinterfaceを追加していきます。
(こちらは、今後もしかしたらinterfaceでなくクラスを扱うこともあるかもしれません。)

依存関係としては
application層は、domain層(下位)や、他ドメインのuseCase層(並列)に依存しています。

interface層

  • images.router.ts

interface層は、T3Stackなので、tRPCのルーター用のファイルになります。
一般的なAPIだとcontrollerに該当するものです。

実装としては、以下のような形です。

import { ImageWithFileUrlDto } from '@/domains/images/application/dtos/imageWithFileUrl.dto';
import { DiContainer } from 'src/server/domains/container';
import { ImagesUsecase } from 'src/server/domains/images/application/images.usecase';
import { publicProcedure, router } from 'src/server/server';
import { pickKeysData } from 'src/server/utils/pickKeysData';
import { z } from 'zod';

const imagesUsecase = DiContainer.get<ImagesUsecase>(ImagesUsecase);

export const imagesRouter = router({
  findUniqueWithFileUrl: publicProcedure
    .input(
      z.object({
        imageId: z.number(),
      }),
    )
    .query(async ({ input }) => {
      const imageWithFileUrl: ImageWithFileUrlDto = await imagesUsecase.findUniqueWithFileUrl(
        input.imageId,
      );
      return {
        imageWithFileUrl: pickKeysData(imageWithFileUrl, [
          'imageId',
          'fileUrl'
          // ...etc
        ]),
      };
    })
})

trpcのrouterは、router()関数で実装する必要があるので、クラスではありません。

内容としては、以下のような処理を実行しています。

  • zodでのバリデーション、inputの型定義を行う
  • DIコンテナからimagesUsecaseを取得して、useCaseにアクセスできるようにする
  • useCaseのメソッドを呼び出す
  • pickKeysData(自作関数)にて、フロントで必要なデータのみを抽出して、レスポンスとして返却する

routerに関する特筆すべき点としては
pickKeysDataという自作関数で、usecaseまでで扱ってきたDtoデータの中から、実際にフロントエンドで必要なデータのみを抽出して、データサイズの削減を行なっている点だと思います。

第一引数にデータを渡して、第二引数にそのデータ内のキー配列を渡すと、そのキーのデータのみ抽出してくれるものです。

function pickKeysData<T, K extends (keyof T)[]>(data: T, pickKeys: K): { [P in Extract<keyof T, K[number]>]: T[P]; }

pickKeys: KかつK extends (keyof T)[]なので、第一引数にデータを渡すと、
第二引数のキーは、そのデータ内のキーが予測で出てきます。(VSCode)

この処理をinterface層で行うか迷ったところではありますが、

  • 自分が過去にGraphQLを使用したプロジェクトで、"フロントエンド側で必要なデータを指定する形"を良いなと思った
  • useCaseは、他ドメインのuseCaseから呼び出されることがあるので、useCase層ではデータの内容を絞りたくなかった

という理由で、interface層で、この処理を行なっています。

ちなみに、フロントエンドのtRPCクライアントから、必要なキーを指定する方法も試したのですが、zodのスキーマ指定の関係上、渡したキーを使ってExtractすることは難しかったです。
実際のデータはExtractできていても、データの型がExtractできなくて挫折し、interfaceのエンドポイントごとに、必要なデータを指定することにしました。

なので、同じuseCaseの処理を呼んでいても違うデータが必要だから、エンドポイントは異なる。という事も起こりうる状態です。
この辺りは、より良い方法がないか考える余地があるなーと思っています。

依存関係としては
interface層は、application層(下位)に依存しています。

ここまで

APIのデータ取得フローと型 に着目した内容を書かせてもらいました。
この連載の1番肝の部分だと思います。

prismaのincludeの機能を殺したくない」 と決めてから試行錯誤が始まり、現状このような形に落ち着いています。

この実装は、
一般的なDDD、レイヤード・クリーンアーキテクチャ等とは、結構異なる実装になっていると思います。
例えば、

  • entityを、insert・updateの時にしか使わない
  • dtoが、interfaceになっている
  • 今のところ、値オブジェクトがない
    などなど

独自解釈も多めなので、参考になる部分と ならない部分があるかと思いますが、
高凝集・疎結合で、依存関係も整理されている つもりなので、もし参考になれば嬉しいです。

感想:
記事にまとめてから気が付きましたが、レイヤードで始めたはずが、いつの間にかクリーンアーキテクチャ寄りになっていることを自覚しました 笑

今後:
今回はデータ取得フローについて書いたので、残りは、

  • データ登録・更新フロー
  • フロントエンドとtRPCまわり
  • DDDを支える、ファイル生成用コマンドラインツール
    について、書いていこうと思います。

(フロントまわりはそんなに書く事がないかもしれません 笑)

それでは今回はここまでです。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?