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

