📝

CDK Construct の依存関係を適切に管理する ESLint ルール

に公開

概要

Web バックエンドエンジニアをしている山梨といいます。

私は普段 TypeScript を使用してバックエンドを開発しており、モジュールの依存関係を管理するために バレルパターンprivateディレクトリ をよく利用しています。

これらの内容を CDK での開発に合わせて、それぞれのモジュール管理方法と、モジュール管理を適切に行うための ESLint ルールについて紹介します。

1. バレルパターンを使用する場合

バレルパターンとは、複数のモジュールからエクスポートを 1 つのモジュールに集約するデザインパターンです。
参考: https://typescript-jp.gitbook.io/deep-dive/main-1/barrel

CDK でバレルパターンを利用することで、以下のような利点があります。
内容は、以下のスライドを参考にさせていただきました。

https://speakerdeck.com/gotok365/aws-cdk-reusability?slide=34

1. インポート行が簡潔になる

以下のように、各モジュールを個別にインポートする必要がなくなります

lib/my-stack.ts
// import { Server } app from './constructs/app/server'
// import { Log } from './constructs/app/log'
import * as app from './constructs/app'

2. 呼び出し元でどのモジュールのConstructかがわかりやすくなる

以下のように <モジュール名>.<Construct名> とアクセスすることで、どのモジュールの Construct かがわかりやすくなり、可読性が向上します

lib/my-stack.ts
import * as app from './constructs/app'

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    const server = app.Server(this, 'Server')
    const lob = app.Server(this, 'Log')
  }
}
lib/constructs/app/index.ts
export { Server } from './server'
export { Log } from './log'

3. どのConstructが外部で使用されるかを意図的に制限できる

以下のようなディレクトリ構成の場合に、外部で使用される Construct だけを index.ts でエクスポートすることで、Construct の公開制限を行うことが可能です

./lib/
├── my-stack.ts
└── constructs
    └── server
        ├── index.ts
        ├── server.ts // これだけ公開したい
        └── log.ts
lib/constructs/app/index.ts
export { Server } from './server' // Serverだけをエクスポートする

このような利点を発揮するために、バレルパターンを使用しているディレクトリでは、Construct のインポートは必ず index.ts から行うようにルール化するのが良さそうです。

しかし、Lint ルールがない場合、index.ts 以外のファイルから Construct をインポートしても (エディタ上など) でエラーにならず、ルール違反に気付きにくいです。
そのため、このルールを強制させるために ESLint のルールを作成していきます。

ESLintルール

各 ts ファイルの内容
lib/constructs/app/log.ts
export class Log {}
lib/constructs/app/server.ts
import { Log } from './log'

export class Server {
   constructor(/** ...some props */) {
     new Log() // Log Constructを呼び出し
   }
}
lib/constructs/app/index.ts
export { Log } from './log'

ESLint のルールは、no-restricted-imports を使用しています。

https://typescript-eslint.io/rules/no-restricted-imports/#how-to-use

https://eslint.org/docs/latest/rules/no-restricted-imports

eslint.config.mjs
import eslint from "@eslint/js";
import tsEslint from "typescript-eslint";

export default tsEslint.config(
  eslint.configs.recommended,
  ...tsEslint.configs.strict,
  ...tsEslint.configs.stylistic,
  {
    files: ["lib/**/*.ts"],
    // 省略
    rules: {
      // 省略
      "@typescript-eslint/no-restricted-imports": [
        "error",
        {
          patterns: [
            {
              regex: "(\\.?\\.*/)?(constructs/)?/app/(?!index$).*",
              message:
                "`constructs/app`配下のモジュールは、必ずindex.tsからimportしてください。",
            },
          ],
        },
      ],
    },
  },
);

実際に server.ts のインポートを試みると、エラーになっていることが確認できます

barrel pattern error import

もし、lib/constructs 配下の全てのディレクトリにバレルを導入する場合は、以下のようなルールを利用できます

eslint.config.mjs
// 省略
rules: {
  "@typescript-eslint/no-restricted-imports": [
    "error",
    {
      "patterns": [
        {
          "regex": "(\\.?\\.*/)?constructs/[^/]+/(?!index$).*",
        }
      ]
    }
  ]
}

2. privateディレクトリを使用する場合

この項目では、「非公開にしたいモジュール」を privateディレクトリ に配置し、private ディレクトリ内の Construct は外部からインポートできないように強制する ESLint ルールを紹介します。

バレルパターンでは「外部からインポートされるモジュール (index.ts)」と「外部からインポートされないモジュール (index.ts 以外)」を同じ階層に配置していました。

一方 private ディレクトリを使用する場合は、以下のように「外部からインポートされるモジュール (private 外)」と「外部からインポートされないモジュール (private 内)」は別の階層になるため、ディレクトリ構成を見た時に依存関係がより明確になる利点があります。

./lib/
├── my-stack.ts
└── constructs
    └── server
        ├── server.ts // private/log.tsを呼び出している
        └── private
            └── log.ts

ESLintルール

各 ts ファイルの内容
lib/constructs/app/private/log.ts
export class Log {}
lib/constructs/app/server.ts
import { Log } from './private/log'

export class Server {
   constructor(/** ...some props */) {
     new Log() // Log Constructを呼び出し
   }
}

ESLint のルールは、no-restricted-imports を使用しています。

eslint.config.mjs
import eslint from "@eslint/js";
import tsEslint from "typescript-eslint";

export default tsEslint.config(
  eslint.configs.recommended,
  ...tsEslint.configs.strict,
  ...tsEslint.configs.stylistic,
  {
    files: ["lib/**/*.ts"],
    // 省略
    rules: {
      // 省略
      "@typescript-eslint/no-restricted-imports": [
        "error",
        {
          patterns: [
            {
              regex: "(\\.?\\.*/)?(constructs/)?/[^/]+/private/*",
              message: "privateディレクトリ内のモジュールはimportできません。",
            },
          ],
        },
      ],
    },
  },
);

実際に private/log.ts のインポートを試みると、エラーになっていることが確認できます

private directory error case

また、正規表現により lib/constructs/app/server.ts からは lib/constructs/app/private 配下のモジュールはインポートできることが確認できます

private directory normal case

まとめ

バレルパターンと、private ディレクトリを使用する場合にオススメな ESLint ルールについて紹介しました。

この記事の執筆段階で ESLint の v8 が EOL になっていたので、v9 のデフォルトである flatConfig での書き方を紹介しました。

https://eslint.org/blog/2024/09/eslint-v8-eol-version-support/

参考

https://speakerdeck.com/gotok365/aws-cdk-reusability

https://typescript-jp.gitbook.io/deep-dive/main-1/barrel

https://eslint.org/docs/latest/rules/no-restricted-imports

GitHubで編集を提案

Discussion