MVCアーキテクチャからサービスレイヤーパターンへの移行は、多くのPHPプロジェクトで見られます。
これは自然な進化と言えるでしょう。
しかし、ただコードをコントローラーからサービスレイヤーに移動させるだけでは不十分です。
本記事では、サービスレイヤーパターンを効果的に実装し管理するための実践的なアプローチを紹介します。
サービスレイヤーの役割と課題
典型的なMVCアプローチでは、コントローラーがビジネスロジックを処理します。
そして、モデルがデータアクセスを担当します。
プロジェクトが成長すると、コントローラーは肥大化します。
その結果、テストや保守が困難になります。
サービスレイヤーの導入はこの問題を解決するはずです。
しかし、多くの場合、新たな問題が発生します。
「玄関を綺麗に保つために、すべての物をガレージに移動したら、今度はガレージが散らかってしまった」
これは複雑性を単に別の場所に移動させただけの状態です。
根本的な問題は解決していません。
効果的なサービスレイヤー管理のアプローチ
レイヤー間の明確な境界を設定する
サービスレイヤーはHTTPリクエスト/レスポンスの詳細から独立している必要があります。
コントローラーの役割は次の2つです。
- HTTPリクエストをドメインメッセージに変換する
- ドメインの結果をHTTPレスポンスにシリアライズする
悪い例:
// コントローラー内でビジネスロジックを処理 public function process(Request $request): Response { $userId = $request->get('user_id'); $amount = $request->get('amount'); // ビジネスロジックがコントローラー内に存在 if ($amount > 1000) { return new Response('金額が上限を超えています', 400); } $db->executeQuery("UPDATE users SET balance = balance - $amount WHERE id = $userId"); return new Response('処理完了', 200); }
良い例:
// コントローラー public function process(Request $request): Response { $orderId = $request->get('order_id'); $percent = $request->get('percent'); try { $order = $this->orderService->applyDiscount($orderId, $percent); return new Response('割引適用完了', 200); } catch (InvalidDiscountException $e) { return new Response($e->getMessage(), 400); } } // サービスレイヤー public function applyDiscount(int $orderId, float $percent): Order { $order = $this->orderRepository->find($orderId); if ($percent > 50) { throw new InvalidDiscountException('50%以上の割引はできません'); } $order->applyDiscount($percent); $this->orderRepository->save($order); return $order; }
この分離により、サービスレイヤーは純粋にビジネスロジックに集中できます。
また、テストや再利用が容易になります。
アクションパターン/コマンドパターンの活用
大きなサービスクラスを多数のメソッドで構成するのではなく、別のアプローチも考えられます。
単一責任を持つ小さなアクションクラスに分割すると効果的です。
// 大きなサービスクラスではなく class UserService { public function register(array $data) { /* ... */ } public function authenticate(string $email, string $password) { /* ... */ } public function resetPassword(string $email) { /* ... */ } // 他多数のメソッド } // 個別のアクションクラスに分割する class RegisterUserAction { public function execute(array $data) { // ユーザー登録に特化したロジック } } class AuthenticateUserAction { public function execute(string $email, string $password) { // 認証に特化したロジック } }
各アクションは単一の処理に集中します。
そして、必要なインフラストラクチャコンポーネントへの依存関係を明示的に宣言します。
このアプローチはコードの見通しを良くします。
また、テストも簡略化されます。
ドメイン駆動設計(DDD)の概念の適用
サービスレイヤーの効果的な組織化には、DDDの概念が非常に有効です。
戦略的DDD
ドメインの大きな構造を把握します。
そして、境界付けられたコンテキストを識別します。
これにより、サービスを論理的なグループに整理できます。
戦術的DDD
エンティティ、値オブジェクト、ドメインサービス、リポジトリなどの構成要素を活用します。
これらでドメインロジックを表現します。
例えば、オンラインショップを構築する場合、次のように分割できます。
- 「カタログ」
- 「注文」
- 「支払い」
- 「配送」
これらの境界付けられたコンテキスト内でサービスやアクションを整理します。
すると、コードの理解と保守が容易になります。
実践的なサービスレイヤー設計の例
オンラインショップの注文処理を例に考えてみましょう:
// 従来のアプローチ(巨大なサービスクラス) class OrderService { public function createOrder(int $userId, array $items, string $paymentMethod) { // 在庫チェック // 価格計算 // 注文レコード作成 // 支払い処理 // メール送信 // ロギング // すべてが一つのメソッド内に... } } // 改善されたアプローチ(単一責任のアクション) class CreateOrderAction { public function __construct( private StockChecker $stockChecker, private PriceCalculator $priceCalculator, private OrderRepository $orderRepository, private PaymentProcessor $paymentProcessor, private OrderConfirmationMailer $mailer, private LoggerInterface $logger ) {} public function execute(CreateOrderCommand $command): Order { // 在庫チェック $this->stockChecker->checkAvailability($command->getItems()); // 価格計算 $totalPrice = $this->priceCalculator->calculate($command->getItems()); // 注文作成 $order = Order::create( $command->getUserId(), $command->getItems(), $totalPrice, $command->getPaymentMethod() ); $this->orderRepository->save($order); // 支払い処理 $this->paymentProcessor->process($order); // メール送信 $this->mailer->sendConfirmation($order); // ロギング $this->logger->info('Order created', ['order_id' => $order->getId()]); return $order; } }
このアプローチは、依存関係が明示的で、テストが容易な設計をもたらします。
各コンポーネントはモックに置き換えられます。
そのため、単体テストを書きやすくなります。
サービスレイヤーのテスト戦略
サービスレイヤーは、ビジネスロジックの中心的な部分です。
そのため、テストカバレッジを高く保つことが重要です。
// アクションのテスト例 public function testCreateOrderAction() { // モックの設定 $stockChecker = $this->createMock(StockChecker::class); $stockChecker->expects($this->once()) ->method('checkAvailability') ->with($this->anything()); $priceCalculator = $this->createMock(PriceCalculator::class); $priceCalculator->expects($this->once()) ->method('calculate') ->willReturn(100.0); // 他のモックも同様に設定... // テスト対象のアクションを作成 $action = new CreateOrderAction( $stockChecker, $priceCalculator, /* 他の依存関係 */ ); // 実行 $command = new CreateOrderCommand(1, [/* 商品データ */], 'credit_card'); $order = $action->execute($command); // 検証 $this->assertEquals(100.0, $order->getTotalPrice()); // その他の検証... }
単一責任の原則に従ったアクションクラスは、このようにテストしやすくなります。
進化するアーキテクチャの検討
プロジェクトの規模や要件に合わせて、適切なアプローチを選択することが重要です。
小規模なプロジェクトでは従来のMVCでも十分な場合があります。
一方、複雑さが増すと、より構造化されたアプローチが有効になります。
さらに洗練されたパターンも検討する価値があります。
例えば:
- 垂直スライスアーキテクチャ(Vertical Slice Architecture)
- CQRS(Command Query Responsibility Segregation)
これらのパターンは、特に大規模で複雑なドメインに対して効果を発揮します。
まとめ
サービスレイヤーパターンを効果的に実装するには、単にコードを移動させるだけでは不十分です。
適切な構造化と責任の分離が必要です。
アクションパターンやDDDの概念を応用すれば、保守性が高くテストしやすいコードベースを構築できます。
最終的には、プロジェクトの特性に合わせてアプローチを調整することが重要です。
過度に複雑な設計は避けましょう。
チームが理解し保守できる範囲でパターンを適用すべきです。
サービスレイヤーの導入は目的ではなく手段です。
より読みやすく、テストしやすく、保守性の高いコードを作ることが本来の目標なのです。