はじめに
この記事では、NestJS の Module が何を分けるための仕組みなのかを整理します。
NestJS を触り始めると、次のようなコードをよく見ます。
@Module({
imports: [],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
Controller や Service は役割を想像しやすいですが、Module は少し分かりにくいです。
フォルダを分けるためのものなのか。
機能単位を表すものなのか。
DI の設定を書く場所なのか。
どれも一部は合っています。
ただし、Module を単なるフォルダ分けとして見ると、NestJS の構造を理解しにくくなります。
この記事では、Module を「DI の公開範囲と依存関係を決める境界」として整理します。
先に結論
NestJS の Module は、関連する Controller と Provider をまとめる単位です。
同時に、次のことを NestJS に伝えます。
- この Module はどの Controller を持つか
- この Module はどの Provider を持つか
- 他の Module から何を使うか
- 他の Module に何を公開するか
つまり Module は、単なるファイル整理ではありません。
アプリケーションの依存関係を、NestJS の DI コンテナが解決できる形で宣言するための境界です。
公式ドキュメントでも、Module は密接に関連する機能をまとめる効果的な方法として説明されています。
また、Module は @Module() デコレータで定義され、Nest がアプリケーション構造を整理するために使うメタデータを提供します。
Module はフォルダではなく境界
たとえば、ユーザー機能を次のように分けたとします。
src/
users/
users.module.ts
users.controller.ts
users.service.ts
このフォルダ構成だけを見ると、users/ が機能のまとまりに見えます。
しかし、NestJS にとって重要なのはフォルダ名ではありません。
重要なのは UsersModule に何が登録されているかです。
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
この定義によって、NestJS は次のことを理解します。
-
UsersControllerはUsersModuleに属する -
UsersServiceはUsersModuleの Provider として扱う -
UsersModuleの中ではUsersServiceを注入できる
つまり、Module はディレクトリ構造そのものではありません。
どのクラスを同じ依存解決の範囲に置くかを宣言するものです。
フォルダは人間が読みやすくするための整理です。
Module は NestJS が依存関係を解決するための整理です。
controllers は入口を登録する
controllers には、その Module が持つ Controller を登録します。
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
Controller は HTTP リクエストの入口です。
たとえば次のような Controller があるとします。
@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(":id")
findById(@Param("id") id: string) {
return this.usersService.findById(id);
}
}
UsersController は UsersService に依存しています。
このとき、UsersService が同じ Module の providers に登録されていれば、NestJS はコンストラクタに UsersService を渡せます。
Module は、Controller と Provider の対応関係を NestJS に知らせる場所です。
providers は Module 内で使える部品を登録する
providers には、DI コンテナで管理したいクラスを登録します。
@Injectable()
export class UsersService {
findById(id: string) {
return {
id,
name: "Alice",
};
}
}
@Module({
providers: [UsersService],
})
export class UsersModule {}
@Injectable() は、そのクラスが Provider として扱えることを示します。
ただし、@Injectable() を付けるだけで、どこからでも自動的に使えるわけではありません。
通常は、どこかの Module の providers に登録されている必要があります。
providers に登録すると、その Module の中で依存として解決できるようになります。
この感覚は重要です。
NestJS では、Provider はただ存在するだけではなく、Module の文脈の中で管理されます。
imports は他の Module の公開APIを使う
別の Module にある Provider を使いたい場合は、imports を使います。
たとえば、認証機能からユーザー情報を取得したいとします。
AuthModule
AuthService
UsersModule
UsersService
AuthService から UsersService を使いたい場合、AuthModule は UsersModule を import します。
@Module({
imports: [UsersModule],
providers: [AuthService],
})
export class AuthModule {}
ただし、これだけでは不十分な場合があります。
UsersModule 側で UsersService を外に公開していなければ、AuthModule から注入できません。
そこで exports が必要になります。
exports は外へ見せるものを決める
exports は、その Module の外から使える Provider を決めます。
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
このようにすると、UsersModule を import した別の Module から UsersService を使えるようになります。
@Injectable()
export class AuthService {
constructor(private readonly usersService: UsersService) {}
}
exports を見ると、その Module が外部に提供しているものが分かります。
これは設計上かなり大事です。
Module 内部で使うだけの Provider は exports しません。
他の機能から使わせたい Provider だけを exports します。
つまり、exports は Module の公開APIです。
imports と exports の関係
Module 間の関係は、次のように考えると整理しやすいです。
AuthModule が UsersModule を import します。
UsersModule が UsersService を export します。
その結果、AuthService は UsersService を注入できます。
ポイントは、import すれば何でも使えるわけではないことです。
使えるのは、import 先の Module が export しているものです。
この制約があるから、Module は境界として機能します。
内部実装を隠し、外に見せる Provider を限定できます。
何でも AppModule に置くと何が困るのか
小さいアプリでは、すべてを AppModule に置いても動きます。
@Module({
controllers: [UsersController, AuthController, OrdersController],
providers: [UsersService, AuthService, OrdersService],
})
export class AppModule {}
ただし、機能が増えると問題が出ます。
- どの Controller と Provider が対応しているか分かりにくい
- 依存関係が AppModule に集まりすぎる
- 機能ごとの公開範囲が曖昧になる
- テストや差し替えの単位が大きくなる
Module を分ける目的は、ファイル数を増やすことではありません。
機能ごとの依存関係を小さく保ち、どこから何を使っているかを見えやすくすることです。
Feature Module は業務機能ごとに分ける
実務では、まず業務機能ごとに Module を分けると考えやすいです。
たとえば次のような単位です。
- UsersModule
- AuthModule
- OrdersModule
- PaymentsModule
- NotificationsModule
それぞれの Module は、自分の機能に必要な Controller と Provider を持ちます。
users/
users.module.ts
users.controller.ts
users.service.ts
users.repository.ts
auth/
auth.module.ts
auth.controller.ts
auth.service.ts
この分け方では、UsersModule はユーザー機能のまとまりです。
AuthModule は認証機能のまとまりです。
そして、認証機能がユーザー情報を必要とするなら、AuthModule が UsersModule を import します。
@Module({
imports: [UsersModule],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
このように書くと、依存関係がコード上に現れます。
Shared Module は乱用しない
共通処理をまとめるために、SharedModule を作りたくなることがあります。
たとえば次のようなものです。
- LoggerService
- DateFormatter
- HashService
- MailService
共通部品をまとめること自体は問題ありません。
ただし、何でも SharedModule に入れると、境界が崩れます。
特に避けたいのは、業務ロジックを SharedModule に逃がすことです。
shared/
user-permission.service.ts
order-cancel-policy.service.ts
payment-rule.service.ts
このような状態になると、shared が便利置き場になります。
業務の意味を持つ Provider は、基本的には該当する Feature Module に置いた方が分かりやすいです。
SharedModule に置くなら、業務機能に依存しない横断的な部品に限定すると扱いやすくなります。
Global Module は例外として使う
NestJS には @Global() があります。
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
Global Module にすると、毎回 imports に書かなくても Provider を使えるようになります。
便利ですが、使いすぎると依存関係が見えにくくなります。
公式ドキュメントでも、何でも global にすることは推奨されていません。
基本的には imports を使い、どの Module が何に依存しているかを明示した方が保守しやすくなります。
Global Module は、ログ、設定、DB 接続など、アプリケーション全体で本当に共通の基盤に限定する方が安全です。
Dynamic Module は設定付きで import する仕組み
NestJS では、ConfigModule.forRoot() のような書き方を見かけます。
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
],
})
export class AppModule {}
これは Dynamic Module の考え方です。
通常の Module は、imports: [UsersModule] のようにそのまま import します。
一方 Dynamic Module は、import するときに設定を渡せます。
たとえば、独自の Module なら次のような形です。
@Module({})
export class MailModule {
static register(options: MailModuleOptions): DynamicModule {
return {
module: MailModule,
providers: [
{
provide: MAIL_OPTIONS,
useValue: options,
},
MailService,
],
exports: [MailService],
};
}
}
使う側は次のように書きます。
@Module({
imports: [
MailModule.register({
from: "noreply@example.com",
}),
],
})
export class AppModule {}
Dynamic Module は、汎用的な Module をアプリごとの設定で使いたいときに便利です。
ただし、通常の業務機能を分けるだけなら、最初から Dynamic Module を作る必要はありません。
まずは通常の Feature Module で十分です。
よくある誤解
Module について、最初に混乱しやすい点を整理します。
| 誤解 | 実際 |
|---|---|
| Module はフォルダ分けである | フォルダではなく DI の境界です |
| import すれば何でも使える | export された Provider だけ使えます |
@Injectable() があればどこでも注入できる |
Module に登録されている必要があります |
| 共通処理は全部 SharedModule に置けばよい | 業務ロジックまで置くと境界が崩れます |
| Global Module にすれば楽になる | 依存関係が見えにくくなるため例外的に使います |
特に大事なのは、imports と exports の関係です。
NestJS の Module は、単に「読み込む」だけではなく、「何を外へ見せるか」を明示します。
設計するときの目安
Module を分けるときは、次の順番で考えると整理しやすいです。
- 業務機能ごとに Feature Module を作る
- その機能の入口を
controllersに登録する - その機能で使う Service や Repository を
providersに登録する - 他の Module から使わせたい Provider だけ
exportsする - 必要な Module だけ
importsする
逆に、次の状態になっていたら見直しどころです。
-
AppModuleが大きすぎる -
SharedModuleに業務ロジックが集まっている -
exportsが多すぎる - どの Module がどの Provider を使っているか追いにくい
- Global Module が増えすぎている
Module は細かく分ければ良いわけではありません。
依存関係が自然に読める単位で分けることが大事です。
まとめ
NestJS の Module は、Controller と Provider をまとめるための仕組みです。
ただし、それだけではありません。
Module は、DI の公開範囲と依存関係を決める境界でもあります。
controllers は入口を登録します。
providers は Module 内で使う部品を登録します。
imports は他の Module の公開APIを使います。
exports は外へ見せる Provider を決めます。
この視点で見ると、@Module() は単なる設定ではありません。
NestJS アプリケーションの構造を、フレームワークが解決できる形で宣言するための中心的な仕組みです。
NestJS のデコレータの考え方については、「なぜ NestJS はアノテーション(デコレータ)文化なのか」でも整理しています。