NestJS でのモジュール設計パターン:アプリをスケーラブルに保つ方法

Node.js でサーバーサイドアプリケーションを開発する際、NestJS は非常に強力なフレームワークとして注目を集めています。しかし、アプリケーションが成長するにつれて、適切なモジュール設計なしでは保守性や拡張性に大きな問題が生じてしまいます。
本記事では、NestJS におけるモジュール設計の基本から、スケーラブルなアプリケーション構築のための実践的なパターンまでを詳しく解説いたします。小規模なアプリケーションから始まり、チーム開発や企業レベルの大規模システムまで対応できる設計手法をご紹介します。
背景
NestJS モジュールシステムの基本概念
NestJS のモジュールシステムは、アプリケーションを論理的な単位で分割し、関心の分離を実現するための中核的な仕組みです。各モジュールは@Module()
デコレータによって定義され、プロバイダー、コントローラー、インポートとエクスポートを管理します。
最もシンプルなモジュールの定義から見てみましょう。
typescriptimport { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [], // 他のモジュールをインポート
controllers: [AppController], // HTTPリクエストを処理するコントローラー
providers: [AppService], // 依存性注入されるプロバイダー
exports: [], // 他のモジュールで使用可能にするプロバイダー
})
export class AppModule {}
モジュールシステムの核となる概念を整理すると以下の表のようになります。
# | 要素 | 役割 | 使用例 |
---|---|---|---|
1 | imports | 他のモジュールの機能を取り込む | TypeOrmModule 、ConfigModule |
2 | controllers | HTTP リクエストの処理とルーティング | REST API、GraphQL resolver |
3 | providers | ビジネスロジック、データアクセス、ユーティリティ | Service、Repository、Helper |
4 | exports | 他のモジュールで利用可能にするプロバイダー | 共通サービス、設定情報 |
NestJS のモジュールシステムは、依存性注入コンテナと密接に統合されているため、各モジュール内でのサービスの生存期間やスコープを適切に管理できます。
小規模アプリケーションから大規模アプリケーションへの変遷
アプリケーションの成長に伴い、モジュール構造も進化させる必要があります。この変遷について段階的に見てみましょう。
小規模なアプリケーション(~ 1000 行)では、単一の AppModule ですべての機能を管理することも可能です。
typescript// 小規模アプリケーションの例
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
// ... 設定
}),
TypeOrmModule.forFeature([User, Post]),
],
controllers: [
UserController,
PostController,
AuthController,
],
providers: [UserService, PostService, AuthService],
})
export class AppModule {}
しかし、機能が増えるにつれて以下のような問題が顕在化します。
アプリケーションの成長段階を図で示すと以下のようになります。
mermaidflowchart LR
small[小規模<br/>~1000行] -->|機能追加| medium[中規模<br/>~5000行]
medium -->|チーム拡大| large[大規模<br/>5000行以上]
subgraph 小規模特徴
single[単一モジュール]
simple[シンプルな構造]
end
subgraph 中規模特徴
feature[機能別モジュール]
shared[共通モジュール]
end
subgraph 大規模特徴
domain[ドメイン分割]
micro[マイクロサービス志向]
end
中規模(1000 ~ 5000 行)になると、機能別にモジュールを分割する必要性が高まります。大規模(5000 行以上)では、ドメイン駆動設計やマイクロサービス的なアプローチが重要になります。
モジュール設計が与える影響
適切なモジュール設計は、開発効率、保守性、テスタビリティに大きな影響を与えます。
開発効率への影響
モジュール分割により、開発者は関心のある範囲に集中できるようになります。例えば、ユーザー管理機能を開発している際に、決済処理のコードを意識する必要がなくなります。
typescript// 良い例:関心が分離されたモジュール構造
@Module({
imports: [DatabaseModule], // データベース接続のみ依存
controllers: [UserController],
providers: [UserService, UserRepository],
exports: [UserService], // 必要最小限をエクスポート
})
export class UserModule {}
保守性への影響
モジュール境界が明確になることで、変更の影響範囲を限定できます。新機能追加時や既存機能の修正時に、他の部分への波及効果を最小限に抑えられます。
テスタビリティの向上
モジュール単位でのテストが可能になり、モックやスタブの作成が容易になります。これにより、単体テストから結合テストまで、効率的なテスト戦略を構築できます。
課題
単一モジュールアプリケーションの限界
多くの NestJS プロジェクトは、シンプルな AppModule 一つから始まります。しかし、プロジェクトが成長するにつれて、この単一モジュール構造は深刻な問題を引き起こします。
コードの可読性低下
すべての機能が一つのモジュールに集約されることで、関連性のないコードが混在し、開発者が全体を把握するのが困難になります。
typescript// 問題のある単一モジュールの例
@Module({
imports: [
TypeOrmModule.forRoot(/* 設定 */),
TypeOrmModule.forFeature([
User,
Post,
Comment,
Category,
Tag, // ユーザー関連
Product,
Order,
Payment,
Shipping, // ECサイト関連
Admin,
Role,
Permission, // 管理者関連
Newsletter,
Email,
Notification, // 通知関連
]),
],
controllers: [
UserController,
PostController,
CommentController,
ProductController,
OrderController,
PaymentController,
AdminController,
NewsletterController, // 20個以上のコントローラー
],
providers: [
UserService,
PostService,
CommentService,
ProductService,
OrderService,
PaymentService,
AdminService,
NewsletterService, // 20個以上のサービス
],
})
export class AppModule {}
この構造では、どのサービスがどのコントローラーと関連しているかが不明確で、新しい開発者がコードを理解するのに長時間を要してしまいます。
責任の境界が曖昧
単一モジュール内では、サービス間の依存関係が複雑に絡み合い、どのサービスがどの責任を持つかが不明確になります。
依存関係の複雑化
アプリケーションが成長すると、サービス間の依存関係が複雑になり、以下のような問題が発生します。
mermaidgraph TD
UserService -->|依存| EmailService
UserService -->|依存| PaymentService
UserService -->|依存| NotificationService
PaymentService -->|依存| UserService
PaymentService -->|依存| OrderService
EmailService -->|依存| UserService
OrderService -->|依存| UserService
OrderService -->|依存| PaymentService
NotificationService -->|依存| UserService
NotificationService -->|依存| EmailService
style UserService fill:#ff9999
style PaymentService fill:#ff9999
style EmailService fill:#ff9999
上図は典型的な循環依存の発生例を示しています。UserService、PaymentService、EmailService が相互に依存し合い、変更時の影響範囲が予測困難になっています。
循環依存の問題
NestJS では循環依存を検出してエラーを投げますが、このエラーが発生した時点で設計の見直しが必要になります。
typescript// 循環依存が発生する問題のあるコード例
@Injectable()
export class UserService {
constructor(private paymentService: PaymentService) {} // PaymentServiceに依存
async createUser(userData: CreateUserDto) {
// ユーザー作成処理
await this.paymentService.setupDefaultPayment(user.id);
}
}
@Injectable()
export class PaymentService {
constructor(private userService: UserService) {} // UserServiceに依存(循環依存!)
async setupDefaultPayment(userId: string) {
const user = await this.userService.findById(userId);
// 決済設定処理
}
}
テストの困難さ
単一モジュール構造では、テストの作成と実行が困難になります。
モックの複雑さ
すべてのサービスが密結合しているため、一つのサービスをテストするために多数の依存関係をモックする必要があります。
typescript// テストが困難な例
describe('UserService', () => {
let userService: UserService;
let mockPaymentService: jest.Mocked<PaymentService>;
let mockEmailService: jest.Mocked<EmailService>;
let mockNotificationService: jest.Mocked<NotificationService>;
let mockOrderService: jest.Mocked<OrderService>;
// さらに10個以上のモックが必要...
beforeEach(async () => {
// 大量のモック設定
const module = await Test.createTestingModule({
providers: [
UserService,
{
provide: PaymentService,
useValue: mockPaymentService,
},
{
provide: EmailService,
useValue: mockEmailService,
},
// 10個以上のモック設定...
],
}).compile();
userService = module.get<UserService>(UserService);
});
});
テスト実行時間の増大
すべてのテストで大量の依存関係を初期化する必要があるため、テストの実行時間が大幅に増加します。
開発チーム間の競合問題
複数の開発者が同じモジュールを編集することで発生する問題について説明します。
Git の競合頻発
単一モジュールファイルを複数の開発者が同時に編集することで、マージ競合が頻繁に発生します。
typescript// app.module.ts での競合例
@Module({
imports: [
/* ... */
],
controllers: [
UserController,
PostController,
// 開発者Aが追加 --> CommentController,
// 開発者Bが追加 --> CategoryController,
],
providers: [
UserService,
PostService,
// 開発者Aが追加 --> CommentService,
// 開発者Bが追加 --> CategoryService,
],
})
export class AppModule {}
開発速度の低下
一人の開発者がモジュールファイルを編集している間、他の開発者は待機しなければならない状況が発生します。これにより、並行開発が困難になり、チーム全体の開発効率が低下します。
コードレビューの複雑化
一つのプルリクエストに複数の機能が混在することで、レビュワーが変更内容を理解するのが困難になります。
解決策
ドメイン駆動設計に基づくモジュール分割
ドメイン駆動設計(DDD)の考え方を取り入れることで、ビジネス要件に沿った自然なモジュール分割が可能になります。
ドメインの識別
まず、アプリケーション内のドメインを識別します。EC サイトを例にすると、以下のようなドメインに分割できます。
mermaidgraph TB
subgraph ECサイトドメイン
user[ユーザードメイン<br/>・会員登録<br/>・プロフィール管理<br/>・認証]
product[商品ドメイン<br/>・商品管理<br/>・在庫管理<br/>・カテゴリ]
order[注文ドメイン<br/>・注文処理<br/>・決済処理<br/>・配送管理]
support[サポートドメイン<br/>・お問い合わせ<br/>・レビュー<br/>・FAQ]
end
user -.-> order
product -.-> order
user -.-> support
product -.-> support
各ドメインは独立性を保ちながら、必要に応じて他のドメインと連携します。
ドメインモジュールの実装
各ドメインを独立したモジュールとして実装します。
typescript// ユーザードメインモジュール
@Module({
imports: [
TypeOrmModule.forFeature([User, UserProfile]),
ConfigModule, // 設定情報のみ依存
],
controllers: [UserController, AuthController],
providers: [
UserService,
UserRepository,
AuthService,
PasswordHashService,
],
exports: [UserService], // 他ドメインで必要な機能のみエクスポート
})
export class UserModule {}
typescript// 商品ドメインモジュール
@Module({
imports: [
TypeOrmModule.forFeature([
Product,
Category,
Inventory,
]),
ConfigModule,
],
controllers: [ProductController, CategoryController],
providers: [
ProductService,
ProductRepository,
CategoryService,
InventoryService,
],
exports: [ProductService], // 注文ドメインで商品情報が必要
})
export class ProductModule {}
ドメイン間の通信
ドメイン間の通信は、明確に定義されたインターフェースを通じて行います。
typescript// ドメイン間通信のためのインターフェース
export interface IUserService {
findById(id: string): Promise<User>;
validateUser(
email: string,
password: string
): Promise<boolean>;
}
export interface IProductService {
findById(id: string): Promise<Product>;
updateInventory(
productId: string,
quantity: number
): Promise<void>;
}
機能別モジュール設計パターン
機能ごとにモジュールを分割することで、開発者が担当する範囲を明確にし、並行開発を促進します。
フィーチャーモジュールパターン
各機能を独立したモジュールとして設計します。
typescript// ユーザー管理機能モジュール
@Module({
imports: [
TypeOrmModule.forFeature([User]),
SharedModule, // 共通機能
],
controllers: [UserController],
providers: [UserService, UserRepository],
exports: [UserService],
})
export class UserFeatureModule {}
typescript// 認証機能モジュール
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: { expiresIn: '1h' },
}),
}),
UserFeatureModule, // ユーザー情報が必要
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthFeatureModule {}
モジュールの階層化
機能モジュールをさらに細かく分割し、階層構造を作ることができます。
mermaidgraph TD
AppModule -->|import| UserModule
AppModule -->|import| ProductModule
AppModule -->|import| OrderModule
UserModule -->|import| UserProfileModule
UserModule -->|import| UserAuthModule
UserModule -->|import| UserPreferenceModule
ProductModule -->|import| ProductCatalogModule
ProductModule -->|import| ProductInventoryModule
ProductModule -->|import| ProductReviewModule
OrderModule -->|import| OrderProcessingModule
OrderModule -->|import| PaymentModule
OrderModule -->|import| ShippingModule
共通モジュールの設計方針
アプリケーション全体で共有される機能は、共通モジュールとして切り出します。
コアモジュール
アプリケーション全体で一度だけインスタンス化される必要がある機能を管理します。
typescript@Global() // グローバルモジュールとして宣言
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV}`,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_NAME'),
autoLoadEntities: true,
synchronize:
configService.get('NODE_ENV') === 'development',
}),
}),
],
providers: [
LoggerService,
CacheService,
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
],
exports: [LoggerService, CacheService],
})
export class CoreModule {
// コアモジュールが複数回インポートされることを防ぐ
constructor(
@Optional() @SkipSelf() parentModule: CoreModule
) {
if (parentModule) {
throw new Error(
'CoreModule is already loaded. Import it in the AppModule only.'
);
}
}
}
シェアードモジュール
複数のモジュールで共有されるユーティリティ機能を提供します。
typescript@Module({
imports: [HttpModule],
providers: [
ValidationService,
DateUtilService,
FileUploadService,
EmailService,
NotificationService,
],
exports: [
ValidationService,
DateUtilService,
FileUploadService,
EmailService,
NotificationService,
HttpModule, // HttpModuleも再エクスポート
],
})
export class SharedModule {}
モジュール間通信の最適化
モジュール間の通信を効率化し、結合度を下げるための手法を説明します。
イベントドリブンアーキテクチャ
モジュール間の直接的な依存関係を避け、イベントを通じて通信します。
typescript// イベント定義
export class UserCreatedEvent {
constructor(
public readonly userId: string,
public readonly email: string,
public readonly createdAt: Date
) {}
}
typescript// イベント発行側(UserModule)
@Injectable()
export class UserService {
constructor(private eventEmitter: EventEmitter2) {}
async createUser(userData: CreateUserDto): Promise<User> {
const user = await this.userRepository.save(userData);
// イベントを発行(他モジュールとの直接依存を避ける)
this.eventEmitter.emit(
'user.created',
new UserCreatedEvent(
user.id,
user.email,
user.createdAt
)
);
return user;
}
}
typescript// イベント受信側(NotificationModule)
@Injectable()
export class NotificationService {
@OnEvent('user.created')
async handleUserCreated(event: UserCreatedEvent) {
// ウェルカムメールの送信
await this.emailService.sendWelcomeEmail(
event.email,
`Welcome! Your account ${event.userId} has been created.`
);
}
}
メッセージキューの活用
大量のデータ処理や非同期処理には、メッセージキューを活用します。
typescript// BullMQを使用したジョブキュー
@Module({
imports: [
BullModule.forRoot({
redis: {
host: 'localhost',
port: 6379,
},
}),
BullModule.registerQueue({
name: 'email',
}),
BullModule.registerQueue({
name: 'image-processing',
}),
],
providers: [EmailProcessor, ImageProcessor],
})
export class QueueModule {}
図で理解できる要点:
- ドメイン分割により各モジュールの責任範囲が明確になる
- イベントドリブン設計で疎結合なモジュール間通信が実現される
- 共通モジュールの適切な設計で重複コードを排除できる
具体例
ユーザー管理モジュールの設計
実際のプロジェクトで使用できるユーザー管理モジュールの設計例をご紹介します。
ディレクトリ構造
sqlsrc/modules/user/
├── dto/
│ ├── create-user.dto.ts
│ ├── update-user.dto.ts
│ └── user-query.dto.ts
├── entities/
│ ├── user.entity.ts
│ └── user-profile.entity.ts
├── repositories/
│ └── user.repository.ts
├── services/
│ ├── user.service.ts
│ └── user-validation.service.ts
├── controllers/
│ └── user.controller.ts
├── interfaces/
│ └── user-service.interface.ts
└── user.module.ts
エンティティの定義
ユーザーエンティティから始めましょう。
typescriptimport {
Entity,
PrimaryGeneratedColumn,
Column,
OneToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserProfile } from './user-profile.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@Column({ default: true })
isActive: boolean;
@Column({ nullable: true })
emailVerifiedAt: Date;
@OneToOne(() => UserProfile, (profile) => profile.user, {
cascade: true,
})
profile: UserProfile;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
サービス層の実装
ビジネスロジックを担当するサービスクラスです。
typescriptimport {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import { CreateUserDto } from '../dto/create-user.dto';
import { UpdateUserDto } from '../dto/update-user.dto';
import { UserValidationService } from './user-validation.service';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly userValidationService: UserValidationService
) {}
async create(
createUserDto: CreateUserDto
): Promise<User> {
// バリデーション処理
await this.userValidationService.validateCreateUser(
createUserDto
);
// 重複チェック
const existingUser = await this.findByEmail(
createUserDto.email
);
if (existingUser) {
throw new ConflictException('Email already exists');
}
const user = this.userRepository.create(createUserDto);
return this.userRepository.save(user);
}
async findById(id: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { id },
relations: ['profile'],
});
if (!user) {
throw new NotFoundException(
`User with ID ${id} not found`
);
}
return user;
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({
where: { email },
relations: ['profile'],
});
}
async update(
id: string,
updateUserDto: UpdateUserDto
): Promise<User> {
const user = await this.findById(id);
// 部分的な更新のバリデーション
await this.userValidationService.validateUpdateUser(
updateUserDto
);
Object.assign(user, updateUserDto);
return this.userRepository.save(user);
}
async softDelete(id: string): Promise<void> {
const user = await this.findById(id);
user.isActive = false;
await this.userRepository.save(user);
}
}
コントローラーの実装
HTTP リクエストを処理するコントローラーです。
typescriptimport {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { UserService } from '../services/user.service';
import { CreateUserDto } from '../dto/create-user.dto';
import { UpdateUserDto } from '../dto/update-user.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
@ApiTags('users')
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@ApiOperation({ summary: 'Create new user' })
@ApiResponse({
status: 201,
description: 'User created successfully',
})
@ApiResponse({
status: 409,
description: 'Email already exists',
})
async create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user by ID' })
@ApiResponse({ status: 200, description: 'User found' })
@ApiResponse({
status: 404,
description: 'User not found',
})
async findOne(@Param('id') id: string) {
return this.userService.findById(id);
}
@Patch(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update user' })
async update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
@Request() req
) {
// 自分自身のアカウントのみ更新可能
if (req.user.id !== id) {
throw new ForbiddenException(
'Cannot update other user accounts'
);
}
return this.userService.update(id, updateUserDto);
}
}
モジュールの統合
各コンポーネントをモジュールとして統合します。
typescriptimport { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UserProfile } from './entities/user-profile.entity';
import { UserController } from './controllers/user.controller';
import { UserService } from './services/user.service';
import { UserValidationService } from './services/user-validation.service';
import { SharedModule } from '../shared/shared.module';
@Module({
imports: [
TypeOrmModule.forFeature([User, UserProfile]),
SharedModule, // バリデーションやユーティリティ機能
],
controllers: [UserController],
providers: [UserService, UserValidationService],
exports: [
UserService, // 他のモジュールで利用可能
],
})
export class UserModule {}
認証・認可モジュールの実装
セキュリティを担当する認証・認可モジュールの実装例です。
JWT 戦略の実装
typescriptimport {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UserService } from '../../user/services/user.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(
Strategy
) {
constructor(
private readonly configService: ConfigService,
private readonly userService: UserService
) {
super({
jwtFromRequest:
ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: any) {
const user = await this.userService.findById(
payload.sub
);
if (!user || !user.isActive) {
throw new UnauthorizedException(
'User not found or inactive'
);
}
return user;
}
}
認証サービス
typescriptimport {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UserService } from '../../user/services/user.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService
) {}
async validateUser(
email: string,
password: string
): Promise<any> {
const user = await this.userService.findByEmail(email);
if (
user &&
(await bcrypt.compare(password, user.password))
) {
const { password: _, ...result } = user;
return result;
}
return null;
}
async login(user: any) {
const payload = { email: user.email, sub: user.id };
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
email: user.email,
profile: user.profile,
},
};
}
async register(createUserDto: any) {
// パスワードをハッシュ化
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(
createUserDto.password,
saltRounds
);
const user = await this.userService.create({
...createUserDto,
password: hashedPassword,
});
return this.login(user);
}
}
認証モジュール
typescriptimport { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import {
ConfigModule,
ConfigService,
} from '@nestjs/config';
import { AuthService } from './services/auth.service';
import { AuthController } from './controllers/auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { UserModule } from '../user/user.module';
@Module({
imports: [
UserModule, // ユーザー情報が必要
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: {
expiresIn: configService.get(
'JWT_EXPIRES_IN',
'1h'
),
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
データベースモジュールの分離
データベース接続とエンティティ管理を分離したモジュール設計です。
データベース設定モジュール
typescriptimport { Module, Global } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
ConfigModule,
ConfigService,
} from '@nestjs/config';
import { User } from '../user/entities/user.entity';
import { UserProfile } from '../user/entities/user-profile.entity';
import { Product } from '../product/entities/product.entity';
import { Order } from '../order/entities/order.entity';
@Global()
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_NAME'),
entities: [User, UserProfile, Product, Order], // エンティティを集約
synchronize:
configService.get('NODE_ENV') === 'development',
logging:
configService.get('NODE_ENV') === 'development',
// 接続プール設定
extra: {
connectionLimit: 10,
acquireTimeout: 60000,
timeout: 60000,
},
}),
}),
],
})
export class DatabaseModule {}
リポジトリパターンの活用
カスタムリポジトリを使用してデータアクセス層を抽象化します。
typescriptimport { Injectable } from '@nestjs/common';
import { Repository, DataSource } from 'typeorm';
import { User } from '../entities/user.entity';
@Injectable()
export class UserRepository extends Repository<User> {
constructor(private dataSource: DataSource) {
super(User, dataSource.createEntityManager());
}
// カスタムクエリメソッド
async findActiveUsers(): Promise<User[]> {
return this.createQueryBuilder('user')
.where('user.isActive = :isActive', {
isActive: true,
})
.leftJoinAndSelect('user.profile', 'profile')
.orderBy('user.createdAt', 'DESC')
.getMany();
}
async findByEmailDomain(domain: string): Promise<User[]> {
return this.createQueryBuilder('user')
.where('user.email LIKE :domain', {
domain: `%@${domain}`,
})
.getMany();
}
// 統計情報取得
async getUserStatistics() {
return this.createQueryBuilder('user')
.select([
'COUNT(*) as totalUsers',
'COUNT(CASE WHEN user.isActive = true THEN 1 END) as activeUsers',
'COUNT(CASE WHEN user.emailVerifiedAt IS NOT NULL THEN 1 END) as verifiedUsers',
])
.getRawOne();
}
}
設定管理モジュールの構築
環境設定や外部サービス設定を管理するモジュールです。
設定スキーマ定義
typescriptimport * as Joi from 'joi';
export const configValidationSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.required(),
PORT: Joi.number().default(3000),
// データベース設定
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(3306),
DB_USERNAME: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_NAME: Joi.string().required(),
// JWT設定
JWT_SECRET: Joi.string().required(),
JWT_EXPIRES_IN: Joi.string().default('1h'),
// 外部サービス設定
REDIS_HOST: Joi.string().default('localhost'),
REDIS_PORT: Joi.number().default(6379),
SMTP_HOST: Joi.string().required(),
SMTP_PORT: Joi.number().default(587),
SMTP_USER: Joi.string().required(),
SMTP_PASS: Joi.string().required(),
});
設定モジュール
typescriptimport { Module, Global } from '@nestjs/common';
import {
ConfigModule,
ConfigService,
} from '@nestjs/config';
import { configValidationSchema } from './config.validation';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [
`.env.${process.env.NODE_ENV}`,
'.env.local',
'.env',
],
validationSchema: configValidationSchema,
validationOptions: {
allowUnknown: true,
abortEarly: true,
},
}),
],
providers: [
{
provide: 'DATABASE_CONFIG',
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_NAME'),
}),
},
{
provide: 'JWT_CONFIG',
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
expiresIn: configService.get('JWT_EXPIRES_IN'),
}),
},
],
exports: ['DATABASE_CONFIG', 'JWT_CONFIG', ConfigService],
})
export class AppConfigModule {}
API バージョニング対応
API のバージョン管理を考慮したモジュール設計です。
typescript// v1/user/user.controller.ts
@Controller({
path: 'users',
version: '1',
})
export class UserV1Controller {
constructor(private readonly userService: UserService) {}
@Get()
async findAll() {
// V1 固有の実装
const users = await this.userService.findAll();
return users.map((user) => ({
id: user.id,
email: user.email,
name:
user.profile?.firstName +
' ' +
user.profile?.lastName,
}));
}
}
// v2/user/user.controller.ts
@Controller({
path: 'users',
version: '2',
})
export class UserV2Controller {
constructor(private readonly userService: UserService) {}
@Get()
async findAll() {
// V2 では詳細情報も含める
const users = await this.userService.findAll();
return users.map((user) => ({
id: user.id,
email: user.email,
profile: {
firstName: user.profile?.firstName,
lastName: user.profile?.lastName,
avatar: user.profile?.avatar,
},
metadata: {
createdAt: user.createdAt,
lastLogin: user.lastLoginAt,
},
}));
}
}
バージョン別モジュール構成図:
mermaidgraph TD
AppModule --> ApiV1Module
AppModule --> ApiV2Module
ApiV1Module --> UserV1Module
ApiV1Module --> ProductV1Module
ApiV2Module --> UserV2Module
ApiV2Module --> ProductV2Module
UserV1Module --> UserService
UserV2Module --> UserService
ProductV1Module --> ProductService
ProductV2Module --> ProductService
図で理解できる要点:
- 各モジュールが明確な責任範囲を持つ単一責任の原則を実現
- リポジトリパターンでデータアクセス層を抽象化
- バージョニング対応で後方互換性を維持しながら機能拡張が可能
まとめ
NestJS におけるモジュール設計は、アプリケーションのスケーラビリティと保守性を決定する重要な要素です。本記事では、基本的なモジュール設計から、実際のプロダクション環境で使用できる高度な設計パターンまでを詳しく解説いたしました。
効果的なモジュール設計のポイント
適切なモジュール分割により、以下のような具体的なメリットを得ることができます。開発効率については、担当者が関心のある機能に集中でき、並行開発が可能になります。保守性においては、変更の影響範囲が限定され、デバッグやトラブルシューティングが容易になります。
スケーラビリティを実現する設計原則
ドメイン駆動設計に基づくモジュール分割、機能別モジュール設計、共通モジュールの適切な活用により、大規模なアプリケーションでも管理しやすい構造を維持できます。特に、モジュール間の疎結合を保つことで、将来的なマイクロサービスへの移行も容易になります。
継続的な改善の重要性
モジュール設計は一度決めたら終わりではありません。アプリケーションの成長に伴い、定期的な見直しと改善を行うことが重要です。テストカバレッジの向上、依存関係の可視化、パフォーマンス監視を通じて、設計の品質を継続的に向上させましょう。
本記事で紹介した設計パターンを参考に、プロジェクトの要件に最適なモジュール構造を構築し、長期的に保守しやすい NestJS アプリケーションを開発してください。
関連リンク
- article
NestJS でのモジュール設計パターン:アプリをスケーラブルに保つ方法
- article
【入門】NestJS とは?初心者が最初に知っておくべき基本概念と特徴
- article
Prisma と NestJS を組み合わせたモダン API 設計
- article
TypeScript で学ぶミドルウェア設計パターン:Express・NestJS の応用例
- article
【対処法】Next.js開発で発生するハイドレーションエラーの原因と解決策
- article
NestJSでバリデーションエラーをログ出力する設定
- article
NestJS でのモジュール設計パターン:アプリをスケーラブルに保つ方法
- article
Vitest で React コンポーネントをテストする方法
- article
Nginx 入門:5 分でわかる高速 Web サーバーの基本と強み
- article
WordPress 入門:5 分で立ち上げる最新サイト構築ガイド
- article
【実践】Zod の union・discriminatedUnion を使った柔軟な型定義
- article
Node.js × FFmpeg でサムネイル自動生成:キーフレーム抽出とスプライト化
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- blog
失敗を称賛する文化はどう作る?アジャイルな組織へ生まれ変わるための第一歩
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来