🍙

NestJSでモジュールの循環参照を生じさせないためのモジュールレイヤードアーキテクチャとルール

2023/04/19に公開

NestJSを1年半ぐらい運用していていい感じのディレクトリ構成を思いついたので共有します。NestJSを使っているといつかモジュール同士の循環参照に悩むときがくるが、そのときに参考にしてくれればと思います。

一般的にレイヤードアーキテクチャというとcontrollerservicerepositoryのような依存関係を指すと思います。各レイヤーでは下方向のレイヤーしか依存してはいけず、同じレイヤー内部の依存や上方向の依存は禁止されています。レイヤーをスキップして依存していいかどうかは、チームのルールによります。

レイヤードアーキテクチャの図

こうすることで、依存を一方向にすることができ、循環参照を防ぐとともに、レイヤー間の抽象化が進んで、置換や再利用がしやすくなります。

NestJSにおけるモジュールの循環参照

ただ、このレイヤードアーキテクチャのルールだけだと、NestJSのモジュールによるディレクトリ構成では循環参照がおきることがあります。

NestJSではcontrollerservicerepository間の依存だけでなく、module同士の依存関係も考慮に入れる必要があります。

例えばusersモジュールとtasksモジュールがあったとします。usersモジュールでtasksモジュール内部のservicerepositoryを使いたいとき、userstasksへの依存が生じます。また、tasksモジュールでusersモジュール内部のservicerepositoryを使いたいとき、tasksusersへの依存が生じます。最初は片方しかありえないと思っていても、開発を進めていくと逆の依存が必要なユースケースが出てきたりします。

root/
  ┗ src
     ┠ users
     ┃  ┠ users.controller.ts
     ┃  ┠ users.module.ts
     ┃  ┠ users.repository.prisma.ts
     ┃  ┠ users.repository.inerface.ts
     ┃  ┗ users.service.ts
     ┗ tasks
        ┠ tasks.controller.ts
        ┠ tasks.module.ts
        ┠ tasks.repository.prisma.ts
        ┠ tasks.repository.inerface.ts
        ┗ tasks.service.ts

この循環参照は、モジュールのアーキテクチャ自体がレイヤードになっていないから起きます。

このような場合は、循環参照を避けるために、自分自身しか参照しない「コア」と、他に依存する「複合」にモジュールを分割したほうが良さそうです。今回だと

コア

  • users
  • tasks

複合

  • user-tasks

とかでしょうか?

root/
  ┗ src
     ┠ users
     ┃  ┠ users.controller.ts
     ┃  ┠ users.module.ts
     ┃  ┠ users.repository.prisma.ts
     ┃  ┠ users.repository.inerface.ts
     ┃  ┗ users.service.ts
     ┗ tasks
     ┃  ┠ tasks.controller.ts
     ┃  ┠ tasks.module.ts
     ┃  ┠ tasks.repository.prisma.ts
     ┃  ┠ tasks.repository.inerface.ts
     ┃  ┗ tasks.service.ts
     ┗ user-tasks
        ┠ user-tasks.controller.ts
        ┠ user-tasks.module.ts
        ┗ user-tasks.service.ts

コードベースの大規模化によるserviceの再利用性の高まり

アプリが大きくなっていくと、1つのDBテーブルに対して複数の抽象化モデルがでてくるときがあります。例えば通常のユーザーという概念の他にスーパーユーザーという概念がでてくるかもしれません。素直にDBのテーブルをidを共通にして分けるのがベストだとは思いますが、既存のusers.repository.tsusers.service.tsのロジックを再利用したいです。そんなとき、次のようなディレクトリ構成になります。

super-users.service.tsでは、users.service.tsに依存しているとします。

root/
  ┗ src
     ┠ users
     ┃  ┠ users.controller.ts
     ┃  ┠ users.module.ts
     ┃  ┠ users.repository.prisma.ts
     ┃  ┠ users.repository.inerface.ts
     ┃  ┗ users.service.ts
     ┠ super-users
     ┃  ┠ super-users.controller.ts
     ┃  ┠ super-users.module.ts
     ┃  ┗ super-users.service.ts
     ┗ users.index.module.ts

上記2つの課題を改善するディレクトリ構成

なるべくNestJSの良さを引き出すために、ドメインごとにフォルダ分けをしています。
ここらへんはこの記事を参照してください。
https://zenn.dev/dove/articles/72e66240f09f34

また、ほかのserviceを依存するserivceに特別な名前としてscenarioをつけて区別しています。

root/
  ┠ src
  ┃  ┠ domains
  ┃  ┃  ┠ users
  ┃  ┃  ┃  ┠ core 
  ┃  ┃  ┃  ┃  ┠ users.controller.ts
  ┃  ┃  ┃  ┃  ┠ users.module.ts
  ┃  ┃  ┃  ┃  ┠ users.repository.prisma.ts
  ┃  ┃  ┃  ┃  ┠ users.repository.inerface.ts
  ┃  ┃  ┃  ┃  ┗ users.service.ts
  ┃  ┃  ┃  ┠ super-users
  ┃  ┃  ┃  ┃  ┠ super-users.controller.ts
  ┃  ┃  ┃  ┃  ┠ super-users.module.ts
  ┃  ┃  ┃  ┃  ┗ super-users.scenario.ts
  ┃  ┃  ┃  ┗ users.index.module.ts
  ┃  ┃  ┠ tasks
  ┃  ┃  ┃  ┗ core 
  ┃  ┃  ┃     ┠ tasks.controller.ts
  ┃  ┃  ┃     ┠ tasks.module.ts
  ┃  ┃  ┃     ┠ tasks.repository.prisma.ts
  ┃  ┃  ┃     ┠ tasks.repository.inerface.ts
  ┃  ┃  ┃     ┗ tasks.service.ts
  ┃  ┃  ┗ user-tasks
  ┃  ┃     ┗ core 
  ┃  ┃        ┠ user-tasks.controller.ts
  ┃  ┃        ┠ user-tasks.module.ts
  ┃  ┃        ┗ user-tasks.scenario.ts
  ┃  ┠ shares
  ┃  ┃  ┠ typeUtil.ts
  ┃  ┃  ┗ i18n.ts
  ┃  ┠ vendors
  ┃  ┃  ┠ prisma
  ┃  ┃  ┃  ┠ prisma.module.ts
  ┃  ┃  ┃  ┗ prisma.service.ts
  ┃  ┃  ┗ redis
  ┃  ┃     ┠ redis.module.ts
  ┃  ┃     ┗ redis.service.ts
  ┃  ┠ app.controller.ts
  ┃  ┠ app.module.ts
  ┃  ┗ main.ts
  ┗ 設定やドキュメント系
フォルダ/ファイル 役割
src コードと設定系を分割するフォルダ
src/domains ドメイン特有のコードを入れるフォルダ
src/vendors prismaなどの依存注入したい外部ライブラリをいれるフォルダ
src/shares すべてのコードが依存する可能性がある便利系のコードを入れるフォルダ
src/app.controller.ts ヘルスチェック系などのコントローラー
src/app.module.ts すべてのモジュールに依存したモジュール
src/main.ts エントリーポイント

依存のイメージマップ

依存のイメージマップの図

ルール

レイヤードアーキテクチャに準じたルールを決めます。箇条書きにすると多く見えますが、基本的には

  • 同じレイヤーには依存できない
  • 下方向にレイヤーにのみ依存できる
  • 上方向のレイヤーには依存できない

を具体的に言っただけです。

  • shareフォルダにはユーティリティ系のファイルを置く
  • shareフォルダ内のファイルはDIで置き換えることは少ないと想定して、直importする。つまりモジュール化しない。
  • vendorsフォルダにはprismaなどのDIで置き換える可能性のある外部ライブラリをモジュール化して置く。
  • vendorsフォルダ内のモジュールは他のモジュールに依存してはいけない(ただし、shareフォルダ内のユーティリティ系ファイルは依存OK)。
  • domainsフォルダにこのドメイン特有のモジュールを置く。
  • domainsファルダ内のモジュールはvendorsフォルダ内部のモジュールに依存しても良い。
  • domainsフォルダ内の各モジュールは、serviceファイルかscenarioファイルのどちらか1つのみしか置いてはいけない。serviceファイルとscenarioファイルの両方を置いてはいけない。
  • serviceファイルのあるモジュールは、他のdomainsフォルダ内のモジュールに依存してはいけない。つまりserviceファイルは基本的に同一フォルダ内のrepositoryにしか依存できない。
  • scenarioファイルのあるモジュールは、他のdomainsフォルダのserviceファイルがあるモジュールに依存してもよい。
  • scenarioファイルのあるモジュールは、他のdomainsフォルダのscenarioファイルがあるモジュールに依存してはいけない。

もし、scenarioに依存したいさらなるscenarioがユースケースとして発生したら、さらに新しいレイヤーを作らないといけないかもですね...

TRAPE(トラピ)

Discussion