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の概念を応用すれば、保守性が高くテストしやすいコードベースを構築できます。
最終的には、プロジェクトの特性に合わせてアプローチを調整することが重要です。
過度に複雑な設計は避けましょう。
チームが理解し保守できる範囲でパターンを適用すべきです。
サービスレイヤーの導入は目的ではなく手段です。
より読みやすく、テストしやすく、保守性の高いコードを作ることが本来の目標なのです。