CDK Construct の依存関係を適切に管理する ESLint ルール
概要
Web バックエンドエンジニアをしている山梨といいます。
私は普段 TypeScript を使用してバックエンドを開発しており、モジュールの依存関係を管理するために バレルパターン と privateディレクトリ をよく利用しています。
これらの内容を CDK での開発に合わせて、それぞれのモジュール管理方法と、モジュール管理を適切に行うための ESLint ルールについて紹介します。
1. バレルパターンを使用する場合
バレルパターンとは、複数のモジュールからエクスポートを 1 つのモジュールに集約するデザインパターンです。
参考: https://typescript-jp.gitbook.io/deep-dive/main-1/barrel
CDK でバレルパターンを利用することで、以下のような利点があります。
内容は、以下のスライドを参考にさせていただきました。
1. インポート行が簡潔になる
以下のように、各モジュールを個別にインポートする必要がなくなります
// import { Server } app from './constructs/app/server'
// import { Log } from './constructs/app/log'
import * as app from './constructs/app'
2. 呼び出し元でどのモジュールのConstructかがわかりやすくなる
以下のように <モジュール名>.<Construct名> とアクセスすることで、どのモジュールの Construct かがわかりやすくなり、可読性が向上します
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')
}
}
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
export { Server } from './server' // Serverだけをエクスポートする
このような利点を発揮するために、バレルパターンを使用しているディレクトリでは、Construct のインポートは必ず index.ts から行うようにルール化するのが良さそうです。
しかし、Lint ルールがない場合、index.ts 以外のファイルから Construct をインポートしても (エディタ上など) でエラーにならず、ルール違反に気付きにくいです。
そのため、このルールを強制させるために ESLint のルールを作成していきます。
ESLintルール
各 ts ファイルの内容
export class Log {}
import { Log } from './log'
export class Server {
constructor(/** ...some props */) {
new Log() // Log Constructを呼び出し
}
}
export { Log } from './log'
ESLint のルールは、no-restricted-imports を使用しています。
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 のインポートを試みると、エラーになっていることが確認できます

もし、lib/constructs 配下の全てのディレクトリにバレルを導入する場合は、以下のようなルールを利用できます
// 省略
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 ファイルの内容
export class Log {}
import { Log } from './private/log'
export class Server {
constructor(/** ...some props */) {
new Log() // Log Constructを呼び出し
}
}
ESLint のルールは、no-restricted-imports を使用しています。
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 のインポートを試みると、エラーになっていることが確認できます

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

まとめ
バレルパターンと、private ディレクトリを使用する場合にオススメな ESLint ルールについて紹介しました。
この記事の執筆段階で ESLint の v8 が EOL になっていたので、v9 のデフォルトである flatConfig での書き方を紹介しました。
参考
Discussion