🍝

NestJS で絡みあったモジュールの循環参照を整理する

に公開

Ubie で副業として Backend For Frontend (BFF) サーバーの開発を担当している nissy-dev です。

この記事では、NestJS を使用したモジュラモノリスアーキテクチャにおいて、開発が進むにつれて増加した循環参照の問題と、その解決に向けた具体的な取り組みについて解説します。

NestJS とモジュールの循環参照

ユビーでは、BFF の GraphQL サーバーを実装する際に、NestJS を利用したモジュラモノリスを採用しています。

https://zenn.dev/ubie_dev/articles/53c5953b037e38

モジュラモノリスの設計において、モジュール間の独立性の確保は非常に重要です「ソフトウェアアーキテクチャの基礎」には、次のような説明があります。

しかし、クラスやコンポーネントを安易に相互インポートすることは、モジュール性を損なうことにつながる。たとえば、図 6-3 には、アーキテクトが避けたいと望む、特に有害なアンチパターンを示している。図 6-3 では、各コンポーネントが相互に参照してしまっている。コンポーネントがこのようなネットワークを作ってしまうと、他のコンポーネントも一緒に持ってこなければ単一のコンポーネントを再利用できないため、モジュール性は損なわれてしまう。

このように、モジュール間の循環参照には特に注意が必要です。しかし、NestJS では forwardRef を使うことで循環参照を簡単に許容することができてしまいます。

https://docs.nestjs.com/fundamentals/circular-dependency

開発している BFF サーバーでは、forwardRef をできる限り利用しないように実装を進めていました。しかし、複数のチームが開発を進めていく中で forwardRef は増加し、次第にモジュール間のどこで循環参照が起きているのかもわからない状態になっていきました。次の Mermaid 図は、実際に循環参照しているモジュールだけを抜き出した時の依存グラフになります。

実際にプロジェクト内の forwardRef を利用している箇所は 30 箇所を超え、モジュール間の依存関係も大きな泥団子と呼ばれる状態に近づいていました。

循環参照の解消

循環参照が進むことでモジュール間の依存関係が複雑化し、開発サーバーの起動時にエラーが発生したり、テスト実装時にモジュールのモックがうまく機能しなくなったりするなどの問題が発生するようになりました。また、AI Agent によるコード生成の精度が低下するといった声も聞かれるようになりました。

これらの循環参照による問題を解消するため、以下の 3 つの取り組みを実施しました。

  • 不要なコードの削除
  • GraphQL の type の extend を利用した依存の逆転
  • モジュールの分割

不要なコードを削除する

まず初めにモジュール間の依存関係を madge を使って精査したところ、ドメインの観点から見て不自然なモジュール間の参照がいくつか見つかりました。これらの参照の多くは、機能の廃止やリファクタリングの影響で不要になったコードに起因するものでした。

この状況に対処するため、knip を使用して不要なコードを削除することにしました。knip は静的解析によって不要なコードを検知できるため、安全にコードの削除を進めることができました。

なお、このような状況は A/B テストなどを通じて機能の追加・削除を頻繁に行うコードベースや、複数のチームで同じコードを管理しているリポジトリでは起きやすい問題です。そのため、knip を CI に導入してチェックを行うことで、不要なコード削除への意識を高めることも重要です。

GraphQL の type の extend を利用した依存の逆転

見つかった循環参照の一つとして、GraphQL の type レベルでの循環参照がありました。例えば、次の src/modules/product/product.graphqlsrc/modules/order/order.graphql では Product と Order が相互に参照を持っています。

src/modules/product/product.graphql
type Query {
  products: [Product!]!
}

type Product {
  id: ID!
  orders: [Order!]! # Product は Order を含む
}
src/modules/order/order.graphql
type Query {
  orders: [Order!]!
}

type Order {
  id: ID!
  products: [Product!]! # Order は Product を含む
}

このようなケースで resolver を実装しようとすると、Product と Order のモジュールがお互いにサービスなどをインポートする必要があるため、NestJS のモジュールでも循環参照が発生します。

src/modules/product/product.resolver.ts
export class ProductResolver {
  // Order モジュールをインポートして OrdersService を利用する
  constructor(private ordersService: OrdersService) {}

  @ResolveField()
  async orders(@Parent() product: Product) {
    const { id } = product;
    return this.ordersService.findByProductId(id);
  }
}
src/modules/order/order.resolver.ts
export class OrderResolver {
  // Product モジュールをインポートして ProductsService を利用する
  constructor(private productsService: ProductsService) {}

  @ResolveField()
  async products(@Parent() order: Order) {
    const { id } = order;
    return this.productsService.findByOrderId(id);
  }
}

この問題については、GraphQL の type の extend を利用して対処することにしました。つまり、GraphQL を次のようにリファクタリングします。

src/modules/product/product.graphql
type Query {
  products: [Product!]!
}

type Product {
  id: ID!
- orders: [Order!]! # Product は Order を含む
}

+ extend Order {
+   products: [Product!]!
+ }
src/modules/order/order.graphql
type Query {
  orders: [Order!]!
}

type Order {
  id: ID!
- products: [Product!]! # Order は Product を含む
}

+ extend Product {
+   orders: [Order!]!
+ }

こうすることで、resolver の実装における Product と Order のモジュール間の循環参照をなくすことができます。具体的には、Product モジュールに Order の products フィールドの resolver を定義することで、Product モジュールから Order モジュールへの参照をなくすことができます。逆に Order モジュールに Product の orders フィールドの resolver を定義することで、Order モジュールから Product モジュールへの参照をなくすことができます。

src/modules/product/product.resolver.ts
// Product モジュールに Order の products フィールドの resolver を定義することで、
// Product モジュールから Order モジュールへの参照をなくすことができる
export class OrderResolver {
  constructor(private productsService: ProductsService) {}

  @ResolveField()
  async products(@Parent() order: Order) {
    const { id } = order;
    return this.productsService.findByOrderId(id);
  }
}
src/modules/order/order.resolver.ts
// Order モジュールに Product の orders フィールドの resolver を定義することで、
// Order モジュールから Product モジュールへの参照をなくすことができる
export class ProductResolver {
  constructor(private ordersService: OrdersService) {}

  @ResolveField()
  async orders(@Parent() product: Product) {
    const { id } = product;
    return this.ordersService.findByProductId(id);
  }
}

なお、他のモジュールで定義されている GraphQL のスキーマや resolver を拡張するこの方法は、一見すると暗黙の依存関係を生み出すように見えます。実際に、拡張によってフィールド名の重複が発生したり、全てのフィールドを一箇所で把握することが難しくなるといった課題が考えられます。

ただし、これらの問題は静的解析によって検知・対処が可能であることを踏まえて、モジュール間の循環参照が発生する状況よりは望ましい選択肢であると判断しました。

モジュールの分割

他に見つかった循環参照としては、NestJS のモジュールレベルでの循環参照です。例えば、次のようにモジュール内のロジックで相互にサービスの実装を参照しているようなケースです。

  • ModuleX は ServiceA、ServiceB、ServiceC に依存する
  • ModuleY は ServiceD、ServiceE に依存する
  • ServiceA は ModuleY を通じて ServiceD に依存する
  • ServiceE は ModuleX を通じて ServiceC に依存する

このケースを Mermaid 図に変換すると次のようになります。ModuleX → ServiceA → ModuleY → ServiceE → ModuleX の循環パスがあることがわかると思います。

この問題を解決するには、他のモジュールから利用されるサービスをまとめてサブモジュールとして切り出し、モジュールを分割できないかを検討します。例えば、ServiceC を含む新しい ModuleZ を作成して、ModuleX と ModuleY を ModuleZ に依存させます。この時の依存関係を Mermaid 図に変換すると次のようになります。

これにより、ModuleX は ModuleY と ModuleZ を、ModuleY は ModuleZ をインポートするだけで済むようになり、モジュール間の循環参照を解消することができます。

循環参照を増やさないために

これまでの作業で、循環参照をほとんど解消することができました。しかし、今後も循環参照が増えないように再発防止の仕組みも整備する必要があります。

そこで、循環参照の差分が生じた場合にリポジトリをメンテナンスするコアメンバーへ通知するワークフローを作成しました。このワークフローでは、PR の base と head のコミットでそれぞれ dependency-cruiser を使用して循環参照を検知し、その差分を PR にコメントする仕組みを実装しています。

ワークフローは次のような流れで動作します:

  1. PR が作成または更新されたときに実行される
  2. base ブランチと head ブランチそれぞれで循環参照を検出する
  3. 両者の差分を抽出する
  4. 差分がある場合は、コアメンバーに通知するコメントを PR に投稿する

実装したワークフローの設定:

name: Check circular dependencies

on:
  pull_request:
    types: [opened, synchronize]
    paths:
      - "src/modules/**/*.module.ts"

jobs:
  check-circular-deps:
    name: check circular dependencies
    runs-on: ubuntu-latest
    steps:
      - name: Check out base on this branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.ref }}
      - name: Setup Node.js
        uses: actions/setup-node@v4
      - name: check circular deps on base
        run: |
          NO_COLOR=1 npx depcruise src/modules > ./circular-deps-base.txt
          # 後続のdiffのために不要な行を削除して一時ファイルに保存
          head -n -3 ./circular-deps-base.txt > /tmp/circular-deps-base.txt
      - name: Check out head on this branch
        uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
      - name: check circular deps on head
        run: |
          NO_COLOR=1 npx depcruise src/modules > ./circular-deps-head.txt
          head -n -3 ./circular-deps-head.txt > /tmp/circular-deps-head.txt
      - name: Diff circular deps
        run: diff -uBw /tmp/circular-deps-base.txt /tmp/circular-deps-head.txt > /tmp/circular-deps-diff.txt || true
      - name: Generate comment
        id: generate-comment
        run: |
          if [ -s /tmp/circular-deps-diff.txt ]; then
            cat << EOF > /tmp/comment.txt
          循環参照の差分が見つかったので、@core-members も確認してください
          <details>
          <summary>dependency-cruiser が検知した差分</summary>

          \`\`\`diff
          $(cat /tmp/circular-deps-diff.txt)
          \`\`\`
          </details>
          EOF
          else
            echo "循環参照の差分は見つかりませんでした。" > /tmp/comment.txt
          fi

          # 複数行の文字列を GITHUB_OUTPUT に代入するときに必要な処理
          # https://github.com/orgs/community/discussions/26288#discussioncomment-3876281
          delimiter="$(openssl rand -hex 8)"
          echo "comment<<${delimiter}" >> $GITHUB_OUTPUT
          cat /tmp/comment.txt >> $GITHUB_OUTPUT
          echo "${delimiter}" >> $GITHUB_OUTPUT
      - name: Upsert PR Comment
        uses: ./.github/actions/upsert-pr-comment
        with:
          prNumber: ${{ github.event.pull_request.number }}
          commentBody: |
            **循環参照のチェック**: ${{ github.event.pull_request.head.sha }}
            ${{ steps.generate-comment.outputs.comment }}
          searchKeyword: "**循環参照のチェック**"

./.github/actions/upsert-pr-comment は PR のコメントを upsert する composite action です。gh コマンドで実装すると複雑になりがちな処理には、actions/github-script が便利な場合があります。

name: Upsert PR Comment

inputs:
  prNumber:
    required: true
  commentBody:
    required: true
  searchKeyword:
    required: true

runs:
  using: "composite"
  steps:
    - name: Upsert PR Comment
      uses: actions/github-script@v7
      env:
        PR_NUMBER: ${{ inputs.prNumber }}
        COMMENT_BODY: ${{ inputs.commentBody }}
        SEARCH_KEYWORD: ${{ inputs.searchKeyword }}
      with:
        script: |
          const { PR_NUMBER, COMMENT_BODY, SEARCH_KEYWORD } = process.env;
          const comments = await github.rest.issues.listComments({
            owner: context.repo.owner,
            repo: context.repo.repo,
            issue_number: PR_NUMBER,
          });

          const commentToUpdate = comments.data.find(comment => comment.body.startsWith(SEARCH_KEYWORD));

          if (commentToUpdate) {
            await github.rest.issues.updateComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              comment_id: commentToUpdate.id,
              body: COMMENT_BODY,
            });
          } else {
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: PR_NUMBER,
              body: COMMENT_BODY,
            });
          }

このワークフローにより、循環参照の増加を早期に検知し、適切な対応を取ることができるようになりました。

まとめ

この記事では、NestJS を使用したモジュラモノリスアーキテクチャにおいて、開発が進むにつれて増加した循環参照の問題と、その解決に向けた具体的な取り組みについて解説しました。

GraphQL の type の extend によるリファクタリングは、type の全フィールドを一箇所で把握しづらくなるというなどのデメリットがありつつも、今回のケースのようにモジュール間の依存関係を整理する上では有効な手法であることが分かりました。

循環参照による問題については理解していたつもりでしたが、実際に AI Agent によるコード生成の精度低下にまで影響が及んでいたことは驚きでした。モジュラーモノリスのコードベースを複数のチームで開発する場合、モジュール全体の依存関係を把握・監視する仕組みを整備することが重要だと実感しました。

GitHubで編集を提案
Ubie テックブログ

Discussion