T-CREATOR

PHP アーキテクチャ設計入門:レイヤリング・依存逆転・ユースケース分離

PHP アーキテクチャ設計入門:レイヤリング・依存逆転・ユースケース分離

PHP プロジェクトが成長するにつれて、コードの保守性や拡張性が問題になることはありませんか?特に、ビジネスロジックがコントローラーやモデルに散らばってしまい、変更が困難になる経験をされた方も多いでしょう。

本記事では、PHP アプリケーションにおけるレイヤリング依存逆転の原則(DIP)、そしてユースケース分離という 3 つの重要なアーキテクチャ設計手法を解説します。これらの手法を理解することで、変更に強く、テストしやすい PHP アプリケーションを構築できるようになりますよ。

背景

なぜアーキテクチャ設計が必要なのか

PHP は柔軟な言語であり、小規模なプロジェクトでは手軽に開発を進められます。しかし、プロジェクトが成長すると、以下のような問題が顕在化してきます。

  • ビジネスロジックがフレームワークやデータベースと密結合している
  • テストが困難で、変更のたびに予期しない不具合が発生する
  • 新機能の追加や既存機能の修正に多大な時間がかかる

これらの問題を解決するために、適切なアーキテクチャ設計が必要となるのです。

アーキテクチャ設計の基本概念

以下の図は、適切なアーキテクチャ設計が実現する構造を示しています。

mermaidflowchart TB
    subgraph presentation["プレゼンテーション層"]
        controller["Controller<br/>(Web リクエスト処理)"]
    end

    subgraph application["アプリケーション層"]
        usecase["UseCase<br/>(ビジネスロジック)"]
    end

    subgraph domain["ドメイン層"]
        entity["Entity<br/>(ビジネスルール)"]
        repository_interface["Repository Interface<br/>(データ操作の抽象)"]
    end

    subgraph infrastructure["インフラストラクチャ層"]
        repository_impl["Repository 実装<br/>(DB アクセス)"]
        external["外部サービス連携"]
    end

    controller -->|依存| usecase
    usecase -->|依存| entity
    usecase -->|依存| repository_interface
    repository_interface -.->|実装| repository_impl
    repository_impl -->|アクセス| external

図で理解できる要点:

  • 各層が明確な責務を持ち、上位層から下位層への依存関係が確立されている
  • インターフェースを介することで、実装の詳細から独立している
  • ビジネスロジックが中心に位置し、技術的詳細から隔離されている

この図が示すように、アーキテクチャ設計では関心の分離依存関係の制御が重要なポイントとなります。

課題

典型的な PHP アプリケーションの問題点

多くの PHP アプリケーションでは、以下のような課題が見られます。

#課題具体例影響
1ビジネスロジックの散在Controller や Model に直接ロジックを記述重複コード、テスト困難
2フレームワークへの強依存Laravel の Eloquent に直接依存フレームワーク変更が困難
3データベースへの密結合SQL 文がビジネスロジックに混在データソース変更が困難
4テストの困難さモックが作成できない、DB が必須テスト実行時間の増加
5変更の影響範囲が不明確1 箇所の修正が予期しない箇所に影響バグの増加、開発速度の低下

密結合なコードの例

以下は、典型的な密結合コードの例です。

php<?php
// 悪い例:全てが密結合している Controller
class UserController
{
    public function register(Request $request)
    {
        // バリデーション
        if (empty($request->input('email'))) {
            return response()->json(['error' => 'Email is required'], 400);
        }

        // ビジネスロジックが Controller に直接記述されている
        $user = new User();
        $user->email = $request->input('email');
        $user->password = bcrypt($request->input('password'));

        // データベースアクセスが直接記述されている
        $user->save();

        // メール送信ロジックも混在
        Mail::to($user->email)->send(new WelcomeMail($user));

        return response()->json(['message' => 'User registered'], 201);
    }
}

このコードには以下の問題があります。

  • バリデーション、ビジネスロジック、データ永続化、外部サービス連携が全て 1 つのメソッドに混在している
  • フレームワーク(Laravel)に強く依存しており、テストが困難
  • ビジネスルールの再利用ができない

次のセクションでは、これらの課題を解決する具体的な手法を見ていきましょう。

解決策

レイヤリング(層化)の導入

レイヤリングとは、アプリケーションを責務ごとに複数の層に分割する設計手法です。一般的には以下の 4 層構造が用いられます。

4 層アーキテクチャの構成

#層名責務具体例
1プレゼンテーション層ユーザーインターフェース、リクエスト処理Controller, View, API エンドポイント
2アプリケーション層ユースケースの実行、処理の調整UseCase, Service, ApplicationService
3ドメイン層ビジネスルール、ビジネスロジックEntity, ValueObject, DomainService
4インフラストラクチャ層技術的詳細、外部システム連携Repository 実装, DB アクセス, API クライアント

各層は明確な責務を持ち、上位層は下位層に依存しますが、その逆はありません。

レイヤリングのルール

レイヤリングを適用する際の重要なルールを理解しましょう。

php<?php
namespace App\Presentation\Controller;

use App\Application\UseCase\RegisterUserUseCase;
use App\Application\UseCase\RegisterUserRequest;

// プレゼンテーション層:リクエストを受け取り、UseCase を呼び出す
class UserController
{
    private RegisterUserUseCase $registerUserUseCase;

    public function __construct(RegisterUserUseCase $registerUserUseCase)
    {
        // 依存性注入により、UseCase を受け取る
        $this->registerUserUseCase = $registerUserUseCase;
    }
}

上記のコードでは、Controller がアプリケーション層の UseCase に依存していることが明確です。これにより、プレゼンテーション層の責務が「リクエストの受け取りと応答」に限定されます。

php<?php
    public function register(Request $request)
    {
        // リクエストデータを UseCase 用のデータ構造に変換
        $useCaseRequest = new RegisterUserRequest(
            email: $request->input('email'),
            password: $request->input('password')
        );

        // ビジネスロジックは UseCase に委譲
        $result = $this->registerUserUseCase->execute($useCaseRequest);

        // 結果を HTTP レスポンスに変換
        return response()->json([
            'user_id' => $result->userId,
            'message' => 'User registered successfully'
        ], 201);
    }

Controller のメソッドは、リクエストの変換、UseCase の実行、レスポンスの生成という 3 つのステップに整理されました。

依存逆転の原則(DIP)の適用

依存逆転の原則(Dependency Inversion Principle)は、SOLID 原則の 1 つであり、以下を定めています。

上位レベルのモジュールは下位レベルのモジュールに依存してはならない。両方とも抽象に依存すべきである。

以下の図は、依存逆転の原則がどのように機能するかを示します。

mermaidflowchart LR
    subgraph before["依存逆転前"]
        uc1["UseCase"] -->|直接依存| db1["MySQL 実装"]
    end

    subgraph after["依存逆転後"]
        uc2["UseCase"] -->|依存| interface["Repository<br/>Interface"]
        mysql["MySQL 実装"] -.->|実装| interface
        postgres["PostgreSQL 実装"] -.->|実装| interface
        memory["InMemory 実装"] -.->|実装| interface
    end

    style interface fill:#e1f5ff
    style uc2 fill:#fff4e1

図で理解できる要点:

  • 依存逆転前は UseCase が具体的な実装(MySQL)に直接依存している
  • 依存逆転後は UseCase が抽象(Interface)に依存し、実装を自由に切り替えられる
  • テスト時には InMemory 実装、本番では MySQL 実装というように使い分けが可能

インターフェースの定義

まず、データアクセスの抽象化としてインターフェースを定義します。

php<?php
namespace App\Domain\Repository;

use App\Domain\Entity\User;

// ドメイン層:Repository のインターフェース(抽象)
interface UserRepositoryInterface
{
    /**
     * ユーザーを保存する
     */
    public function save(User $user): void;

    /**
     * メールアドレスでユーザーを検索する
     */
    public function findByEmail(string $email): ?User;

    /**
     * ID でユーザーを検索する
     */
    public function findById(int $id): ?User;
}

このインターフェースはドメイン層に配置され、ビジネスロジックが必要とするデータ操作を定義しています。

インターフェースの実装

次に、インフラストラクチャ層で具体的な実装を提供します。

php<?php
namespace App\Infrastructure\Repository;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use Illuminate\Support\Facades\DB;

// インフラストラクチャ層:Repository の具体的実装
class MySQLUserRepository implements UserRepositoryInterface
{
    public function save(User $user): void
    {
        // データベースへの保存処理
        DB::table('users')->insert([
            'email' => $user->getEmail(),
            'password' => $user->getPasswordHash(),
            'created_at' => now(),
        ]);
    }

実装クラスはインターフェースを実装し、具体的なデータベースアクセスロジックを提供します。

php<?php
    public function findByEmail(string $email): ?User
    {
        $row = DB::table('users')
            ->where('email', $email)
            ->first();

        if ($row === null) {
            return null;
        }

        // データベースの行を Entity に変換
        return new User(
            id: $row->id,
            email: $row->email,
            passwordHash: $row->password
        );
    }

    public function findById(int $id): ?User
    {
        $row = DB::table('users')
            ->where('id', $id)
            ->first();

        if ($row === null) {
            return null;
        }

        return new User(
            id: $row->id,
            email: $row->email,
            passwordHash: $row->password
        );
    }
}

この実装により、データベースの詳細がインフラストラクチャ層に隔離されました。

依存性注入の設定

Laravel のサービスコンテナを使用して、インターフェースと実装をバインドします。

php<?php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Domain\Repository\UserRepositoryInterface;
use App\Infrastructure\Repository\MySQLUserRepository;

// 依存性注入の設定
class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // インターフェースに対して具体的な実装をバインド
        $this->app->bind(
            UserRepositoryInterface::class,
            MySQLUserRepository::class
        );
    }
}

これにより、UserRepositoryInterface を要求するクラスには自動的に MySQLUserRepository が注入されます。必要に応じて、テスト環境では別の実装に切り替えることも容易です。

ユースケース分離の実装

ユースケース分離とは、各ビジネスユースケースを独立したクラスとして実装する手法です。これにより、ビジネスロジックの再利用性とテスタビリティが向上します。

UseCase クラスの構造

php<?php
namespace App\Application\UseCase;

// UseCase のリクエストデータを表すクラス
class RegisterUserRequest
{
    public function __construct(
        public readonly string $email,
        public readonly string $password
    ) {}
}

リクエストデータは、イミュータブルな(読み取り専用の)データ構造として定義します。

php<?php
namespace App\Application\UseCase;

// UseCase のレスポンスデータを表すクラス
class RegisterUserResponse
{
    public function __construct(
        public readonly int $userId,
        public readonly string $email
    ) {}
}

レスポンスデータも同様に、イミュータブルな構造として定義しましょう。

UseCase の実装

php<?php
namespace App\Application\UseCase;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Service\UserDomainService;

// アプリケーション層:ユーザー登録のユースケース
class RegisterUserUseCase
{
    public function __construct(
        private UserRepositoryInterface $userRepository,
        private UserDomainService $userDomainService
    ) {}

UseCase のコンストラクタでは、必要な Repository と DomainService を依存性注入で受け取ります。

php<?php
    public function execute(RegisterUserRequest $request): RegisterUserResponse
    {
        // 1. メールアドレスの重複チェック(ドメインサービスに委譲)
        if ($this->userDomainService->isEmailDuplicated($request->email)) {
            throw new \DomainException('Email already exists');
        }

        // 2. User エンティティの生成
        $user = User::create(
            email: $request->email,
            plainPassword: $request->password
        );

        // 3. ユーザーの永続化
        $this->userRepository->save($user);

        // 4. レスポンスの生成
        return new RegisterUserResponse(
            userId: $user->getId(),
            email: $user->getEmail()
        );
    }
}

UseCase の execute メソッドは、ビジネスロジックの実行手順を明確に表現しています。各ステップがコメントで説明され、処理の流れが理解しやすくなっていますね。

具体例

完全な実装例

ここまでの解説を踏まえて、完全な実装例を見ていきましょう。ユーザー登録機能を例に、各層のコードを示します。

ドメイン層の実装

php<?php
namespace App\Domain\Entity;

// ドメイン層:User エンティティ
class User
{
    private ?int $id;
    private string $email;
    private string $passwordHash;

    private function __construct(
        ?int $id,
        string $email,
        string $passwordHash
    ) {
        $this->id = $id;
        $this->email = $email;
        $this->passwordHash = $passwordHash;
    }

Entity のコンストラクタは private にして、ファクトリメソッドから生成するようにします。

php<?php
    // 新規ユーザーを作成するファクトリメソッド
    public static function create(string $email, string $plainPassword): self
    {
        // バリデーション
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Invalid email format');
        }

        if (strlen($plainPassword) < 8) {
            throw new \InvalidArgumentException(
                'Password must be at least 8 characters'
            );
        }

        // パスワードのハッシュ化
        $passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT);

        return new self(null, $email, $passwordHash);
    }

ファクトリメソッドでは、バリデーションとビジネスルールの適用を行います。

php<?php
    // 既存ユーザーを再構築するファクトリメソッド
    public static function reconstruct(
        int $id,
        string $email,
        string $passwordHash
    ): self {
        return new self($id, $email, $passwordHash);
    }

    // ゲッターメソッド
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function getPasswordHash(): string
    {
        return $this->passwordHash;
    }
}

Entity は不変オブジェクトとして設計し、状態の変更は新しいインスタンスを返すメソッドで行います。

ドメインサービスの実装

php<?php
namespace App\Domain\Service;

use App\Domain\Repository\UserRepositoryInterface;

// ドメイン層:ドメインサービス
class UserDomainService
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    /**
     * メールアドレスの重複チェック
     * 複数のエンティティにまたがるロジックはドメインサービスに記述
     */
    public function isEmailDuplicated(string $email): bool
    {
        $existingUser = $this->userRepository->findByEmail($email);
        return $existingUser !== null;
    }
}

ドメインサービスは、単一の Entity では表現できないビジネスロジックを担当します。

アプリケーション層の実装(詳細版)

php<?php
namespace App\Application\UseCase;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Service\UserDomainService;
use Psr\Log\LoggerInterface;

// アプリケーション層:ユーザー登録ユースケース(完全版)
class RegisterUserUseCase
{
    public function __construct(
        private UserRepositoryInterface $userRepository,
        private UserDomainService $userDomainService,
        private LoggerInterface $logger
    ) {}

ロギングなどのインフラストラクチャ的関心事も、インターフェース経由で依存性注入します。

php<?php
    public function execute(RegisterUserRequest $request): RegisterUserResponse
    {
        $this->logger->info('User registration started', [
            'email' => $request->email
        ]);

        try {
            // メールアドレスの重複チェック
            if ($this->userDomainService->isEmailDuplicated($request->email)) {
                throw new \DomainException(
                    "Email '{$request->email}' is already registered"
                );
            }

            // User エンティティの生成
            $user = User::create(
                email: $request->email,
                plainPassword: $request->password
            );

            // ユーザーの永続化
            $this->userRepository->save($user);

            $this->logger->info('User registration completed', [
                'user_id' => $user->getId()
            ]);

            return new RegisterUserResponse(
                userId: $user->getId(),
                email: $user->getEmail()
            );

        } catch (\Exception $e) {
            $this->logger->error('User registration failed', [
                'email' => $request->email,
                'error' => $e->getMessage()
            ]);
            throw $e;
        }
    }
}

UseCase では、処理の流れを調整し、適切なロギングとエラーハンドリングを行います。

プレゼンテーション層の実装(完全版)

php<?php
namespace App\Presentation\Controller;

use App\Application\UseCase\RegisterUserUseCase;
use App\Application\UseCase\RegisterUserRequest;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

// プレゼンテーション層:Controller(完全版)
class UserController
{
    public function __construct(
        private RegisterUserUseCase $registerUserUseCase
    ) {}

Controller では UseCase のみに依存し、他の層への直接的な依存を避けます。

php<?php
    public function register(Request $request): JsonResponse
    {
        // 1. リクエストのバリデーション(フレームワーク固有の処理)
        $validated = $request->validate([
            'email' => 'required|email',
            'password' => 'required|string|min:8',
        ]);

        try {
            // 2. UseCase リクエストの生成
            $useCaseRequest = new RegisterUserRequest(
                email: $validated['email'],
                password: $validated['password']
            );

            // 3. UseCase の実行
            $response = $this->registerUserUseCase->execute($useCaseRequest);

            // 4. HTTP レスポンスの生成
            return response()->json([
                'data' => [
                    'user_id' => $response->userId,
                    'email' => $response->email,
                ],
                'message' => 'User registered successfully'
            ], 201);

        } catch (\DomainException $e) {
            // ドメイン例外はクライアントエラーとして扱う
            return response()->json([
                'error' => $e->getMessage()
            ], 400);

        } catch (\Exception $e) {
            // その他の例外はサーバーエラーとして扱う
            return response()->json([
                'error' => 'Internal server error'
            ], 500);
        }
    }
}

Controller では、HTTP に関する処理(バリデーション、レスポンス生成、ステータスコード設定)のみを担当します。

テストコードの例

適切にレイヤー分離されたコードは、テストが容易になります。

php<?php
namespace Tests\Application\UseCase;

use App\Application\UseCase\RegisterUserUseCase;
use App\Application\UseCase\RegisterUserRequest;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Service\UserDomainService;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;

// UseCase のテスト
class RegisterUserUseCaseTest extends TestCase
{
    public function test_ユーザー登録が成功する(): void
    {
        // モックの作成
        $repository = $this->createMock(UserRepositoryInterface::class);
        $repository->method('findByEmail')->willReturn(null);
        $repository->expects($this->once())->method('save');

        $domainService = new UserDomainService($repository);
        $useCase = new RegisterUserUseCase(
            $repository,
            $domainService,
            new NullLogger()
        );

モックを使用することで、データベースなしでテストを実行できます。

php<?php
        // テストの実行
        $request = new RegisterUserRequest(
            email: 'test@example.com',
            password: 'password123'
        );

        $response = $useCase->execute($request);

        // アサーション
        $this->assertNotNull($response->userId);
        $this->assertEquals('test@example.com', $response->email);
    }

    public function test_メールアドレスが重複している場合は例外が発生する(): void
    {
        // 重複ユーザーが存在する状態をモック
        $repository = $this->createMock(UserRepositoryInterface::class);
        $repository->method('findByEmail')->willReturn(
            \App\Domain\Entity\User::reconstruct(
                1,
                'test@example.com',
                'hash'
            )
        );

        $domainService = new UserDomainService($repository);
        $useCase = new RegisterUserUseCase(
            $repository,
            $domainService,
            new NullLogger()
        );

        // 例外が発生することを期待
        $this->expectException(\DomainException::class);

        $request = new RegisterUserRequest(
            email: 'test@example.com',
            password: 'password123'
        );

        $useCase->execute($request);
    }
}

テストケースごとに異なるモックの振る舞いを設定することで、様々なシナリオを検証できますね。

ディレクトリ構造の例

以下は、レイヤードアーキテクチャに基づいた推奨ディレクトリ構造です。

bashapp/
├── Domain/                      # ドメイン層
│   ├── Entity/
│   │   └── User.php
│   ├── ValueObject/
│   │   └── Email.php
│   ├── Repository/
│   │   └── UserRepositoryInterface.php
│   └── Service/
│       └── UserDomainService.php
│
├── Application/                 # アプリケーション層
│   └── UseCase/
│       ├── RegisterUserUseCase.php
│       ├── RegisterUserRequest.php
│       └── RegisterUserResponse.php
│
├── Infrastructure/              # インフラストラクチャ層
│   ├── Repository/
│   │   ├── MySQLUserRepository.php
│   │   └── InMemoryUserRepository.php
│   └── External/
│       └── MailService.php
│
└── Presentation/                # プレゼンテーション層
    ├── Controller/
    │   └── UserController.php
    └── Request/
        └── RegisterUserHttpRequest.php

この構造により、各層の責務が明確になり、コードの配置場所が直感的に理解できます。

まとめ

本記事では、PHP アプリケーションにおける 3 つの重要なアーキテクチャ設計手法を解説しました。

レイヤリングにより、アプリケーションを責務ごとに分離し、各層の役割を明確にできます。プレゼンテーション層、アプリケーション層、ドメイン層、インフラストラクチャ層という 4 層構造を採用することで、変更の影響範囲を限定できるでしょう。

依存逆転の原則を適用することで、上位層が具体的な実装に依存せず、抽象(インターフェース)に依存するようになります。これにより、実装の切り替えが容易になり、テスタビリティが大幅に向上しますね。

ユースケース分離では、各ビジネスユースケースを独立したクラスとして実装します。これにより、ビジネスロジックの再利用性が高まり、コードの意図が明確になります。

これらの手法を組み合わせることで、以下のメリットが得られます。

  • 変更に強い設計:1 つの層の変更が他の層に影響しにくい
  • テストしやすい:モックを使用した単体テストが容易
  • 理解しやすい:各クラスの責務が明確で、コードの意図が読み取りやすい
  • 拡張しやすい:新機能の追加や既存機能の修正がスムーズ

最初は複雑に感じるかもしれませんが、小さな機能から段階的に適用していくことをお勧めします。プロジェクトの成長とともに、適切なアーキテクチャ設計の価値を実感できるはずですよ。

関連リンク