SOLID原則とデザインパターンを用いた堅牢なTypeScriptバックエンドの構築
Daniel Hayes
Full-Stack Engineer · Leapcell

はじめに
ソフトウェア開発の急速に進化する状況において、堅牢でスケーラブル、かつ保守性の高いバックエンドアプリケーションを構築することは最優先事項です。JavaScriptはNode.jsとともにサーバーサイドでその支配力を維持していますが、TypeScriptは型安全性を提供し、開発者エクスペリエンスを向上させる不可欠なツールとして登場しました。しかし、高品質なコードを保証するには、TypeScriptを使用するだけでは十分ではありません。バックエンドソリューションを真に向上させるには、確立されたアーキテクチャガイドラインと、繰り返し発生する問題に対する実績のあるソリューションを採用する必要があります。そこで登場するのがSOLID原則と一般的なデザインパターンです。これらは単なる学術的概念ではなく、効果的に適用されると、混沌としたコードベースをエレガントで回復力のあるシステムに変える実用的な設計図なのです。この記事では、TypeScriptバックエンドアプリケーションにおけるSOLID原則と主要なデザインパターンの実践的な実装について掘り下げ、それらが適応、拡張、そして永続するソフトウェアを構築するのにどのように貢献するかを説明します。
基本の理解
実践的な例に入る前に、議論の根幹をなすコアコンセプトを理解することが重要です。
SOLID原則は、ソフトウェア設計をより理解しやすく、柔軟で、保守しやすくすることを目的とした5つの設計原則のセットです。これらはRobert C. Martin(Uncle Bob)によって推進されました。
- 単一責任の原則(SRP):クラスは変更される理由を1つだけ持つべきです。これは、クラスが1つだけのジョブを持つべきであることを意味します。
- オープン/クローズド原則(OCP):ソフトウェアエンティティ(クラス、モジュール、関数など)は、拡張に対してはオープンであるべきですが、変更に対してはクローズドであるべきです。既存のコードを変更せずに新しい機能を追加できるはずです。
- リスコフの置換原則(LSP):プログラム内のオブジェクトは、そのプログラムの正しさを変えることなく、そのサブタイプのインスタンスで置き換えることができるべきです。簡単に言えば、サブクラスはそのスーパークラスの代わりに使用できるべきです。
- インターフェース分離原則(ISP):クライアントは、使用しないインターフェースに依存することを強制されるべきではありません。1つの大きなインターフェースよりも、小さくて目的固有の多くのインターフェースの方が優れています。
- 依存性逆転原則(DIP):高レベルモジュールは低レベルモジュールに依存すべきではありません。両方とも抽象に依存すべきです。抽象は詳細に依存すべきではありません。詳細は抽象に依存すべきです。
デザインパターンは、ソフトウェア設計中に発生する一般的な問題に対する、一般化された再利用可能なソリューションです。これらは直接的な解決策ではなく、特定の問題を解決するためにカスタマイズできるテンプレートまたは設計図です。バックエンド開発に関連するいくつかの一般的なパターンに焦点を当てます。
- シングルトンパターン:クラスが1つのインスタンスのみを持つことを保証し、それへのグローバルなアクセスポイントを提供します。
- ファクトリーメソッドパターン:オブジェクトを作成するためのインターフェースを定義しますが、サブクラスがどのクラスをインスタンス化するかを決定します。サブクラスにインスタンス化を委譲します。
- ストラテジーパターン:アルゴリズムのファミリを定義し、それぞれをカプセル化し、それらを交換可能にします。ストラテジーは、アルゴリズムがそれを使用するクライアントから独立して変化することを可能にします。
- リポジトリパターン:ドメインオブジェクトにアクセスするためのコレクションのようなインターフェースを使用して、ドメインとデータマッピング層の間を媒介します。
TypeScriptバックエンドでの実践的応用
通常、Eコマースの注文処理システムのようなシナリオを使用して、これらの原則とパターンがTypeScriptバックエンドコンテキスト内で実行可能なコードにどのように変換されるかを見ていきましょう。
単一責任の原則(SRP)
OrderService
クラスを考えてみましょう。SRPがない場合、注文作成、支払い処理、通知送信、ログ記録などを担当する可能性があります。これにより、保守とテストが困難になります。
問題のある設計(SRP違反):
// services/order.service.ts class OrderService { createOrder(userId: string, itemIds: string[]): Order { // 1. 入力検証 // 2. データベースへの注文の永続化 // 3. 支払い処理 // 4. 注文確認メールの送信 // 5. 注文作成イベントのログ記録 // ... 多くの責任 return new Order(); // 簡略化 } }
SRP準拠設計:
OrderService
を、それぞれ単一の責任を持ついくつかの個別のクラスに分解します。
// interfaces/order.interface.ts interface OrderCreationRequest { userId: string; itemIds: string[]; } interface Order { id: string; userId: string; status: string; // ... } // repositories/order.repository.ts class OrderRepository { async create(orderData: OrderCreationRequest): Promise<Order> { console.log("データベースに注文データを永続化しています..."); // DB操作のシミュレーション return { id: 'order-123', userId: orderData.userId, status: 'pending' }; } // ... find, update, deleteのような他の永続化メソッド } // services/payment.service.ts class PaymentService { async processPayment(orderId: string, amount: number): Promise<boolean> { console.log(`注文 ${orderId} の支払い処理中、金額: ${amount}`); // 支払いゲートウェイ連携のシミュレーション return true; } } // services/notification.service.ts class NotificationService { async sendOrderConfirmationEmail(order: Order, userEmail: string): Promise<void> { console.log(`注文 ${order.id} の確認メールを ${userEmail} に送信中`); // メール送信のシミュレーション } } // services/logger.service.ts class LoggerService { log(message: string, context?: object): void { console.log(`LOG: ${message}`, context); } } // services/order.service.ts (オーケストレーターとなる) class OrderCreationService { constructor( private orderRepository: OrderRepository, private paymentService: PaymentService, private notificationService: NotificationService, private loggerService: LoggerService ) {} async createOrder(request: OrderCreationRequest, userEmail: string): Promise<Order> { // 入力検証は別のサービスやミドルウェア/デコレータになる可能性あり const newOrder = await this.orderRepository.create(request); await this.paymentService.processPayment(newOrder.id, 100); // 金額を仮定 await this.notificationService.sendOrderConfirmationEmail(newOrder, userEmail); this.loggerService.log('注文が正常に作成されました', { orderId: newOrder.id, userId: newOrder.userId }); return newOrder; } } // Expressルートハンドラーでの使用例 // const orderRepo = new OrderRepository(); // const paymentSvc = new PaymentService(); // const notificationSvc = new NotificationService(); // const loggerSvc = new LoggerService(); // const orderCreator = new OrderCreationService(orderRepo, paymentSvc, notificationSvc, loggerSvc); // app.post('/orders', async (req, res) => { // const order = await orderCreator.createOrder(req.body, req.user.email); // res.status(201).json(order); // });
このリファクタリングにより、各クラスの理解、テスト、保守が容易になります。支払いロジックが変更された場合、PaymentService
のみを変更する必要があります。
オープン/クローズド原則(OCP)とストラテジーパターン
例えば、支払い処理で複数の支払いゲートウェイ(Stripe、PayPalなど)をサポートする必要があるとします。新しいゲートウェイを追加するたびにPaymentService
を直接変更するとOCPに違反します。ストラテジーパターンとOCPを組み合わせて使用できます。
OCP違反:
// services/payment.service.ts class PaymentService { async processPayment(orderId: string, amount: number, paymentMethod: 'stripe' | 'paypal'): Promise<boolean> { if (paymentMethod === 'stripe') { // Stripe固有のロジック } else if (paymentMethod === 'paypal') { // PayPal固有のロジック } else { throw new Error('サポートされていない支払い方法'); } return true; } }
OCPとストラテジー準拠設計:
// interfaces/payment-gateway.interface.ts interface PaymentGateway { process(amount: number, orderId: string): Promise<boolean>; } // strategies/stripe-gateway.ts class StripeGateway implements PaymentGateway { async process(amount: number, orderId: string): Promise<boolean> { console.log(`Stripe経由で注文 ${orderId} の ${amount} を処理中`); // Stripe APIを呼び出す return true; } } // strategies/paypal-gateway.ts class PayPalGateway implements PaymentGateway { async process(amount: number, orderId: string): Promise<boolean> { console.log(`PayPal経由で注文 ${orderId} の ${amount} を処理中`); // PayPal APIを呼び出す return true; } } // services/payment.processor.ts class PaymentProcessor { private gateway: PaymentGateway; setPaymentGateway(gateway: PaymentGateway): void { this.gateway = gateway; } async executePayment(amount: number, orderId: string): Promise<boolean> { if (!this.gateway) { throw new Error("支払いゲートウェイが設定されていません。"); } return this.gateway.process(amount, orderId); } } // 使用例 // const paymentProcessor = new PaymentProcessor(); // // Stripe支払いの場合 // paymentProcessor.setPaymentGateway(new StripeGateway()); // await paymentProcessor.executePayment(50, 'order-456'); // // PayPal支払いの場合 // paymentProcessor.setPaymentGateway(new PayPalGateway()); // await paymentProcessor.executePayment(75, 'order-789');
これで、新しい支払いゲートウェイを追加するには、PaymentGateway
を実装する新しいクラスを作成し、それをPaymentProcessor
に注入するだけです。PaymentProcessor
の変更は必要なく、OCPに準拠しています。
リスコフの置換原則(LSP)
さまざまな種類の配送を考えてみましょう。StandardShipping
とExpressShipping
クラスがある場合、ExpressShipping
は機能性を損なうことなく、StandardShipping
が期待される場所でどこでも使用できるはずです。
// interfaces/shipping.interface.ts interface ShippingService { calculateCost(weight: number, distance: number): number; deliver(orderId: string, address: string): Promise<boolean>; } // services/standard-shipping.ts class StandardShipping implements ShippingService { calculateCost(weight: number, distance: number): number { return weight * 0.5 + distance * 0.1; // 簡単な計算 } async deliver(orderId: string, address: string): Promise<boolean> { console.log(`標準配送で注文 ${orderId} を ${address} に配送中`); // 標準配送のシミュレーション return true; } } // services/express-shipping.ts class ExpressShipping implements ShippingService { calculateCost(weight: number, distance: number): number { return weight * 1.5 + distance * 0.3 + 10; // より高いコスト } async deliver(orderId: string, address: string): Promise<boolean> { console.log(`速達便で注文 ${orderId} を ${address} に配送中`); // 速達配送のシミュレーション return true; } // コンテキストを壊す可能性のある「余分な」メソッドはありません } // 使用例 function shipOrder(shippingService: ShippingService, orderId: string, address: string, weight: number, distance: number) { const cost = shippingService.calculateCost(weight, distance); console.log(`配送料: $${cost}`); shippingService.deliver(orderId, address); } // shipOrder(new StandardShipping(), 'order-1', '123 Main St', 5, 100); // shipOrder(new ExpressShipping(), 'order-2', '456 Oak Ave', 3, 50);
StandardShipping
とExpressShipping
の両方をShippingService
の代わりに使用しても問題なく、LSPを維持しています。
インターフェース分離原則(ISP)
単一の巨大なUserRepository
インターフェースではなく、それを分割することができます。
ISP違反:
// interfaces/user.repository.ts interface IUserRepository { create(user: User): Promise<User>; findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; update(id: string, user: Partial<User>): Promise<User | null>; delete(id: string): Promise<boolean>; // プロファイル管理、認証などのメソッドを含む updateProfile(userId: string, profileData: Partial<UserProfile>): Promise<UserProfile>; resetPassword(userId: string, newPasswordHash: string): Promise<boolean>; }