はじめに
NestJSを1年ほど使っている。未経験の状態からすぐに高い生産性を発揮してくれた、素晴らしいフレームワークだ。
様々な機能を持つが、中でも特徴的なのはモジュールシステムだろう。
This helps us manage complexity and develop with SOLID principles, especially as the size of the application and/or team grow.
SOLID原則への準拠を目的とした機能だが、これを使いさえすれば私達のコードが自動的にSOLIDに準拠するわけでは当然ない。モジュール分割の指針やディレクトリ構成はユーザーに委ねられているわけだが、公式のチュートリアルに掲載されたパターンを現実のプロジェクトにそのまま適用しても、高い確率でそれは破綻する。
設計の根幹となるモジュール分割だけは、NestJSが標準で用意する枠組みを必要に応じて拡張する必要がある。この記事では、そのためのヒントを提供する。
モジュールを “使わない” 選択肢
まず、そもそも何故モジュールシステムを使うのか考える。
チュートリアルでは、各コントローラーが参照するユースケースは当たり前のようにServiceとしてプロバイダより注入するよう案内される。NestJSを使う以上それが流儀と考えてしまいがちだが、全てをDIだけで制御しようとした時に XXXX.module.ts
やその依存関係が無用に複雑になってしまうのは、多くの人が経験したことだろう。 const add = (x, y) => x + y;
にDIを適用する理由は、おそらく無い。
以上を踏まえ、次のような関数やオブジェクトを考えてみる:
- 副作用を伴わない、純粋な関数である。
- テスト時の要件として、モックする必要がない。
- 他のオブジェクトに依存しない。
- 機能要件として、実装の差し替えを行う必要がない。
上から順に「必ずしもDIを使わなくて良い順」に列挙した。
DIによって担保される品質特性は主に、テスト容易性(Testability)、変更容易性(Modifiability)、交換可能性(Replaceability)などである。逆に言うと、これらの品質特性を考慮する必要性の薄い実装は、必ずしもDIを、すなわちモジュールシステムを使う必要はない。
- 単一の純粋関数として表現できる。
- 実装の差し替えを行う必要性が薄い。
こういった実装に関しては、従来通りTypeScriptの import
/ export
だけを使う選択肢もある。メリットはコードベースのシンプルさだ。モジュールシステムを維持するコストと天秤にかけ、必要であればテストライブラリ標準のモック機能で代替可能かなどを踏まえ検討すると良い。
モジュールの “種類” を考える
結論としては「Controllerを exports
するモジュール」「それ以外を exports
するモジュール」の2つに分け、ディレクトリや名前で区別すると良い。
まず、何も考えずNestJSのチュートリアルや nest generate
コマンドの生成結果に従った場合の構成を考えてみよう。
例えば「ユーザー」を扱う UsersController
/ UsersService
とそれが所属するモジュール、「投稿」を扱う同じセット、という構成を考える。「ユーザー」「投稿」ともServiceがデータベースから情報を取得するものとする。
🚫 チュートリアルに準じた実装
チュートリアルで案内された通りに構成するとこのようになるが、この構成は PostsModule
からユーザーをクエリしたい という要請だけで破綻する。このまま素直に書き進めた場合、次のような構成になるだろう。
🚫 必要に応じて provies
を追加
ディレクトリ(機能)を跨いで詳細に依存してしまった。モジュールシステムを使ってはいるがSOLID原則には程遠い。詳細ではなく「モジュール」に依存するよう修正してみる。
🚫 構成を変えず imports
を追加
依存関係の隠蔽は行えたが UsersModule
の責務が暴走してしまった。更に言うと、UsersModule
からで投稿をクエリしたいという新たな要請により循環参照まで発生してしまう。
今回の場合、次のようにすると良いだろう。
✅ 機能をグループ化したモジュールを新設
モジュール分割を考えず単にクラスの依存関係だけ考えた場合とほぼ同じ形だ。そして、例題としてよく挙げられる「仮にDBを変更することになったら?」という要請には RepositoryModule
を丸ごと差し替えるだけで対応可能でもある。
次に、このモジュール分割におけるディレクトリ構成について考える。
🚫 nest generate
が標準で提供する配置
src/
repository/
repository.module.ts
user.repository.ts
post.repository.ts
users/
users.module.ts
users.controller.ts
posts/
posts.module.ts
posts.controller.ts
標準の構成では非常に座りが悪い。RESTリソースを想定した users/
や posts/
と、機能としての repository/
が同列に並んでいる。
数が増えてきた際に混沌とするのは目に見えている。次のように修正すると良いだろう。
✅ コントローラーとそれ以外とを区別
src/
controllers/
users/
users.controller.module.ts
users.controller.ts
posts/
posts.controller.module.ts
posts.controller.ts
services/
repository/
repository.service.module.ts
users.repository.ts
posts.repository.ts
ディレクトリ名を services/
としたがこれは何でも良い。ディレクトリを分け、名前も FooControllerModule
/ BarServiceModule
と言った形で区別を容易にした。
最終的に「よく見るディレクトリ構成」に落ち着いたに過ぎないのだが、 nest generate
に全面的に従っただけではこの形にならない点に注意。冒頭で述べた通り 公式のチュートリアルに掲載されたパターンを現実のプロジェクトにそのまま適用 しても、多くはうまくいかない。
ℹ️ HTTPリクエスト以外への対応
ところで、NestJSはブートストラップ処理(通常はsrc/main.ts
)を複数用意することで、HTTPリクエスト以外からも処理を起動できる。バッチ処理やHTTP外の非同期処理を実装する場合、それに応じて起動ハンドラに毎に次のような構成にするのも良い:
src/
handlers/
controllers/
HTTPリクエストを処理するハンドラusers/
posts/
commands/
例: CLIコマンドを処理するハンドラ
…queue-consumers/
例: 非同期キューからのメッセージを処理するハンドラ
…
services/
…
ℹ️ AppModule
は全体の設定のみ行う
では AppModule
は「コントローラー」「それ以外」どちらに該当するだろう?
「どちらにも該当しない」として扱うと良い。何かを exports
するわけにはいかない。配下に直接コントローラーを配置してしまうと、上で述べたような座りの悪い状態になる。
従って、自動的に配置される AppService
/ AppController
は削除してしまおう。もし /
へのルーティングが必要なのであれば IndexControllerModule
辺りを作成し隔離する。
結果 AppModule
の役割は:
- コントローラーモジュールを
imports
する。 @Global()
なサービスモジュールをimports
する。APP_FILTER
やAPP_INTERCEPTOR
で全体の振る舞いを設定する。- 必要に応じて
configure()
で起動処理などを実装する。
以上に絞られることになる。
providers
と imports
の選択
モジュール内でDIを設定するには、次の2つの方法がある:
- 注入クラスを
providers
へ直接指定 - 注入クラスの提供モジュールを
imports
へ指定
この選択が、特に依存が連鎖する場合の保守性に大きく影響する。
教科書的には「単一責任原則に従い決定すれば良い」という回答になるが、ここでは、NestJSにおけるそれぞれの選択がその後の保守性にどう影響するかを考えてみる。
依存の連鎖
例えば次のような、少し複雑なパターンを考える:
VerySimpleService
: モジュールでラップせず直接providers
へ指定BarService
: 注入クラスをproviders
へ直接指定FugaService
: 注入クラスの提供モジュールをimports
へ指定
最初の SomeController
から VerySimpleService
への依存に注目する。例えば現在は次のようなコードとディレクトリ構成だったとする。
@Module({
controllers: [SomeController],
providers: [
VerySimpleService,
],
})
export class SomeControllerModule {}
controllers/
some/
some.controller.module.ts
some.controller.ts
services/
very-simple.service.ts
この構成に対し、何らかの追加要件で VerySimpleService
が他のサービスに依存しなければならなくなった状況を考えよう。
🚫 providers
の連鎖
まず確認だが、SomeController
が 直接依存しているのは VerySimpleService
だけだ。従ってコードは次のようになっている:
@Controller()
export class SomeController {
constructor(
private readonly verySimpleService: VerySimpleService,
) {}
}
だが、この孫の依存も、それが所属する SomeControllerModule
から providers
として参照することになる。
@Module({
controllers: [SomeController],
providers: [
+ LittleComplexService, // 追加
VerySimpleService,
],
})
export class SomeControllerModule {}
この依存の追加が、破滅への第一歩だ。 具体的には:
controllers
やproviders
が今よりも増えていった場合- 「孫」だけでなくそれより先の子孫の依存が必要になった場合
こういった状況で、この providers
を管理できるだろうか?
修正範囲はおそらく SomeControllerModule
に留まらない。 VerySimpleService
を間接的にでも参照する全てのモジュール はもちろんのこと @nestjs/testing
によるユニットテストを実装している場合、 テストコード中の Test.createTestingModule()
などにまで及ぶ。
一度こうなってしまうと 何か依存を追加する度に Nest can't resolve dependencies of ...
に長時間悩む ことになる。これは避けたい。
✅ imports
の連鎖
既に挙げた3例のうち:
この形であれば対応は容易だ。依存が追加された以上、それが所属するモジュールに providers
を追加することは避けられないが、新たな依存はモジュールの内部に隠蔽されているため 修正箇所は1箇所 で済む。従って:
- SHOULD: 単独の小さなサービスであっても、モジュールでラップし
exports
で公開 すべきである - MAY: 新たな依存は追加されないと予想できる小さなサービスは
providers
で直接参照 しても良い- MUST: 予想に反し依存が追加された場合、その時点でモジュールに隠蔽 しなければならない
このように考えると良いだろう。
ここは臨機応変な対応が重要だ。将来を心配するのであれば あらゆるサービスは providers
への直接の指定を避けモジュールでラップすべき となるが、極めて小さな(しかし複数のモジュールから参照される)ユースケースを書く度にファイルを2つ作成し @Module()
を設定するのは気が重い。
幸いなことにTypeScriptは、気の利いたエディタであればクラス名の変更やファイル移動(及びそれに応じた import from
の自動修正)は容易だ。上の SHOULD と MUST に特に注意しつつ、状況が許せば MAY の採用を辞さないのが合理的と私は考えている。
Package Privateの導入
ここまで「ディレクトリ構成」「依存の設定方法」について述べてきたが、何れも単なる「指針」にしか過ぎず、強制力が無い。
本来であればモジュールを通じて imports
されることを想定したサービスクラスを別のモジュールで直接 providers
から参照されてしまうことは、TypeScriptの仕様として避けられないわけだが、これを避け安全に運用するため、何等か追加のルールやツールを導入してもいいだろう。
ℹ️ index.ts
による暗黙的な表明
users/
users.controller.module.ts
users.controller.ts
users.service.ts
index.ts
ここまで紹介したテクニックに準拠した場合、このフォルダはおそらく「UsersControllerModule
を AppModule
で imports
する」以外のことは想定されていない。そこで:
export * from './users.controller.module.ts';
Package Privateであることをこのように表明できる。強制力は無いままだが、何もしないよりはこの方が良い。
✅ eslint-plugin-import-access での禁止
ツールの導入により強制力を持たせることもできる。
前述の例で言えば、users/users.service.ts
/ users/users.controller.ts
は users/
配下以外で決して import
してはいけない(users/
配下では import
して良い)ことになるが、これをLintルールとアノテーションで強制できる。 index.ts
を使った表明にも対応している。
詳しくは作者のZennの記事を参照して欲しい。
まとめ
1年ほどの試行錯誤を通じて得たノウハウを一通り紹介した。
別案や異論、発展案などをお持ちの方は是非とも @KentarouTakeda まで連絡して欲しい。特に、自分はまだ Monorepo mode の運用経験が無いため、コードベースが更に成長した際のプラクティスに対しては検討が足りないかもしれない。
何れも習作の段階やプロジェクト初期はオーバースペックだが、サービスクラスの数が2桁程度になってきた辺りでストレスを感じてくるはずだ。その時は是非、ここで取り上げたテクニックを活用して欲しい。
Nest can't resolve dependencies of ...
に悩まされることのないコードベースの運用を目指していきたい。