NestJSバックエンドにおけるSOLID原則の実装
Takashi Yamamoto
Infrastructure Engineer · Leapcell

はじめに
ソフトウェア開発は絶えず進化しており、堅牢で保守性が高く、スケーラブルなシステムを構築することが極めて重要です。JavaScriptは引き続きその勢いを保ち、特にTypeScriptの静的型付けと予測可能性の向上により、バックエンド開発は大きく進歩しました。NestJSのようなフレームワークは、その意見が強くモジュラーなアーキテクチャにより、エンタープライズグレードのアプリケーションを構築するための素晴らしい基盤を提供します。しかし、強力なフレームワークを使用するだけでは十分ではありません。真の品質は、基本的な設計原則を遵守することから生まれます。ここでSOLID原則が登場します。これら5つの原則(単一責任、オープン/クローズ、リスコフの置換、インターフェース分離、依存関係逆転)は、よりクリーンで、より柔軟で、回復力のあるコードを書くためのロードマップを提供します。TypeScriptバックエンドプロジェクト、特にNestJSを活用しているプロジェクトにおいて、SOLID原則を理解し適用することは、コードの整理を劇的に改善し、技術的負債を削減し、コラボレーションを促進します。この記事では、SOLID原則の各項目を詳細に解説し、具体的なコード例を通じてNestJSコンテキスト内での実践的な適用方法を示します。
コアコンセプトの解説
実践的な実装に入る前に、SOLID原則のコアコンセプトを簡単に定義しましょう。
- 単一責任の原則 (SRP): クラスまたはモジュールは、変更される理由が1つだけであるべきです。これは、単一の、明確に定義された機能に責任を持つべきであることを意味します。
- オープン/クローズドの原則 (OCP): ソフトウェアエンティティ(クラス、モジュール、関数など)は、拡張に対しては開かれており、修正に対しては閉じられていなければなりません。既存の動作するコードを変更することなく、新しい機能を追加できるべきです。
- リスコフの置換原則 (LSP): サブタイプは、プログラムの正しさを変更することなく、基底タイプと置換可能でなければなりません。これは、
SがTのサブタイプである場合、型Tのオブジェクトは、アプリケーションを壊すことなく型Sのオブジェクトと置き換えることができることを意味します。 - インターフェース分離の原則 (ISP): クライアントは、使用しないインターフェースに依存することを強制されるべきではありあません。1つの大きくて汎用的なインターフェースではなく、複数の小さく、役割固有のインターフェースを優先すべきです。
- 依存関係逆転の原則 (DIP): 高レベルモジュールは低レベルモジュールに依存すべきではなく、両方とも抽象に依存すべきです。抽象は詳細に依存すべきではなく、詳細が抽象に依存すべきです。
NestJSは、モジュール、サービス、依存関係注入を強く重視しており、これらの原則を実践するための優れた環境を提供します。
NestJSにおけるSOLID原則の実装
典型的なeコマラーンスシナリオを想定して、NestJSアプリケーション内での実践的な例でこれらの原則を説明しましょう。
単一責任の原則 (SRP)
NestJSでは、コントローラーはリクエストを調整し、サービスはビジネスロジックを処理し、リポジトリはデータアクセスを管理します。SRPを遵守することは、これらの関心事を明確に分離することを意味します。
悪い例 (SRP違反):
// user.controller.ts (例 - 多くのことをやりすぎる) import { Controller, Get, Post, Body } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; @Controller('users') export class UserController { constructor( @InjectRepository(User) private usersRepository: Repository<User>, ) {} @Post() async createUser(@Body() userData: any): Promise<User> { // ユーザー作成、パスワードハッシュ化、ウェルカムメール送信などのロジック const newUser = this.usersRepository.create(userData); await this.usersRepository.save(newUser); // ここにEメール送信ロジックがあると想像してください console.log('Sending welcome email...'); return newUser; } @Get(':id') async getUser(@Param('id') id: string): Promise<User> { return this.usersRepository.findOne(id); } }
このUserControllerは、HTTPリクエストの処理、ユーザーの作成、データベースへの保存、そしてメール送信さえも担当しています。これらの関心のいずれかが変更された場合、UserControllerは変更が必要になります。
良い例 (SRP遵守):
// user.entity.ts import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column({ unique: true }) email: string; @Column() passwordHash: string; // ハッシュ化されたパスワードを保存 } // user.repository.ts (Userのデータアクセスを処理) import { EntityRepository, Repository } from 'typeorm'; import { User } from './user.entity'; @EntityRepository(User) export class UserRepository extends Repository<User> { // カスタムデータアクセスメソッドをここに追加できます } // email.service.ts (Eメール送信の関心を処理) import { Injectable } from '@nestjs/common'; @Injectable() export class EmailService { async sendWelcomeEmail(to: string, username: string): Promise<void> { console.log(`Sending welcome email to ${username} at ${to}`); // 実際のEメール送信ロジック (例: nodemailerを使用) } } // user.service.ts (Userに関するビジネスロジックを処理) import { Injectable } from '@nestjs/common'; import { UserRepository } from './user.repository'; import { EmailService } from '../email/email.service'; import * as bcrypt from 'bcrypt'; // パスワードハッシュ化用 @Injectable() export class UserService { constructor( private readonly userRepository: UserRepository, private readonly emailService: EmailService, ) {} async createUser(email: string, passwordPlain: string): Promise<User> { const passwordHash = await bcrypt.hash(passwordPlain, 10); const newUser = this.userRepository.create({ email, passwordHash }); await this.userRepository.save(newUser); await this.emailService.sendWelcomeEmail(email, email); // 簡単のためEメールをユーザー名として使用 return newUser; } async findUserById(id: number): Promise<User> { return this.userRepository.findOne(id); } } // user.controller.ts (HTTPリクエストを処理し、サービスに委任) import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { UserService } from './user.service'; import { User } from './user.entity'; @Controller('users') export class UserController { constructor(private readonly userService: UserService) {} @Post() async createUser(@Body() userData: { email: string; passwordPlain: string }): Promise<User> { return this.userService.createUser(userData.email, userData.passwordPlain); } @Get(':id') async getUser(@Param('id') id: string): Promise<User> { return this.userService.findUserById(parseInt(id, 10)); } }
これで、UserControllerはHTTPリクエストのみを処理し、UserServiceはユーザー関連のビジネスロジックを管理し、UserRepositoryはデータ永続化を処理し、EmailServiceはEメール送信の責任のみを持ちます。各クラスは変更される理由が1つだけです。
オープン/クローズドの原則 (OCP)
OCPを例示するために、eコマラーンスアプリケーションでさまざまな種類の支払い方法があるシナリオを考えてみましょう。
悪い例 (OCP違反):
// payment.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class PaymentService { processPayment(amount: number, method: 'credit_card' | 'paypal' | 'stripe'): string { if (method === 'credit_card') { // クレジットカード決済のロジック return `Processed credit card payment of $${amount}`; } else if (method === 'paypal') { // PayPal決済のロジック return `Processed PayPal payment of $${amount}`; } else if (method === 'stripe') { // Stripe決済のロジック return `Processed Stripe payment of $${amount}`; } else { throw new Error('Unsupported payment method'); } } }
新しい支払い方法(例:「Google Pay」)を追加するには、processPaymentメソッドを変更する必要があり、OCPに違反します。
良い例 (OCP遵守):
// payment-method.interface.ts export interface IPaymentMethod { processPayment(amount: number): string; } // credit-card.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class CreditCardPayment implements IPaymentMethod { processPayment(amount: number): string { // クレジットカード固有のロジック return `Processed Credit Card payment of $${amount}`; } } // paypal.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class PayPalPayment implements IPaymentMethod { processPayment(amount: number): string { // PayPal固有のロジック return `Processed PayPal payment of $${amount}`; } } // stripe.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class StripePayment implements IPaymentMethod { processPayment(amount: number): string { // Stripe固有のロジック return `Processed Stripe payment of $${amount}`; } } // payment.service.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class PaymentService { // NestJSの依存性注入は、トークンまたはモジュールで提供される具体的な実装を注入することを可能にします。 // ここでファクトリまたは戦略パターンを持つこともできます。 processPaymentWithStrategy(paymentMethod: IPaymentMethod, amount: number): string { return paymentMethod.processPayment(amount); } } // payment.module.ts (異なる戦略をどのように提供するかを示す例) import { Module } from '@nestjs/common'; import { PaymentService } from './payment.service'; import { CreditCardPayment } from './credit-card.payment'; import { PayPalPayment } from './paypal.payment'; import { StripePayment } from './stripe.payment'; import { IPaymentMethod } from './payment-method.interface'; const paymentMethodProviders = [ { provide: 'CREDIT_CARD_PAYMENT', useClass: CreditCardPayment, }, { provide: 'PAYPAL_PAYMENT', useClass: PayPalPayment, }, { provide: 'STRIPE_PAYMENT', useClass: StripePayment, }, ]; @Module({ providers: [PaymentService, ...paymentMethodProviders], exports: [PaymentService, ...paymentMethodProviders], }) export class PaymentModule {} // payment.controller.ts import { Controller, Post, Body, Inject } from '@nestjs/common'; import { PaymentService } from './payment.service'; import { IPaymentMethod } from './payment-method.interface'; @Controller('payments') export class PaymentController { constructor( private readonly paymentService: PaymentService, @Inject('CREDIT_CARD_PAYMENT') private readonly creditCardPayment: IPaymentMethod, @Inject('PAYPAL_PAYMENT') private readonly payPalPayment: IPaymentMethod, @Inject('STRIPE_PAYMENT') private readonly stripePayment: IPaymentMethod, ) {} @Post('process') processPayment(@Body() data: { amount: number; method: string }): string { let paymentStrategy: IPaymentMethod; switch (data.method) { case 'credit_card': paymentStrategy = this.creditCardPayment; break; case 'paypal': paymentStrategy = this.payPalPayment; break; case 'stripe': paymentStrategy = this.stripePayment; break; default: throw new Error('Unsupported payment method'); } return this.paymentService.processPaymentWithStrategy(paymentStrategy, data.amount); } }
これで、新しい支払い方法を追加するには、IPaymentMethodを実装する新しいクラスを作成し、モジュールに登録するだけです。既存のPaymentServiceおよびPaymentControllerは変更されず、OCPを遵守します。
リスコフの置換原則 (LSP)
LSPは、新しい派生クラスが基底クラスの動作を変更せずに拡張することを保証します。Productエンティティとそのサブタイプを考えてみましょう。
悪い例 (LSP違反):
// product.interface.ts export interface IProduct { getId(): string; getPrice(): number; // すべての製品に割引を適用するメソッド applyDiscount(percentage: number): number; } // tangible-product.class.ts export class TangibleProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } applyDiscount(percentage: number): number { return this.price * (1 - percentage / 100); } } // digital-product.class.ts (LSP違反) export class DigitalProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } // デジタル製品は、ライセンスまたは固定コストのため割引を適用できません applyDiscount(percentage: number): number { throw new Error('Digital products cannot be discounted.'); } } // 注文処理ロジックのどこか function calculateTotal(products: IProduct[]): number { let total = 0; for (const product of products) { // この関数は、すべての製品でapplyDiscountが機能することを期待しています。 // DigitalProductが渡された場合、エラーが発生し、契約が壊れます。 total += product.applyDiscount(10); // 10%割引を適用 } return total; }
ここでは、DigitalProductのapplyDiscountメソッドはエラーをスローし、IProduct.applyDiscountの期待される動作を変更します。IProductが期待される場所でDigitalProductが使用された場合、アプリケーションはクラッシュする可能性があります。
良い例 (LSP遵守):
これを修正するには、インターフェースを再評価するか、DigitalProductが割引を適切に処理するように(例:元の価格を返す)するか、どちらかを行う必要があります。LSPのより良いアプローチは、関心の分離を改善したり、動作をより正確に定義することかもしれません。
// discountable-product.interface.ts export interface IDiscountableProduct { applyDiscount(percentage: number): number; } // product.interface.ts (割引ロジックなしの基本製品) export interface IProduct { getId(): string; getPrice(): number; } // tangible-product.class.ts export class TangibleProduct implements IProduct, IDiscountableProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } applyDiscount(percentage: number): number { return this.price * (1 - percentage / 100); } } // digital-product.class.ts (デフォルトでは割引機能なし) export class DigitalProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } // applyDiscountメソッドなし、または元の価格を返す } // 注文処理ロジックのどこか function calculateDiscountedTotal(products: IDiscountableProduct[]): number { let total = 0; for (const product of products) { total += product.applyDiscount(10); // 割引可能な製品のみが渡されるため安全になりました } return total; } function calculateTotal(products: IProduct[]): number { let total = 0; for (const product of products) { total += product.getPrice(); } return total; }
これで、割引可能な製品を期待する関数はそれらのみを受け取るようになり、動作が予期せず変更されないことが保証されます。これはISPにも触れます。
インターフェース分離の原則 (ISP)
ISPは、クライアントは使用しないインターフェースに依存することを強制されるべきではないと示唆しています。
悪い例 (ISP違反):
// user-management.interface.ts (巨大なインターフェース) export interface IUserManager { createUser(data: any): Promise<any>; updateUser(id: string, data: any): Promise<any>; deleteUser(id: string): Promise<void>; getUsers(): Promise<any[]>; resetPassword(userId: string): Promise<void>; assignRole(userId: string, role: string): Promise<void>; // ... その他、sendWelcomeEmail、generateReportなどの無関係なメソッド... } // ユーザーの作成と取得のみが必要だが、インターフェース全体を実装するサービス export class UserDisplayService implements IUserManager { createUser(data: any): Promise<any> { /* ... */ } updateUser(id: string, data: any): Promise<any> { throw new Error('Not implemented'); } // 不要 deleteUser(id: string): Promise<void> { throw new Error('Not implemented'); } // 不要 getUsers(): Promise<any[]> { /* ... */ } resetPassword(userId: string): Promise<void> { throw new Error('Not implemented'); } // 不要 assignRole(userId: string, role: string): Promise<void> { throw new Error('Not implemented'); } // 不要 }
UserDisplayServiceは、必要のないメソッドを実装することを強制され、不要な複雑さとエラーの可能性(例:Not implementedエラーのスロー)につながります。
良い例 (ISP遵守):
// user-creator.interface.ts export interface IUserCreator { createUser(data: any): Promise<any>; } // user-reader.interface.ts export interface IUserReader { getUsers(): Promise<any[]>; getUserById(id: string): Promise<any>; } // user-updater.interface.ts export interface IUserUpdater { updateUser(id: string, data: any): Promise<any>; resetPassword(userId: string): Promise<void>; } // user-deleter.interface.ts export interface IUserDeleter { deleteUser(id: string): Promise<void>; } // ユーザー表示サービスはIuserReaderのみを必要とします export class UserDisplayService implements IUserReader { constructor(/* 依存関係 */) {} getUsers(): Promise<any[]> { /* ... */ } getUserById(id: string): Promise<any> { /* ... */ } } // 完全なユーザー管理サービスは、複数の小さなインターフェースを実装する可能性があります export class FullUserService implements IUserCreator, IUserReader, IUserUpdater, IUserDeleter { constructor(/* 依存関係 */) {} createUser(data: any): Promise<any> { /* ... */ } getUsers(): Promise<any[]> { /* ... */ } getUserById(id: string): Promise<any> { /* ... */ } updateUser(id: string, data: any): Promise<any> { /* ... */ } resetPassword(userId: string): Promise<void> { /* ... */ } deleteUser(id: string): Promise<void> { /* ... */ } }
これで、UserDisplayServiceはIUserReaderのみに依存し、不要なメソッドの実装を排除します。これにより、コードはよりクリーンで、より集中し、テストしやすくなります。
依存関係逆転の原則 (DIP)
DIPは、具体的な実装ではなく抽象に依存することを強調します。NestJSの依存関係注入コンテナはDIPを強力にサポートします。
悪い例 (DIP違反):
// email-sender.class.ts (具体的な低レベルモジュール) export class SMSEmailSender { send(to: string, subject: string, body: string): void { console.log(`Sending SMS email to ${to}: ${subject} - ${body}`); // 特定のSMS-to-emailゲートウェイ経由での送信をシミュレーション } } // user.service.ts import { Injectable } from '@nestjs/common'; import { SMSEmailSender } from './email-sender.class'; // 具体的なクラスへの直接依存 @Injectable() export class UserService { private emailSender: SMSEmailSender; constructor() { this.emailSender = new SMSEmailSender(); // 高レベルモジュールが低レベルモジュールを作成 } async registerUser(email: string, passwordHash: string): Promise<void> { // ... ユーザー登録ロジック ... this.emailSender.send(email, 'Welcome!', 'Thank you for registering.'); } }
UserService(高レベルモジュール)は、SMSEmailSender(低レベルモジュール)に直接依存しています。異なるEメール送信メカニズム(例:SendGridやMailgunを使用)に切り替えたい場合、UserServiceを変更する必要があります。
良い例 (DIP遵守):
// email-sender.interface.ts (抽象) export interface IEmailSender { send(to: string, subject: string, body: string): Promise<void>; } // sms-email-sender.class.ts (具体的な低レベルモジュール、抽象に依存) import { Injectable } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class SMSEmailSender implements IEmailSender { async send(to: string, subject: string, body: string): Promise<void> { console.log(`Sending SMS email to ${to}: ${subject} - ${body}`); // 実際のSMS-to-emailゲートウェイ連携 } } // sendgrid-email-sender.class.ts (別の具体的な低レベルモジュール) import { Injectable } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class SendGridEmailSender implements IEmailSender { async send(to: string, subject: string, body: string): Promise<void> { console.log(`Sending via SendGrid to ${to}: ${subject} - ${body}`); // 実際のSendGrid API連携 } } // user.service.ts (高レベルモジュール、抽象に依存) import { Injectable, Inject } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class UserService { constructor( @Inject('EMAIL_SENDER') private readonly emailSender: IEmailSender, // 抽象に依存 ) {} async registerUser(email: string, passwordHash: string): Promise<void> { // ... ユーザー登録ロジック ... await this.emailSender.send(email, 'Welcome!', 'Thank you for registering.'); } } // user.module.ts (NestJS DIを使用した依存関係の配線) import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { SMSEmailSender } from './sms-email-sender.class'; import { SendGridEmailSender } from './sendgrid-email-sender.class'; import { IEmailSender } from './email-sender.interface'; @Module({ providers: [ UserService, // 機能トグル、環境などに基づいて、どの実装を提供するかを選択します。 { provide: 'EMAIL_SENDER', // 依存関係のトークン useClass: SMSEmailSender, // 使用する具体的な実装 // 実際のプロジェクトでは、これを切り替える場合があります: // useClass: process.env.EMAIL_PROVIDER === 'SENDGRID' ? SendGridEmailSender : SMSEmailSender, }, ], exports: [UserService], }) export class UserModule {}
これで、UserServiceはIEmailSender抽象に依存します。実際の実装(SMSEmailSenderまたはSendGridEmailSender)は、モジュールレベル(または実行時/構成時)で決定され、UserServiceの変更なしで、優れた柔軟性とテスト可能性を提供します。
結論
NestJSを使用したTypeScriptバックエンドプロジェクトでSOLID原則を採用することは、単に一連のルールに従うだけでなく、柔軟で堅牢で保守性の高いソフトウェアの作成につながる考え方を育むことです。各コンポーネントが明確で唯一の目的を持つようにすること(SRP)から、既存のコードを壊すことなく、新しい機能を簡単に組み込めるシステムを設計すること(OCP)、信頼性の高い継承階層を構築すること(LSP)、機敏で焦点を絞ったインターフェースを作成すること(ISP)、そして最終的に抽象を通じて高レベルのポリシーと低レベルの詳細を分離すること(DIP)まで、SOLID原則は貴重なフレームワークを提供します。NestJSのアーキテクチャパターン(モジュール、サービス、リポジトリ、強力な依存関係注入システム)は、これらの原則の適用と自然に一致し、開発者が自信と容易さをもって複雑でスケーラブルなアプリケーションを構築できるようにします。開発ライフサイクル全体でSOLID原則を意識的に適用することにより、コードベースを単なる機能的なものから模範的なものへと引き上げることができます。