Zenn
Open18

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

submaxsubmax

最近、TypeScript を採用している色々な企業の技術スタックを見てると、サーバーサイドのフレームワークに Nest.js が結構採用されている...ような気がする(観測範囲だと)。

実は触ったことがなく、TypeScript にあるデコレーター機能を使って色々やってるくらいのイメージだが、そんなに良いのかちょっと懐疑的、実際にコードを書いて確かめる。

(自分の趣向は置いといて)主な比較対象は、最近の New デファクトっぽくて人気ありそうな Hono とする。

submaxsubmax

プロジェクト作成時間

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
submaxsubmax

Package.json 依存関係

project generator で作成した状態で比較

プロジェクト初期状態の依存関係だが、 Nest.js は凄いたくさんある。
もしかしたら使わないものは省いて良いのかもしれないが、どれか消したら動かなくなるかもとか考えるのは大変そう。

Hono はマジで最低限
TypeScript すらインストールしないので、そのへんはお好みということだろうか
(Bun とか Deno とか、デフォルトで TypeScript を実行・ビルドできる開発ツールもあるので)

Nest.js
{
  "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"
  },
}
Hono
{
  "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"
  }
}
submaxsubmax

初期 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

なんか既にいっぱい生成されている

submaxsubmax

@Module, @Controller, @Injectable

Nest.js の世界で良く使われるデコレーター関数
@ を付けて、 class や property や引数の前に書くことで様々な効果を発揮する。

ちなみに、Nest.js デコレーターを適用した class はほぼ全て Singleton になるらしい

Module

Nest.js には ESModule 以外のモジュールシステムが存在するらしい。

https://docs.nestjs.com/modules

@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 を注入するのが主な使い方っぽい

submaxsubmax

Hello world コード

Nest.js
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ファイル分割が必須)

Hono
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 するだけなのでもうちょっと短く書ける

submaxsubmax

やっぱ Hono のほうが短いコードで書ける
(Expressに近い)

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

submaxsubmax

コントローラーの増設

Nest.js は Scaffolding によるコード生成を行う
(ボイラープレートが多いから誤魔化してる...ように見えるのはスルー)

shell
nest g controller users

tree nest-project/src/users/
# nest-project/src/users/
# ├── users.controller.spec.ts
# └── users.controller.ts
Nest.js
// 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 をコントローラーと見なしてみる。

submaxsubmax

Dependency Injection (依存性の注入)

一時期、バズワードのごとく流行っていた気がする依存性注入
Nest.js はフレームワークの根幹の概念としてこれをサポートすることを命題としている感はある。

一種のチュートリアル的コードを書いてみた↓

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 のモジュールシステムは使わんでもいい感。

submaxsubmax

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);

あれ、クラスデコレーター要らなくね? (とか言ってはいけない...)
まあでも関数がファーストクラスな言語を使ってるのであれば、普通に高階関数から依存するモジュールを引数として渡す考えのほうがシンプル。
コードも少なくできる。

submaxsubmax

リクエストパラメーター検証 (DTO)

GET の path params や POST の body を検証してみる

shell
pnpm add class-validator class-transforme
nest-project/src/main.ts
+ 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 のメソッド引数は検証された状態で渡されるようになる。

users.controller.ts
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) {}
// ... 後のコードは同様
submaxsubmax

Validation in Hono

Hono も zod を使える

shell
pnpm add @hono/zod-validator zod
hono-project/src/index.ts
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 してくれ)

submaxsubmax

DTO is 何?

Data Transfer Object の略らしい

https://ja.wikipedia.org/wiki/Data_Transfer_Object

デザインパターンの一種で、アプリケーションソフトウェアのサブシステム間でデータを転送するのに使う

オブジェクト指向用語みたいな感じで分かりづらいが...
バリデーション用途だと Transfer の部分は関係ないような?
データを検証するという意味だと Schema が 1 ワードでぴったりなので、DTOより意味が通りやすく使いやすい気がする。

Nest.js でも zod 使ったほうが良いかも。

追記:
ドメイン駆動開発用語でもあるらしい (最近、F#で学ぶ関数型ドメインモデリングの本で読んだ)
働いてるプロジェクトで DTO のほうが通りがいいなら DTO でいい。

submaxsubmax

Nest.js 微妙ポイント

触ってたら見つけたので、メモしておく。

1. 依存している @Injectable を登録し忘れても型エラーにならない

デコレーターによるDIは厳密に型サポートがあるわけではないらしい。
TypeScript のビルドは通るしエディターでもエラー表示されてないが、以下のコードはランタイムでエラーになる。

Nest.js
@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時にはエラーにならないが、実行時エラーになる。

Nest.js
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 ルールが使えなくなる)

submaxsubmax

Nest.js のパフォーマンス

https://web-frameworks-benchmark.netlify.app/result?l=javascript

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 とかもある
https://github.com/Savory/Danet

submaxsubmax

結論: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ぐらいしか聞いたことがなく、あまり普遍的でない開発手法
submaxsubmax

考察:Nest.js が流行ってる理由

個人的には微妙だったが、流行っているものには理由があるはず

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

Redditとか読んでると、上記のような理由があるように思える
(他にこういう理由で採用してますよ!とかあれば教えて下さい)

ログインするとコメントできます