Nest.js を他のフレームワークと比較する(そのOOP、必要?)

最近、TypeScript を採用している色々な企業の技術スタックを見てると、サーバーサイドのフレームワークに Nest.js が結構採用されている...ような気がする(観測範囲だと)。
実は触ったことがなく、TypeScript にあるデコレーター機能を使って色々やってるくらいのイメージだが、そんなに良いのかちょっと懐疑的、実際にコードを書いて確かめる。
(自分の趣向は置いといて)主な比較対象は、最近の New デファクトっぽくて人気ありそうな Hono とする。

プロジェクト作成時間
pnpm
を使用、time
コマンドで測定
(インストールオプションのCLI 操作はなるべくもたつかないように善処)
Nest.js は依存関係が多いのか、インストールに結構時間がかかった。
time pnpm dlx @nestjs/cli new nest-project
# package manager = pnpm
________________________________________________________
Executed in 27.72 secs fish external
usr time 7.96 secs 74.00 micros 7.96 secs
sys time 15.45 secs 892.00 micros 15.45 secs
time pnpm create hono@latest hono-project
# package manager = pnpm
# runtime = nodejs
________________________________________________________
Executed in 8.43 secs fish external
usr time 2.04 secs 75.00 micros 2.04 secs
sys time 0.68 secs 787.00 micros 0.68 secs

Package.json 依存関係
project generator で作成した状態で比較
プロジェクト初期状態の依存関係だが、 Nest.js は凄いたくさんある。
もしかしたら使わないものは省いて良いのかもしれないが、どれか消したら動かなくなるかもとか考えるのは大変そう。
Hono はマジで最低限
TypeScript すらインストールしないので、そのへんはお好みということだろうか
(Bun とか Deno とか、デフォルトで TypeScript を実行・ビルドできる開発ツールもあるので)
{
"name": "nest-project",
"version": "0.0.1",
// ...
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^15.14.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
}
{
"name": "hono-project",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.7.2"
},
"devDependencies": {
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
}
}

初期 src
Hono と Nest.js を比較
tree hono-project/src
src/
└── index.ts
tree nest-project/src/
nest-project/src/
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts
なんか既にいっぱい生成されている

@Module, @Controller, @Injectable
Nest.js の世界で良く使われるデコレーター関数
@ を付けて、 class や property や引数の前に書くことで様々な効果を発揮する。
ちなみに、Nest.js デコレーターを適用した class はほぼ全て Singleton になるらしい
Module
Nest.js には ESModule 以外のモジュールシステムが存在するらしい。
@Module
デコレーターをクラスに適用し、引数で様々な機能を導入する
-
controllers
: @Controller class を登録する -
providers
: @Injectable class を登録する -
imports
: 他のモジュールからexport されている providers を登録する -
exports
: 他のモジュールへ providers を公開する
Controllers
@Controller デコレーターをクラスに適用して作成。
@Get, @Post などをメソッドに適用して、HTTPサーバーとしてのエンドポイント実装になる。
依存性注入においては Provider に対する Consumer であり、
Controller クラスのコンストラクターで、 Module に Provider 登録された @Injectable クラスのシングルトンを受け取って依存関係としてプロパティにセットし、使用することができる。
そのあたりのコンストラクタへの値渡しは、 NestFactory.create()
関数がいろいろやるので、プログラマーからはブラックボックス。
Injectable
Injectable = 依存性注入における Provider
シングルトンとしてインスタンス化され、Controller のコンストラクタにシングルトンが渡されて使われるクラス。
****Service
というクラス名でビジネスロジックを実装したモジュールを注入したり、DBや外部サービスへの接続を行う Repogitory を注入するのが主な使い方っぽい

Hello world コード
import { NestFactory } from '@nestjs/core';
import { Controller, Get, Module } from '@nestjs/common';
@Controller()
export class AppController {
constructor() {}
@Get()
getHello(): string {
return "Hello NestJS!";
}
}
@Module({
imports: [],
controllers: [AppController],
providers: [],
})
export class AppModule {}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Nest.js はともかく class 定義とデコレーター適用があるので、行数が多くなる。
ちょっとしたコードなので 1 ファイルにまとめたが、コード生成の観点から規定通りにファイルを分けたほうが良い (app.module/controller/service.ts
の 3ファイル分割が必須)
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
serve({
fetch: app.fetch,
port: 3000
})
Hono + Bun だと、app を default export するだけなのでもうちょっと短く書ける

やっぱ Hono のほうが短いコードで書ける
(Expressに近い)
Nest.js スタイルの、デコレートされた class を定義してプログラマー自身はそれを new せずフレームワークの Factory 関数に任せるというのは一種の冗長性とブラックボックスだが、どの辺りで Express 的なコードより生産性の観点で上回ってくるのかは気になる。

コントローラーの増設
Nest.js は Scaffolding によるコード生成を行う
(ボイラープレートが多いから誤魔化してる...ように見えるのはスルー)
nest g controller users
tree nest-project/src/users/
# nest-project/src/users/
# ├── users.controller.spec.ts
# └── users.controller.ts
// users.controller.ts
import { Controller, Get } from "@nestjs/common";
@Controller("users")
export class UsersController {
@Get()
findAll() {
return [
{ id: 1, name: "John" },
{ id: 2, name: "Alice" },
{ id: 3, name: "Mike" },
];
}
}
// app.module.ts
@Module({
imports: [],
+ controllers: [AppController, UsersController],
providers: [AppService],
})
export class AppModule {}
Hono は Hono アプリを複数作って、 app.route()
で適用することで prefix を付けた別のルーターを統合できる。
import { Hono } from "hono";
const app = new Hono().get(/* ... */)
const usersApp = new Hono().get("/", (c) => {
return c.json([
{ id: 1, name: "John" },
{ id: 2, name: "Alice" },
{ id: 3, name: "Mike" },
]);
});
app.route("/users", usersApp);
まあ Hono には Controller の概念は無いけど、 Handler をまとめて登録した Sub-App をコントローラーと見なしてみる。

Dependency Injection (依存性の注入)
一時期、バズワードのごとく流行っていた気がする依存性注入
Nest.js はフレームワークの根幹の概念としてこれをサポートすることを命題としている感はある。
一種のチュートリアル的コードを書いてみた↓
import { Controller, Get, Injectable, Module } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello Nest.js!';
}
}
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
const app = await NestFactory.create(AppModule);
// const testApp: TestingModule = await Test.createTestingModule({
// controllers: [MockAppController],
// providers: [MockAppService],
//}).compile();
AppService クラスと AppControler クラスを実装し、AppModule に渡す。
AppController は、依存性注入されてきた AppService クラスを使用できる。
テストのときだけ実装を変えたかったら、モック実装したクラスを代わりに渡す。
DI を意識したコードを書くと、テストで特別にモジュールをモックする機能とかが必要がなくなったり、一部実装を変えてコードを再利用できたりするのがウリらしい。
(データレイヤーに RDB を使う代わりに NoSQL を使ったりとか)
まあテストに関しては jest でも vitest でも普通にモジュールをモックしたら良いと思いますが...
ESModule でしっかり作れば別に Nest.js のモジュールシステムは使わんでもいい感。

DI in Hono
まあ別に、Nest.js のようなクラスデコレーターを使った仕組みを使わなくても DI はできる。
標準的な JavaScript の関数に依存モジュールを引数として渡して、それに依存する Hono アプリ実装を返せばいいだけ。
import { Hono } from "hono";
const appServiceObj = {
getHello() {
return "Hello Hono!";
},
};
function makeApp(appService: { getHello: () => string }) {
return new Hono().get("/", (c) => c.text(appService.getHello()));
}
const app = makeApp(appServiceObj);
// const testApp = makeApp(mockAppServiceObj);
あれ、クラスデコレーター要らなくね? (とか言ってはいけない...)
まあでも関数がファーストクラスな言語を使ってるのであれば、普通に高階関数から依存するモジュールを引数として渡す考えのほうがシンプル。
コードも少なくできる。

リクエストパラメーター検証 (DTO)
GET の path params や POST の body を検証してみる
pnpm add class-validator class-transforme
+ import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
+ app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
フィールドにバリデーションデコレーターを適用した DTOクラス を引数の型にして、引数に @Body デコレーターを適用することで、 Controller のメソッド引数は検証された状態で渡されるようになる。
import { IsInt, IsNumberString, IsNotEmpty, IsString } from "class-validator";
class CreateUserDto {
@IsNotEmpty()
@IsInt()
id: number;
@IsNotEmpty()
@IsString()
name: string;
}
class FindOneParams {
@IsNumberString() id: number;
}
@Controller("users")
export class UsersController {
@Post()
create(@Body() params: CreateUserDto) {
console.log(`id: ${params.id}, name: ${params.name}`);
return "created";
}
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
return `user id: ${id}`;
}
//...
}
nestjs-zod というやつもあるが...
デコレーターに適応するため zod schema を DTO Class に変換する一行が必要
import { createZodDto } from 'nestjs-zod'
import { z } from 'zod'
const createUserSchema = z.object({
id: z.number(),
name: z.string(),
})
class CreateUserDto extends createZodDto(createUserSchema) {}
// ... 後のコードは同様

Validation in Hono
Hono も zod を使える
pnpm add @hono/zod-validator zod
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const createUserSchema = z.object({ id: z.number(), name: z.string() })
app.post(
"/users",
zValidator("json", createUserSchema),
async (c) => {
const params = await c.req.valid("json")
console.log(`id: ${params.id}, name: ${params.name}`);
return c.json(c.body);
},
).get("/users/:id", async (c) => {
const id = c.req.param().id; // 特別、バリデーションを入れなくてもパスパラメータは型付きで取得できる。 validator 変換もできる
return c.text(`user id: ${id}`);
});
基本的に同じことをしているので、書き方の違いはあれどどっちが良いとかなさそう。
Nest.js はデコレーターを書く都合上で記述量が肥大化する。
リクエストのパラメータ検証周りのコードの綺麗さは ElysiaJS が一番好きかも
バリデータが内蔵されているというのもある。
(つか絶対必要なんだからモダンフレームワークを名乗るなら battery-include してくれ)

DTO is 何?
Data Transfer Object の略らしい
デザインパターンの一種で、アプリケーションソフトウェアのサブシステム間でデータを転送するのに使う
オブジェクト指向用語みたいな感じで分かりづらいが...
バリデーション用途だと Transfer の部分は関係ないような?
データを検証するという意味だと Schema が 1 ワードでぴったりなので、DTOより意味が通りやすく使いやすい気がする。
Nest.js でも zod 使ったほうが良いかも。
追記:
ドメイン駆動開発用語でもあるらしい (最近、F#で学ぶ関数型ドメインモデリングの本で読んだ)
働いてるプロジェクトで DTO のほうが通りがいいなら DTO でいい。

Nest.js 微妙ポイント
触ってたら見つけたので、メモしておく。
1. 依存している @Injectable を登録し忘れても型エラーにならない
デコレーターによるDIは厳密に型サポートがあるわけではないらしい。
TypeScript のビルドは通るしエディターでもエラー表示されてないが、以下のコードはランタイムでエラーになる。
@Module({
imports: [],
controllers: [AppController, UsersController],
- providers: [/* AppService */],
})
export class AppModule {}
さっき DI in Hono で書いた例では、引数の渡し忘れは型エラーになる。
function makeApp(appService: AppService) {
// ...
}
+ const app = makeApp(undefined); // type error
おそらく、Nest.js の CLI ジェネレーターは、ボイラープレート作成の手間削減の他に、プロバイダー登録のし忘れを防止するためにあるのだろう。
TypeScript の実験的機能(デコレーター)を使う割には、型安全性についてはそんなに優れてない。
しっかり型エラーになってほしい。
2. Injectable Class を type import すると依存解決できない
TypeScript デコレーター機能が実験的なため?
@Controller のコンストラクタ引数の型としてしか使ってなくても、クラス自体を import しないとエラーになる。
これもビルド時やLint時にはエラーにならないが、実行時エラーになる。
import { Controller, Get } from '@nestjs/common';
- import type { AppService } from './app.service'; // @Injectable クラスを type だけ import するとバグる
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
}
知らずにデフォルトのESLint設定を変えたり、Biomeに移行したりすると理由もわからず死ぬトラップ。
ESlint Rule にも consistent-type-imports があるように、 type import で済ませると不要な import をビルド時に削除できたりといった利点があるのだが、フレームワークの挙動のためにそこを制限するのはどうなのか。
(この挙動をバグらせないために、プロジェクト全体で consistent-type-imports ルールが使えなくなる)

Nest.js のパフォーマンス
FW | req/sec(64) |
---|---|
ultimate-express | 114,417 |
elysia | 108,638 |
hono | 62,692 |
nestjs-fastify | 3,068 |
nestjs-express | 2,777 |
上のベンチマークが本当ならマジで遅い
(Fastifyこんなに遅かったっけ...?)
Hono は速い。 Elysia はもっと速い。
Nest.js は実質的に Express or Fastify のラッパーライブラリなので、動作が速いサーバー実装にアダプターできれば早くなる見込みはある。
2025/02/18 時点でトップの ultimate-express とか。
代替として、 Hono の上に実装された Nest.js ライクの Deno フレームワークの Danet とかもある

結論:Nest.js の個人的評価
Guard とか Pipe とか Intercepter とか、まだ触ってない概念もあるけど大体分かった。
個人の感想としては、他のFWに比べて特別に便利でもシンプルでもないし、OOP風味が逆に冗長。
OOPのデザインパターンをフレームワークレベルで導入している感じ。
この辺の冗長さをペイできるのはどれくらいの開発期間を経た後なのかが気になる。
個別の機能の感想
機能 | 感想 |
---|---|
モジュールシステム | @Module より ESModule で良くね? 同じことを解決する2つの手法を重複して使っている感。 |
NPMの依存関係 | プロジェクト初期状態でも依存パッケージが多い。 最近のフレームワークは依存少なくしようと頑張っているので見習ってほしい 追記: よく見るとdevDependenciesが多い、実際の依存は少なめ? (それでも rxjs とか必要かと思うが) |
DI (依存性注入) | 別にデコレーター使わなくても、JS の関数でできる |
デコレーター記法 | 別に必要ない。@Get method() と書くのが、 Express的な app.get(...) に比べて優れているとは感じない。 Express よりも宣言的とかいう意見もありそうだが、 自分で直接newしない Singleton クラスを定義して Factory に作らせるという手法がブラックボックス過ぎて微妙。 あと、JSに存在しない概念なので、TSを使わないとNest.jsは利用できない |
データ検証 | DTOとかOOPのデザインパターン用語出てくる? zod schema でいい。 |
シンプルさ | OOP特有の、DIとかDTOとかFactoryとかSingletonとかの概念をFWレベルで 導入することでExpress的なシンプルさよりもだいぶ複雑になっている。 OOP要る? |
型安全性 | 甘い。 DIの依存を登録し忘れても型エラーが出ない、type import 周りでバグるなど、 TSの実験的機能を使用している故の落とし穴がある あと、pnpm create でプロジェクト作成したときの tsconfig で noImplicitAny が無効化されてるのも気になる |
プロジェクト構造 | ~.module.ts ~.service.ts ~.controler.ts モジュールごとにディレクトリを切りこれに従う。 フレームワークがソースツリー構造を決めているので、 チームでやるときに議論の余地がなくて良い(?) |
学習曲線 | 高そう。 TypeScriptのクラスデコレーターを使ってる有名フレームワークは 他にはAngularぐらいしか聞いたことがなく、あまり普遍的でない開発手法 |

考察:Nest.js が流行ってる理由
個人的には微妙だったが、流行っているものには理由があるはず
- 依存性注入、デコレータ、DTO、etc、OOPのデザインパターン指向のフレームワークだから?
- Java の Spring Boot、C# の ASP.NET に雰囲気が似てる
- つまり、学習曲線の高さに反して採用市場に潜在的な開発者が多い?
- クラスベースOOP言語から TypeScript に移ってきた開発者の方々が、同じメンタルモデルを流用できるから採用している?
- Java の Spring Boot、C# の ASP.NET に雰囲気が似てる
- エコシステムが大きく、プラグインが多い
- Swagger 生成、GraphQL, tRPC, Prisma など一通り対応している
- srcディレクトリの構造が規格的で、複数人での開発に向く(と思われているから)
- ExpressライクなFWはディレクトリ構造を制限しないが、複数人開発だと好ましくない?
(逆に Spring ライクに慣れていない自分にとっては冗長に感じるが...)
- ExpressライクなFWはディレクトリ構造を制限しないが、複数人開発だと好ましくない?
Redditとか読んでると、上記のような理由があるように思える
(他にこういう理由で採用してますよ!とかあれば教えて下さい)