「Express で書いていたら肥大化してメンテ不能になった」「DIコンテナや AOP 的な仕組みが欲しい」「チーム開発でルールを統一したい」──そんな悩みを解決するのが NestJS です。Angular に強く影響を受けた モジュール / コントローラ / プロバイダ / DI / デコレータ という構造を Node.js 上に持ち込み、Express も Fastify も中で動かせる、まさにエンタープライズ向け Node.js フレームワークです。
本記事では NestJS 10.x(2026年最新) をベースに、Module / Controller / Service / DI / Pipe / Guard / Interceptor / Middleware / Exception Filter まで、コア要素をすべてコピペで動くTypeScriptコードで解説します。さらに TypeORM / Prisma / Mongoose / GraphQL / WebSocket / CQRS / Microservices / Swagger / Jest / Docker まで踏み込み、現場で本当に使える深さまで掘り下げます。
当サイトには Express 5 完全実践ガイド(ライト用途) と Hono 系の記事もありますが、本記事は 「エンタープライズ用途・大規模・チーム開発」 に最適化された NestJS の使いこなしに振り切ります。Express で物足りなくなった人、Hono が小規模すぎると感じている人は、ぜひ本記事を起点に NestJS を選択肢に加えてください。
- 1. NestJS とは何か:Express/Fastify との立ち位置
- 2. プロジェクトのセットアップとディレクトリ構成
- 3. Module / Controller / Service:三位一体の基本
- 4. DTO とバリデーション(class-validator + ValidationPipe)
- 5. Dependency Injection の深掘り
- 6. Guard / Interceptor / Middleware / Filter(AOP4兄弟)
- 7. 設定モジュール・DBアダプタ・GraphQL・WebSocket
- 8. CQRS / Microservices / 高度なパターン
- 9. テスト(Jest + Supertest + Test Module)
- 10. OpenAPI(Swagger)・Docker・運用
- 11. NestJS のはまりどころとアンチパターン
- 12. Express / Hono / NestJS の使い分け早見表
- まとめ:NestJS は「構造を強制してくれる Express」
1. NestJS とは何か:Express/Fastify との立ち位置
NestJS は 「HTTPサーバ実装(Express または Fastify)を内部で動かす、構造化フレームワーク」 です。Express をそのまま使うと、ルーティング・バリデーション・認証・DI・例外処理がすべて自前実装になります。NestJS はこれらを標準パターンとして与えてくれます。
1.1 採用基準:いつ NestJS を選ぶか
| 規模 | 推奨フレームワーク | 理由 |
|---|---|---|
| 個人ツール / マイクロエンドポイント | Hono / Express | 軽量・起動も書き味も最速 |
| 中規模 REST API | Express + 自前構造化 | 慣れた書き味で十分 |
| 大規模・チーム開発・長期運用 | NestJS | DI・テスト・モジュール境界が標準で揃う |
| マイクロサービス基盤 | NestJS Microservices | NATS/Kafka/RabbitMQ がアダプタ1行 |
1.2 動作モデル:Express も Fastify も内側で動く
NestJS の HttpAdapter は Express と Fastify を切り替え可能。書き味は変わらず、パフォーマンス特性だけ変えられます。
// main.ts(Expressアダプタ・デフォルト)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
// main.ts(Fastifyアダプタへ差し替え)
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
await app.listen(3000, '0.0.0.0');
}
bootstrap();
2. プロジェクトのセットアップとディレクトリ構成
2.1 CLI のインストールとプロジェクト生成
# NestJS CLI をグローバルインストール npm install -g @nestjs/cli # プロジェクト生成(パッケージマネージャを選択) nest new my-app # 起動 cd my-app npm run start:dev
# 主要な CLI コマンド nest g module users # UsersModule を生成 nest g controller users # UsersController を生成 nest g service users # UsersService を生成 nest g resource posts # CRUD一式(module + controller + service + DTO)を生成 nest g pipe shared/parse-int # カスタムPipeを生成 nest g guard auth/jwt # カスタムGuardを生成 nest g interceptor logging # カスタムInterceptorを生成 nest g filter shared/all-exceptions # 例外Filterを生成
2.2 推奨ディレクトリ構成
src/
├─ main.ts # エントリポイント
├─ app.module.ts # ルートモジュール
├─ common/ # 共通(Pipe/Guard/Filter/Interceptor)
│ ├─ filters/
│ ├─ guards/
│ ├─ interceptors/
│ └─ pipes/
├─ config/ # 設定モジュール
├─ users/
│ ├─ users.module.ts
│ ├─ users.controller.ts
│ ├─ users.service.ts
│ ├─ dto/
│ │ ├─ create-user.dto.ts
│ │ └─ update-user.dto.ts
│ └─ entities/
│ └─ user.entity.ts
└─ auth/
├─ auth.module.ts
├─ auth.service.ts
├─ auth.controller.ts
├─ guards/
└─ strategies/
2.3 tsconfig.json の推奨設定
// tsconfig.json(抜粋・NestJS 10想定)
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}
ポイント: experimentalDecorators と emitDecoratorMetadata はNestJSの DI(Reflect Metadata)が動くために必須です。
3. Module / Controller / Service:三位一体の基本
3.1 ルートモジュール(AppModule)
// src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
@Module({
imports: [UsersModule, AuthModule], // 他のモジュールを束ねる
controllers: [], // ルートに直書きする Controller があれば
providers: [], // ルートで共有する Provider があれば
})
export class AppModule {}
3.2 機能モジュール(UsersModule)
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController], // HTTPを受ける
providers: [UsersService], // DI コンテナに登録
exports: [UsersService], // 他モジュールから使うなら export
})
export class UsersModule {}
3.3 Controller:HTTP の境界レイヤ
// src/users/users.controller.ts
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll(@Query('limit') limit?: string) {
return this.usersService.findAll(Number(limit ?? 20));
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
@Post()
@HttpCode(201)
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateUserDto,
) {
return this.usersService.update(id, dto);
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseIntPipe) id: number) {
return this.usersService.remove(id);
}
}
3.4 Service:ビジネスロジック層
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private users: User[] = [];
private nextId = 1;
findAll(limit = 20): User[] {
return this.users.slice(0, limit);
}
findOne(id: number): User {
const user = this.users.find((u) => u.id === id);
if (!user) throw new NotFoundException(`user ${id} not found`);
return user;
}
create(dto: CreateUserDto): User {
const user: User = { id: this.nextId++, ...dto };
this.users.push(user);
return user;
}
update(id: number, dto: UpdateUserDto): User {
const user = this.findOne(id);
Object.assign(user, dto);
return user;
}
remove(id: number): void {
const idx = this.users.findIndex((u) => u.id === id);
if (idx < 0) throw new NotFoundException(`user ${id} not found`);
this.users.splice(idx, 1);
}
}
4. DTO とバリデーション(class-validator + ValidationPipe)
4.1 DTO の定義
// src/users/dto/create-user.dto.ts
import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
@Length(1, 50)
name!: string;
@IsEmail()
email!: string;
}
// src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
// 全フィールドを optional 化
export class UpdateUserDto extends PartialType(CreateUserDto) {}
4.2 グローバル ValidationPipe
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTOに無いプロパティを除去
forbidNonWhitelisted: true, // 余計なプロパティは 400
transform: true, // クラスインスタンスへ自動変換
transformOptions: { enableImplicitConversion: true },
}),
);
await app.listen(3000);
}
bootstrap();
必要パッケージ: npm i class-validator class-transformer
4.3 カスタム Pipe(独自のパース処理)
// src/common/pipes/parse-positive-int.pipe.ts
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from '@nestjs/common';
@Injectable()
export class ParsePositiveIntPipe implements PipeTransform<string, number> {
transform(value: string, _metadata: ArgumentMetadata): number {
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
throw new BadRequestException('positive integer required');
}
return n;
}
}
// 使い方
@Get(':id')
findOne(@Param('id', ParsePositiveIntPipe) id: number) {
return this.usersService.findOne(id);
}
5. Dependency Injection の深掘り
5.1 コンストラクタ注入の仕組み
NestJS の DI は Reflect Metadata でクラスの型情報を読み取り、コンテナが自動で組み立てます。@Injectable() が付いているクラスはコンテナの対象です。
@Injectable()
export class MailService {
send(to: string, body: string) { /* ... */ }
}
@Injectable()
export class UsersService {
constructor(private readonly mail: MailService) {} // 自動注入
}
5.2 useClass / useValue / useFactory
// useClass:インターフェースに別実装を差し替える
@Module({
providers: [
{
provide: 'IMailer',
useClass: process.env.NODE_ENV === 'test' ? FakeMailer : RealMailer,
},
],
})
export class MailModule {}
// useValue:定数の注入
@Module({
providers: [
{ provide: 'APP_CONFIG', useValue: { timeoutMs: 5000, retries: 3 } },
],
})
export class ConfigModule {}
// useFactory:非同期に依存するProvider生成
@Module({
providers: [
{
provide: 'DB_CLIENT',
useFactory: async () => {
const client = createDbClient();
await client.connect();
return client;
},
},
],
exports: ['DB_CLIENT'],
})
export class DbModule {}
5.3 トークン注入(@Inject デコレータ)
import { Inject, Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
constructor(
@Inject('DB_CLIENT') private readonly db: DbClient,
@Inject('APP_CONFIG') private readonly cfg: { timeoutMs: number },
) {}
}
5.4 Provider のスコープ
| Scope | 用途 |
|---|---|
| DEFAULT(シングルトン) | 通常のサービス。アプリ起動から終了まで1インスタンス |
| REQUEST | リクエストごとに別インスタンス(リクエストコンテキスト保持) |
| TRANSIENT | 注入する側ごとに別インスタンス |
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
userId?: number;
}
6. Guard / Interceptor / Middleware / Filter(AOP4兄弟)
6.1 Guard:認可・認証ゲート
// src/auth/guards/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest<Request>();
return req.header('x-api-key') === process.env.API_KEY;
}
}
// 適用
@Controller('admin')
@UseGuards(ApiKeyGuard)
export class AdminController { /* ... */ }
6.2 JWT Guard(passport-jwt)
// src/auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
export interface JwtPayload { sub: number; email: string; roles: string[]; }
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET!,
ignoreExpiration: false,
});
}
validate(payload: JwtPayload) {
return { id: payload.sub, email: payload.email, roles: payload.roles };
}
}
// src/auth/guards/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
6.3 Roles Guard(RBAC)
// src/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
// src/auth/guards/roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
ctx.getHandler(),
ctx.getClass(),
]);
if (!required || required.length === 0) return true;
const { user } = ctx.switchToHttp().getRequest();
return required.some((r) => user?.roles?.includes(r));
}
}
// 使い方
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.usersService.remove(id);
}
6.4 Interceptor:ログ・整形・キャッシュ
// src/common/interceptors/logging.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
const req = ctx.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => {
const ms = Date.now() - start;
this.logger.log(`${req.method} ${req.url} ${ms}ms`);
}),
);
}
}
// src/common/interceptors/transform.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T }> {
intercept(_ctx: ExecutionContext, next: CallHandler): Observable<{ data: T }> {
return next.handle().pipe(map((data) => ({ data })));
}
}
6.5 Middleware(Express互換層)
// src/common/middleware/request-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'crypto';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const id = req.header('x-request-id') ?? randomUUID();
res.setHeader('x-request-id', id);
(req as any).requestId = id;
next();
}
}
// AppModule で配線
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
@Module({ /* ... */ })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
6.6 Exception Filter:統一エラーレスポンス
// src/common/filters/all-exceptions.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('Exception');
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
this.logger.error(`${req.method} ${req.url} -> ${status}`, exception as any);
res.status(status).json({
statusCode: status,
path: req.url,
timestamp: new Date().toISOString(),
message,
});
}
}
// main.ts でグローバル登録 app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalInterceptors(new LoggingInterceptor(), new TransformInterceptor());
7. 設定モジュール・DBアダプタ・GraphQL・WebSocket
7.1 ConfigModule(環境変数の型安全アクセス)
// npm i @nestjs/config
// src/app.module.ts
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
cache: true,
}),
/* ... */
],
})
export class AppModule {}
// 利用側
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AppService {
constructor(private readonly config: ConfigService) {}
get dbUrl() {
return this.config.getOrThrow<string>('DATABASE_URL');
}
}
7.2 動的モジュール(forRoot / forFeature)
// 自作の動的モジュール例
import { DynamicModule, Module } from '@nestjs/common';
export interface CacheModuleOptions { ttlMs: number; }
@Module({})
export class CacheModule {
static forRoot(options: CacheModuleOptions): DynamicModule {
return {
module: CacheModule,
global: true,
providers: [{ provide: 'CACHE_OPTIONS', useValue: options }],
exports: ['CACHE_OPTIONS'],
};
}
}
7.3 TypeORM 統合
// npm i @nestjs/typeorm typeorm pg
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
autoLoadEntities: true,
synchronize: false, // 本番は必ず false
}),
TypeOrmModule.forFeature([UserEntity]),
],
})
export class AppModule {}
// src/users/entities/user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn() id!: number;
@Column() name!: string;
@Column({ unique: true }) email!: string;
}
// TypeORM の Repository を Service に注入
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(UserEntity)
private readonly repo: Repository<UserEntity>,
) {}
findAll() { return this.repo.find(); }
create(dto: CreateUserDto) { return this.repo.save(this.repo.create(dto)); }
}
7.4 Prisma 統合
// npm i @prisma/client && npx prisma init
// src/prisma/prisma.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}
// src/users/users.service.ts(Prisma版)
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
findAll() { return this.prisma.user.findMany({ take: 20 }); }
create(dto: CreateUserDto) { return this.prisma.user.create({ data: dto }); }
}
7.5 Mongoose / MongoDB
// npm i @nestjs/mongoose mongoose
import { MongooseModule, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
@Schema({ timestamps: true })
export class Post {
@Prop({ required: true }) title!: string;
@Prop() body?: string;
}
export type PostDocument = HydratedDocument<Post>;
export const PostSchema = SchemaFactory.createForClass(Post);
@Module({
imports: [
MongooseModule.forRoot(process.env.MONGO_URL!),
MongooseModule.forFeature([{ name: Post.name, schema: PostSchema }]),
],
})
export class PostsModule {}
7.6 GraphQL(Code First)
// npm i @nestjs/graphql @nestjs/apollo @apollo/server graphql
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'schema.gql',
sortSchema: true,
playground: true,
}),
],
})
export class AppModule {}
// src/users/user.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class User {
@Field(() => Int) id!: number;
@Field() name!: string;
@Field() email!: string;
}
// src/users/users.resolver.ts
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UsersService } from './users.service';
import { User } from './user.model';
import { CreateUserDto } from './dto/create-user.dto';
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Query(() => [User])
users() { return this.usersService.findAll(); }
@Query(() => User)
user(@Args('id', { type: () => Int }) id: number) {
return this.usersService.findOne(id);
}
@Mutation(() => User)
createUser(@Args('input') input: CreateUserDto) {
return this.usersService.create(input);
}
}
7.7 WebSocket Gateway(Socket.IO)
// npm i @nestjs/websockets @nestjs/platform-socket.io socket.io
import {
ConnectedSocket,
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway {
@WebSocketServer() server!: Server;
@SubscribeMessage('message')
onMessage(@MessageBody() data: string, @ConnectedSocket() client: Socket) {
this.server.emit('message', { from: client.id, data });
}
}
8. CQRS / Microservices / 高度なパターン
8.1 CQRS パターン(Command/Query 分離)
// npm i @nestjs/cqrs
import { CqrsModule, CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
export class CreateUserCommand {
constructor(public readonly name: string, public readonly email: string) {}
}
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
constructor(private readonly usersService: UsersService) {}
async execute(cmd: CreateUserCommand) {
return this.usersService.create({ name: cmd.name, email: cmd.email });
}
}
@Controller('users')
export class UsersController {
constructor(private readonly commandBus: CommandBus) {}
@Post()
create(@Body() dto: CreateUserDto) {
return this.commandBus.execute(new CreateUserCommand(dto.name, dto.email));
}
}
8.2 Microservices(NATS / RabbitMQ / Redis)
// マイクロサービス側の bootstrap
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{
transport: Transport.NATS,
options: { servers: ['nats://localhost:4222'] },
},
);
await app.listen();
}
bootstrap();
// メッセージハンドラ
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
@Controller()
export class UsersMicroController {
@MessagePattern({ cmd: 'user.find' })
find(@Payload() id: number) {
return { id, name: 'sample' };
}
}
// クライアント側
import { ClientsModule, Transport, ClientProxy } from '@nestjs/microservices';
@Module({
imports: [
ClientsModule.register([
{
name: 'USER_SERVICE',
transport: Transport.NATS,
options: { servers: ['nats://localhost:4222'] },
},
]),
],
})
export class GatewayModule {}
@Injectable()
export class UserGatewayService {
constructor(@Inject('USER_SERVICE') private readonly client: ClientProxy) {}
find(id: number) { return this.client.send({ cmd: 'user.find' }, id); }
}
8.3 スケジューラ(@nestjs/schedule)
// npm i @nestjs/schedule
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression, Interval } from '@nestjs/schedule';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
@Cron(CronExpression.EVERY_HOUR)
handleHourly() { this.logger.log('hourly job'); }
@Interval(5000)
handleInterval() { this.logger.debug('every 5s'); }
}
9. テスト(Jest + Supertest + Test Module)
9.1 Service のユニットテスト
// src/users/users.service.spec.ts
import { Test } from '@nestjs/testing';
import { UsersService } from './users.service';
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [UsersService],
}).compile();
service = moduleRef.get(UsersService);
});
it('creates and finds a user', () => {
const u = service.create({ name: 'tarou', email: 't@example.com' });
expect(service.findOne(u.id)).toEqual(u);
});
it('throws NotFound when missing', () => {
expect(() => service.findOne(9999)).toThrow(/not found/);
});
});
9.2 Controller のテスト(モック注入)
import { Test } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('UsersController', () => {
let controller: UsersController;
const mockService = {
findAll: jest.fn().mockReturnValue([{ id: 1, name: 'a', email: 'a@a.io' }]),
};
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [UsersController],
providers: [{ provide: UsersService, useValue: mockService }],
}).compile();
controller = moduleRef.get(UsersController);
});
it('returns users', () => {
expect(controller.findAll()).toEqual([{ id: 1, name: 'a', email: 'a@a.io' }]);
});
});
9.3 e2e テスト(Supertest)
// test/users.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Users (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
afterAll(async () => { await app.close(); });
it('POST /users -> 201', async () => {
const res = await request(app.getHttpServer())
.post('/users')
.send({ name: 'taro', email: 't@example.com' })
.expect(201);
expect(res.body.id).toBeDefined();
});
it('POST /users with invalid email -> 400', async () => {
await request(app.getHttpServer())
.post('/users')
.send({ name: 'taro', email: 'INVALID' })
.expect(400);
});
});
10. OpenAPI(Swagger)・Docker・運用
10.1 Swagger ドキュメント自動生成
// npm i @nestjs/swagger
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('My API')
.setDescription('NestJS API documentation')
.setVersion('1.0')
.addBearerAuth()
.build();
const doc = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, doc);
await app.listen(3000);
}
bootstrap();
// DTO に注釈をつけると自動で型がドキュメントに出る
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString } from 'class-validator';
export class CreateUserDto {
@ApiProperty({ example: '山田太郎' })
@IsString() name!: string;
@ApiProperty({ example: 'taro@example.com' })
@IsEmail() email!: string;
}
10.2 Dockerfile(マルチステージ)
# Dockerfile FROM node:22-bookworm-slim AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM node:22-bookworm-slim AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:22-bookworm-slim AS run WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/dist ./dist COPY --from=deps /app/node_modules ./node_modules COPY package*.json ./ EXPOSE 3000 CMD ["node", "dist/main.js"]
# docker-compose.yml(API + Postgres)
services:
api:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://app:app@db:5432/app
JWT_SECRET: changeme
depends_on: [db]
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports: ["5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:
10.3 ヘルスチェック(@nestjs/terminus)
// npm i @nestjs/terminus
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, HttpHealthIndicator } from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.http.pingCheck('self', 'http://localhost:3000'),
]);
}
}
10.4 グレースフルシャットダウン
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // SIGTERM 時に onModuleDestroy / onApplicationShutdown を呼ぶ
await app.listen(3000);
}
bootstrap();
// onApplicationShutdown を実装
@Injectable()
export class PrismaService extends PrismaClient
implements OnApplicationShutdown {
async onApplicationShutdown() { await this.$disconnect(); }
}
11. NestJS のはまりどころとアンチパターン
11.1 「全部 AppModule に書く」は事故
機能ごとに UsersModule / AuthModule / PostsModule と切り分け、依存関係を明示するのが鉄則です。AppModule にすべて詰めるとテストでモック差し替えができなくなります。
11.2 ビジネスロジックを Controller に書かない
Controller は HTTP の入出力境界 に専念し、計算・DB・外部API呼び出しは Service へ。これによりユニットテストが書きやすくなり、Microservices 化したい時に Controller 差し替えで対応できます。
11.3 ValidationPipe の transform: true を忘れる
transform: true を入れないと、@Body() dto: CreateUserDto が単なる plain object のままになり、instanceof CreateUserDto が false になります。クラスメソッド経由のロジックや継承の検出が壊れるので必須設定です。
11.4 循環依存(Circular Dependency)
// AモジュールがBを、BがAを使う場合
import { forwardRef, Module } from '@nestjs/common';
@Module({
imports: [forwardRef(() => BModule)],
/* ... */
})
export class AModule {}
// 同様にコンストラクタ側も
constructor(
@Inject(forwardRef(() => BService))
private readonly b: BService,
) {}
とはいえ 循環依存は設計の警告サイン。共通レイヤを切り出すなどリファクタを優先しましょう。
11.5 REQUEST スコープを乱用しない
Scope.REQUEST はリクエストごとにインスタンスが作られるため、依存ツリー全体がリクエストスコープに引きずられパフォーマンスが落ちます。基本はシングルトン、本当に必要な場面のみ REQUEST に。
12. Express / Hono / NestJS の使い分け早見表
| 観点 | Express 5 | Hono | NestJS 10 |
|---|---|---|---|
| 書き味 | 関数ベース・自由 | 関数チェーン・最小 | クラス + デコレータ |
| DI | 無し(自前) | 無し | 標準装備 |
| ランタイム | Node のみ | Node/Bun/Workers/Deno | Node(Express/Fastify) |
| 規模 | 小〜中 | 小〜中(Edge) | 中〜大 |
| テスト容易性 | △ | ○ | ◎ |
| マイクロサービス | 自前 | 自前 | 標準装備 |
| 学習コスト | 低 | 低 | 中(デコレータ・DI 理解必要) |
判断基準: 3人以上のチームで6か月以上運用するなら NestJS。1人で来週リリースなら Express か Hono。Edge / Cloudflare Workers なら Hono 一択。
まとめ:NestJS は「構造を強制してくれる Express」
- Module / Controller / Service / Provider の4要素を理解すれば設計が固まる
- DI コンテナ によりテストでのモック差し替えが容易
- Pipe / Guard / Interceptor / Filter / Middleware で横断的関心事(認証・ログ・例外)を分離
- TypeORM / Prisma / Mongoose / GraphQL / WebSocket / CQRS / Microservices までモジュールが揃う
- Jest + Supertest によるユニット/e2eテストが標準
- 本番運用は Swagger + Docker + Terminus + Graceful Shutdown で堅牢化
Express で書き散らかしたコードベースが手に負えなくなってきたら、それは NestJS への移行サインです。デコレータと DI の文化を一度身につけると、Angular / Spring / .NET といった他のエンタープライズフレームワークとの行き来もスムーズになります。エンジニアキャリアを長く伸ばす意味でも、NestJS を一度しっかり通っておく価値は大きいでしょう。
もし「独学だと挫折しそう」「実務でレビューを受けながら身に着けたい」と感じたら、メンター付きのオンラインスクールを検討するのも一手です。バックエンド/TypeScript/フレームワークまで体系的に学べる教材は、独学の時間を大幅に圧縮してくれます。

コメント