NestJS と Express、Hono の違い: モジュールと依存性の注入
もくじ
Express は書けるのに、NestJS を初めて触ったら、最初の機能を1つ作るところで止まった。サービスのクラスを import して、エディタに赤い波線も出ていないのに、起動すると英語のエラーが出て動かない。知りたかったのは、なぜ import しただけでは動かないのかと、このエラーのどこを読めば直す場所が分かるのかだ。
Express、Hono、NestJS は、どれもバックエンド(サーバーの側で動いて、求められたデータを用意するプログラム)を書くための Web フレームワークだ。API は、バックエンドのうち、アプリやブラウザーからの求めを受け取って、データを返す部分だ。サービスは、データを扱うコードをまとめたクラスを指す。赤い波線は、TypeScript の型検査(コードを動かす前に、型の誤りを調べること)などで見つかった誤りの場所に、エディタが引く線だ。
止まった原因は、モジュール、依存性の注入、controllers と providers の書き分けという NestJS の基本を、僕が知らないまま書き始めたことだった。Express や Hono では、部品をどこでつなぐかを、僕は自分でいかようにでも決めてきた。NestJS では、部品を登録する場所が決まっている。動かなかった理由は、この違いにあった。
前提: Web フレームワークは、パスと処理を結び付ける
Web フレームワークは、アプリやブラウザーから届いたリクエスト(求め)を受け取り、パスに対応する処理を呼んで、結果を返す。パスは、GET /books の /books の部分だ。たとえば、GET /books というリクエストが届いたら、本の一覧を返す関数を呼ぶ。
処理が増えてくると、リクエストを受け取るコードと、データを扱うコードを分けて書くことが多い。受け取る側を コントローラ、データを扱う側を サービス と呼ぶ。たとえば、GET /books を受け取るのがコントローラで、本の配列から一覧を取り出すのがサービスだ。分けたからには、コントローラがサービスを呼べるように、だれかが2つをつながなければならない。
ここで扱うのは、コントローラとサービスの間だ。コントローラにサービスを渡すコードを、だれが、どこに書くのか。3つのフレームワークは、ここが違う。
3つの公式の説明は、こうだ。
- Express の公式サイトは、Express を「Fast, unopinionated, minimalist web framework for Node.js」と説明している。unopinionated は、決まったやり方を押し付けない、という意味だ
- Hono の公式ドキュメントは、Hono を「a small, simple, and ultrafast web framework built on Web Standards」と説明している。小さく、単純で、速い、という説明である
- NestJS の公式ドキュメントは、Node.js のライブラリや道具は数多くあるが、どれもアーキテクチャ(コードを役目ごとにどう分けて、どこに何を書くかの決め方)という主な問題を解いていない、と述べている。そのうえで、NestJS はすぐに使えるアーキテクチャを提供する、と説明している
NestJS は、内部では既定で Express を使う。既定のままなら、NestJS は Express の上に、どこに何を書くかの決まりを足していると言える。
困りごと: import したのに、起動で止まる
本の一覧を返す API を、NestJS で書く。GET /books に、本の番号と題名を返すだけの API だ。
src/
├── main.ts 起動する
├── app.module.ts 入口のモジュール
└── books/
├── books.service.ts サービス。本の配列を持つ
├── books.controller.ts コントローラ。GET /books を受け取る
└── books.module.ts 本の機能のモジュール
サービスは、本の配列を持ち、全件を返す。
// src/books/books.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class BooksService {
private readonly books = [
{ id: '1', title: '吾輩は猫である' },
{ id: '2', title: '注文の多い料理店' },
];
findAll() {
return this.books;
}
}
@ で始まる行は デコレータ で、クラスやメソッドに印を付ける。印を付けるだけで、印を読んで動くのは NestJS だ。@Injectable() は、このクラスを NestJS が管理できる、という印である。
コントローラは、GET /books を受け取って、サービスの findAll() を呼ぶ。
// src/books/books.controller.ts
import { Controller, Get } from '@nestjs/common';
import { BooksService } from './books.service.js';
@Controller('books')
export class BooksController {
constructor(private readonly booksService: BooksService) {}
@Get()
findAll() {
return this.booksService.findAll();
}
}
@Controller('books') と @Get() は、GET /books が届いたら findAll() を呼ぶ、という印だ。constructor の行(コンストラクタ。クラスから実体を作るときに呼ばれる部分)は、BooksService を引数で受け取る、と書いている。実体は、new で作った物を指す。このファイルのどこにも、new BooksService() は無い。
悪い例: providers が空のモジュール
モジュールは、本の機能を作っている部品の一覧だ。
// src/books/books.module.ts
import { Module } from '@nestjs/common';
import { BooksController } from './books.controller.js';
import { BooksService } from './books.service.js';
@Module({
controllers: [BooksController],
providers: [],
})
export class BooksModule {}
controllers にはコントローラを並べた。providers は空のままだ。BooksService は、コントローラのファイルでも、このファイルでも、先頭で import してある。
入口のモジュールには、本の機能のモジュールを並べる。
// src/app.module.ts
import { Module } from '@nestjs/common';
import { BooksModule } from './books/books.module.js';
@Module({
imports: [BooksModule],
})
export class AppModule {}
型検査のコマンドを実行しても、エラーは1件も出ない。ところが起動すると、次のエラーが出て止まる。エラーの出力のうち、原因を述べている1文だ。
Nest can't resolve dependencies of the BooksController (?). Please make sure that the argument BooksService at index [0] is available in the BooksModule module.
僕は、この文が英語のままでは読めなかった。どのアプリでも出そうな文に見えて、自分のコードのどこが悪いのかが分からない。僕のエディタに、赤い波線は出ていなかったのに、である。
結論: NestJS では、import に加えてモジュールへの登録が要る
NestJS では、サービスを import しただけでは受け取れない。上の例では、BooksService を BooksModule の providers に並べると受け取れる。入口のモジュールの imports はそのままにして、providers: [BooksService] と書けば、この例は動く。
理由は、コントローラにサービスを渡す作業を、NestJS が引き受けているからだ。Express と Hono では、つなぐコードを自分で書く。NestJS では、自分は「どの部品があるか」の一覧を書き、渡す作業は NestJS に任せる。この例では、BooksService を BooksModule の providers に登録しないと、NestJS はコントローラに渡せない。ファイルの先頭の import は、そのファイルでクラスを使えるようにする。モジュールの providers に登録する働きは無い。
Express では、作って渡すコードを自分で書く
同じ API を Express で書く。コントローラとサービスは、印の付いていない普通のクラスだ。
import express from 'express';
class BooksService {
private readonly books = [
{ id: '1', title: '吾輩は猫である' },
{ id: '2', title: '注文の多い料理店' },
];
findAll() {
return this.books;
}
}
class BooksController {
constructor(private readonly booksService: BooksService) {}
findAll() {
return this.booksService.findAll();
}
}
const booksService = new BooksService();
const booksController = new BooksController(booksService);
const app = express();
app.get('/books', (req, res) => {
res.json(booksController.findAll());
});
new BooksService() でサービスの実体を作り、new BooksController(booksService) でコントローラに渡している。この2行が、つなぐコードだ。app.get() は、パスと処理を結び付ける。
どの例も、起動したあとに自分で GET /books を呼び、HTTP の番号(200 や 404 のような、応答の結果を表す番号)と応答の本文を表示する数行を足して実行した。その数行は載せていない。
200 [{"id":"1","title":"吾輩は猫である"},{"id":"2","title":"注文の多い料理店"}]
サービスを渡し忘れて new BooksController() と書くと、型検査が次のエラーを出す。
error TS2554: Expected 1 arguments, but got 0.
引数が1つ要るのに0個だった、という意味だ。型検査で見つかる誤りなので、エディタが赤い波線を引く対象になる。この例では、コンストラクタが引数を1つ必ず受け取ると書いてあるので、渡し忘れは TypeScript の目に入る。
Hono では、分けたルートを自分でつなぐ
Hono の公式ドキュメントの「Best Practices」は、Ruby on Rails(Ruby という言語の Web フレームワーク)のようなコントローラを、できれば作らないよう勧めている。コントローラに分けると、複雑な型の指定を書かない限り、パスに含まれる値(/books/:id の id の部分)の型を TypeScript が推論できないためだ。代わりに、パスの定義のすぐ後に処理を書き、アプリが大きくなったらファイルごとに分けて app.route() でつなぐ、と書いてある。それでもコントローラの形にしたいときのために、型の推論が働く書き方も、同じページに載っている。
その勧めに沿って、同じ API を書く。パスと処理の組を、ルートと呼ぶ。
// src/books.ts
import { Hono } from 'hono';
const books = new Hono();
books.get('/', (c) => {
return c.json([
{ id: '1', title: '吾輩は猫である' },
{ id: '2', title: '注文の多い料理店' },
]);
});
export default books;
// src/index.ts
import { Hono } from 'hono';
import books from './books.js';
const app = new Hono();
app.route('/books', books);
app.route('/books', books) が、つなぐコードだ。本のルートを、/books というパスにつないでいる。
200 [{"id":"1","title":"吾輩は猫である"},{"id":"2","title":"注文の多い料理店"}]
app.route() の1行を書き忘れても、型検査は通り、エラーも出ずに動く。GET /books を呼んだときに、はじめて分かる。
404 404 Not Found
先頭の 404 が HTTP の番号で、その後ろが応答の本文だ。books を import してあっても、つないでいなければ、そのパスは無いものとして扱われる。import しただけでは動かないのは、Hono も同じである。違うのは、つなぐコードを書くのが自分だという点だ。
コントローラとサービスのクラスに分けたときに、Hono でサービスをどう受け渡すかは、ここでは確かめていない。
NestJS では、モジュールに登録して、渡す作業を任せる
良い例: providers にサービスを並べたモジュール
// src/books/books.module.ts
import { Module } from '@nestjs/common';
import { BooksController } from './books.controller.js';
import { BooksService } from './books.service.js';
@Module({
controllers: [BooksController],
providers: [BooksService],
})
export class BooksModule {}
変えたのは providers の1行だけだ。
200 [{"id":"1","title":"吾輩は猫である"},{"id":"2","title":"注文の多い料理店"}]
自分が書く場所は、2か所ある。
- 「欲しい」と書く場所は、コントローラのコンストラクタの引数だ。
constructor(private readonly booksService: BooksService) {}が、BooksServiceが欲しい、と言っている - 「渡してよい」と書く場所は、モジュールの
providersだ。providers: [BooksService]が、BooksServiceを用意して渡してよい、と言っている
2か所がそろって、はじめて動く。サービスに @Injectable() の印を付けただけでは、一覧に載ったことにはならない。NestJS は、起動してコントローラを作るときに、コンストラクタの引数に書かれたクラスを手掛かりにして、渡してよい部品を探す。この例で探す先は、BooksModule の providers だ。
Express で自分が書いた2行を、NestJS が代わりに実行している。
const booksService = new BooksService();
const booksController = new BooksController(booksService);
僕は、この置き換えでやっと腑に落ちた。NestJS のコードに new が無いのは、だれも実行していないからではない。NestJS が実行しているからだ。
ここまでに出てきた物には、名前がある。
- モジュール:ひとつの機能を作っている部品を一覧にして、NestJS に伝えるクラス。
@Module()を付けて書く - プロバイダ:NestJS が用意して、必要とするクラスへ渡す部品。この例ではサービスがプロバイダで、モジュールの
providersに並べる - 依存性の注入:クラスが必要な部品を自分で
newせず、外から受け取る書き方。この例では、コンストラクタの引数で受け取っている。必要な部品を「依存性」、外から渡すことを「注入」と呼んでいる
部品が2つなら、自分で new して渡しても困らない。部品が増えると、どれを先に作り、どこへ渡すかを、自分で順番どおりに書き続けることになる。NestJS は、モジュールに登録された部品と、コンストラクタの引数から、作る順番と渡す先を決める。
import と imports は別の物
紛らわしいのは、似た名前が3つあることだ。
| 書く物 | 読むのは | 働き |
|---|---|---|
ファイルの先頭の import | TypeScript | ほかのファイルのクラスを、そのファイルで使えるようにする |
@Module() の imports | NestJS | このモジュールが使う、ほかのモジュールを並べる |
@Module() の providers | NestJS | 用意して渡してよい部品を並べる |
@Module() の controllers | NestJS | このモジュールのコントローラを並べる |
ファイルの先頭の import で BooksService を使えるようにしても、NestJS の一覧には載らない。NestJS が読むのは、@Module() の中だ。
受け取れる範囲も決まっている。NestJS の公式ドキュメントの「Modules」によると、通常のモジュールで受け取れるのは、同じモジュールのプロバイダか、imports に並べたモジュールが外へ出すと決めたプロバイダだけだ。
型検査は通るのに、起動で止まるエラーの読み方
この例の providers の書き忘れは、型検査に出ない。僕のエディタにも、赤い波線は出なかった。TypeScript から見ると、BooksService は正しく import されていて、コンストラクタの引数の型も合っている。providers: [] も、空の配列として正しい。BooksService が providers に登録されているかまでは、型検査は調べない。NestJS が起動して providers を読むときに、はじめて調べられる。TypeScript が黙っているのは、問題が無いからではない。TypeScript が調べる範囲の外だからだ。
起動で出るエラーには、続きがある。
Nest can't resolve dependencies of the BooksController (?). Please make sure that the argument BooksService at index [0] is available in the BooksModule module.
Potential solutions:
- Is BooksModule a valid NestJS module?
- If BooksService is a provider, is it part of the current BooksModule?
- If BooksService is exported from a separate @Module, is that module imported within BooksModule?
最初の1文を頭から日本語にすると、「NestJS は、BooksController が必要とする部品を用意できない。0番目の引数の BooksService が、BooksModule の中で使える状態になっているかを確かめてほしい」となる。
読む所は3つで、どれもクラスの名前だ。
| エラーの中の部分 | 分かること | この例では |
|---|---|---|
of the BooksController (?) | 受け取れなかったクラス。カッコの中はコンストラクタの引数を順に並べた物で、用意できなかった引数が ? になる | BooksController |
argument BooksService at index [0] | 足りない物の名前と、何番目の引数か。番号は0から数える | BooksService |
in the BooksModule module | NestJS が探したモジュール | BooksModule |
3つを並べると、直す場所が決まる。BooksModule の providers に、BooksService を足せばよい。「Potential solutions」の2行目も、BooksService がプロバイダなら、今の BooksModule に入っているか、と同じことを尋ねている。
Express の書き方に置き換えると、new BooksController(?) になる。渡す物が見つからなかった、というだけの話である。Express なら自分で書く行なので、この例の渡し忘れは型検査で見つかる。NestJS では NestJS がこの行を実行するので、実行する時、つまり起動の時に分かる。
NestJS の公式ドキュメントの「Common errors」は、同じ形の文を載せている。いちばん多い原因は、足りない物がそのモジュールの providers に無く、imports に並べたモジュールからも外へ出されていないことだ、と書いてある。
この例で imports から外すと、エラーは出ずに 404 になる
間違える場所を変えると、結果も変わる。BooksModule の providers は正しく書き、入口のモジュールの imports から BooksModule を外す。
// src/app.module.ts
import { Module } from '@nestjs/common';
import { BooksModule } from './books/books.module.js';
@Module({
imports: [],
})
export class AppModule {}
今度は、型検査も通り、起動もエラーなしで終わる。GET /books を呼ぶと、こうなる。
404 {"message":"Cannot GET /books","error":"Not Found","statusCode":404}
この例では、BooksModule が AppModule の imports に無いと、NestJS は BooksModule を読まない。BooksController も作られないので、BooksService を欲しがるクラスがいない。足りない物が無いので、エラーも出ない。/books というパスが登録されないだけだ。
| 書き忘れた場所 | 型検査 | 起動 | GET /books |
|---|---|---|---|
BooksModule の providers | 通る | エラーを出して止まる | 起動していないので送れない |
AppModule の imports | 通る | エラーなしで終わる | 404 |
Express、Hono、NestJS で違う所
下の表のうち、書き忘れに気づく時の行は、上で試した書き忘れだけの結果だ。ほかの間違い方までは確かめていない。
| 比べる点 | Express | Hono | NestJS |
|---|---|---|---|
| 部品をつなぐのは | 自分。new して渡す | 自分。app.route() でつなぐ | NestJS。モジュールの一覧を読んで渡す |
| この例で自分が書いた物 | new して渡す2行 | app.route() の1行 | モジュールの providers と、コンストラクタの引数 |
| 上で試した書き忘れに気づく時 | 型検査の時(引数の渡し忘れ) | リクエストを送った時(404) | 起動した時(providers の書き忘れ)か、リクエストを送った時(imports の書き忘れ。404) |
上の3つの例で書いたファイルは、Express が1つ、Hono が2つ、NestJS が5つだ。NestJS は、モジュールのファイルが増え、登録する場所を覚える手間が先に来る。僕が最初の機能で止まったのは、その手間をまだ払っていなかったからだ。どちらが優れているという話ではない。つなぐ作業を自分で持つか、フレームワークに預けるかが違う。
まとめ: NestJS では、import に加えてモジュールへの登録が要る
Express と Hono では、部品をつなぐコードを自分で書く。NestJS では、モジュールに部品を登録して、つなぐ作業を任せる。だから NestJS では、import しただけのサービスは受け取れない。
providers への登録を忘れると、型検査は通り、起動で止まる。そのときのエラーには、受け取れなかったクラス、足りない物、探したモジュールの3つの名前が入っている。どのアプリでも出そうに見えた文は、自分のコードのクラスの名前を3つ並べた文だった。
おわり😊

