mcpField
C
clean-architecture-php

clean-architecture-php

github.com/giuseppe-trisciuoglio開発36595 回インストール7 週間前に更新
リポジトリ

clean-architecture-php について

PHP 8.3+ と Symfony 7.x 向けに、クリーンアーキテクチャ、ヘキサゴナルアーキテクチャ、ドメイン駆動設計を扱うスキルです。ドメインロジックをフレームワークやインフラから分離し、コア層を独立してテストできる構成を案内します。

主な機能

  1. 01依存関係を内側へ向けるレイヤー設計
  2. 02ドメイン駆動設計パターンの適用
  3. 03ポートとアダプターによるインフラ分離
  4. 04インメモリリポジトリによるテスト支援
  5. 05Symfony の依存性注入の設定

活用シーン

  1. 01新しい Symfony のエンタープライズアプリを設計
  2. 02レガシー PHP をテスト可能な構造にリファクタリング
  3. 03PHP プロジェクトにドメイン駆動設計を導入
  4. 04交換可能なインフラを持つモジュラーシステムを構築

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/giuseppe-trisciuoglio/developer-kit --skill clean-architecture-php

インストール後、Claude Code で/clean-architecture-phpを入力して読み込まれたか確認します。

同梱ファイルreferences/php-clean-architecture.mdreferences/symfony-implementation.md

詳細

PHP/Symfony 向けクリーンアーキテクチャ、ヘキサゴナルアーキテクチャ、DDD

概要

このスキルでは、Symfony 7.x を使用する PHP 8.3 以降のアプリケーションで、クリーンアーキテクチャ、ヘキサゴナルアーキテクチャ(ポート&アダプター)、ドメイン駆動設計のパターンを実装するためのガイダンスを提供します。内側にのみ依存するレイヤー構造を通じて、関心事を明確に分離し、フレームワークに依存しないビジネスロジックと、テストしやすいコードを実現します。

使用する場面

  • Symfony 7.x で新しいエンタープライズ向け PHP アプリケーションを設計する場合
  • レガシー PHP コードを、最新のテストしやすいパターンにリファクタリングする場合
  • PHP プロジェクトでドメイン駆動設計を実装する場合
  • 関心事が明確に分離された、保守しやすいアプリケーションを作成する場合
  • フレームワークに依存しない、テスト可能なビジネスロジックを構築する場合
  • インフラストラクチャを差し替え可能な、モジュール式の PHP システムを設計する場合

手順

1. アーキテクチャのレイヤーを理解する

クリーンアーキテクチャは依存関係のルールに従います。依存関係は内側にのみ向かいます。

+-------------------------------------+
|  Infrastructure (Frameworks)        |  Symfony, Doctrine, External APIs
+-------------------------------------+
|  Adapter (Interface Adapters)       |  Controllers, Repositories, Presenters
+-------------------------------------+
|  Application (Use Cases)            |  Commands, Handlers, DTOs
+-------------------------------------+
|  Domain (Entities & Business Rules) |  Entities, Value Objects, Domain Events
+-------------------------------------+

ヘキサゴナルアーキテクチャ(ポート&アダプター):

  • ドメインコア:ビジネスロジック。フレームワークに依存しません
  • ポート:インターフェース(例:UserRepositoryInterface)
  • アダプター:具体的な実装(Doctrine、テスト用の InMemory など)

DDD の戦術的パターン:

  • エンティティ:ID を持つオブジェクト(例:User、Order)
  • 値オブジェクト:属性によって定義される不変のオブジェクト(例:Email、Money)
  • 集約:ルートエンティティを持つ整合性の境界
  • ドメインイベント:ビジネス上の出来事を記録します
  • リポジトリ:集約を永続化および取得します
2. ディレクトリ構造を整理する

レイヤーの分離を徹底するため、次のディレクトリ構造を作成します。

src/
+-- Domain/                 # Innermost layer - no dependencies
|   +-- Entity/
|   |   +-- User.php
|   |   +-- Order.php
|   +-- ValueObject/
|   |   +-- Email.php
|   |   +-- Money.php
|   |   +-- OrderId.php
|   +-- Repository/
|   |   +-- UserRepositoryInterface.php
|   +-- Event/
|   |   +-- UserCreatedEvent.php
|   +-- Exception/
|       +-- DomainException.php
+-- Application/            # Use cases - depends on Domain
|   +-- Command/
|   |   +-- CreateUserCommand.php
|   |   +-- UpdateOrderCommand.php
|   +-- Handler/
|   |   +-- CreateUserHandler.php
|   |   +-- UpdateOrderHandler.php
|   +-- Query/
|   |   +-- GetUserQuery.php
|   +-- Dto/
|   |   +-- UserDto.php
|   +-- Service/
|       +-- NotificationServiceInterface.php
+-- Adapter/                # Interface adapters
|   +-- Http/
|   |   +-- Controller/
|   |   |   +-- UserController.php
|   |   +-- Request/
|   |       +-- CreateUserRequest.php
|   +-- Persistence/
|       +-- Doctrine/
|           +-- Repository/
|           |   +-- DoctrineUserRepository.php
|           +-- Mapping/
|               +-- User.orm.xml
+-- Infrastructure/         # Framework & external concerns
    +-- Config/
    |   +-- services.yaml
    +-- Event/
    |   +-- SymfonyEventDispatcher.php
    +-- Service/
        +-- SendgridEmailService.php
3. ドメインレイヤーを実装する

最も内側のレイヤー(ドメイン)から始め、外側へと進めます。

  1. 値オブジェクトを作成する際に、コンストラクターで検証を行います。PHP 8.1 以降の readonly を使って不変にする必要があります
  2. ドメインロジックとビジネスルールを備えたエンティティを作成します。エンティティは単なるデータの入れ物ではなく、振る舞いをカプセル化する必要があります
  3. リポジトリインターフェース(ポート)を定義します。小さく、目的を絞ったものにします
  4. コアのビジネスロジックから副作用を切り離すために、ドメインイベントを定義します
4. アプリケーションレイヤーを実装する

ドメインオブジェクトを調整するユースケースを構築します。

  1. 書き込み操作を表す readonly DTO として、コマンドを作成します
  2. 読み取り操作用のクエリを作成します(CQRS パターン)
  3. コマンドやクエリを受け取り、ドメインオブジェクトを調整するハンドラーを実装します
  4. 外部依存関係(通知など)のサービスインターフェースを定義します
5. アダプターレイヤーを実装する

アプリケーションとインフラストラクチャを接続するインターフェースアダプターを作成します。

  1. HTTP リクエストを受け取り、ハンドラーを呼び出すコントローラーを作成します
  2. Symfony のバリデーション属性を持つリクエスト DTO を作成します
  3. ドメインインターフェースと永続化レイヤーをつなぐリポジトリーアダプターを実装します
6. インフラストラクチャを設定する

フレームワーク固有の設定を行います。

  1. インターフェースと実装を対応付けるために、Symfony DI を設定します
  2. データベースなしでユニットテストを行えるよう、テスト用の代替実装(In-Memory リポジトリ)を作成します
  3. 永続化のためのDoctrine マッピングを設定します
7. フレームワークなしでテストする

Symfony、Doctrine、データベースなしで、ドメインレイヤーとアプリケーションレイヤーをテストできるようにします。高速なユニットテストには In-Memory リポジトリを使用します。

例

例 1:バリデーション付きの値オブジェクト
<?php
// src/Domain/ValueObject/Email.php

namespace App\Domain\ValueObject;

use InvalidArgumentException;

final readonly class Email
{
    public function __construct(
        private string $value
    ) {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(
                sprintf('"%s" is not a valid email address', $value)
            );
        }
    }

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

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function domain(): string
    {
        return substr($this->value, strrpos($this->value, '@') + 1);
    }
}
例 2:ドメインロジックを持つエンティティ
<?php
// src/Domain/Entity/User.php

namespace App\Domain\Entity;

use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use App\Domain\Event\UserCreatedEvent;
use DateTimeImmutable;

class User
{
    private array $domainEvents = [];

    public function __construct(
        private UserId $id,
        private Email $email,
        private string $name,
        private DateTimeImmutable $createdAt,
        private bool $isActive = true
    ) {
        $this->recordEvent(new UserCreatedEvent($id->value()));
    }

    public static function create(
        UserId $id,
        Email $email,
        string $name
    ): self {
        return new self(
            $id,
            $email,
            $name,
            new DateTimeImmutable()
        );
    }

    public function deactivate(): void
    {
        $this->isActive = false;
    }

    public function canPlaceOrder(): bool
    {
        return $this->isActive;
    }

    public function id(): UserId
    {
        return $this->id;
    }

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

    public function domainEvents(): array
    {
        return $this->domainEvents;
    }

    public function clearDomainEvents(): void
    {
        $this->domainEvents = [];
    }

    private function recordEvent(object $event): void
    {
        $this->domainEvents[] = $event;
    }
}
例 3:リポジトリポート(インターフェース)
<?php
// src/Domain/Repository/UserRepositoryInterface.php

namespace App\Domain\Repository;

use App\Domain\Entity\User;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;

interface UserRepositoryInterface
{
    public function findById(UserId $id): ?User;

    public function findByEmail(Email $email): ?User;

    public function save(User $user): void;

    public function delete(UserId $id): void;
}
例 4:コマンドとハンドラー
<?php
// src/Application/Command/CreateUserCommand.php

namespace App\Application\Command;

final readonly class CreateUserCommand
{
    public function __construct(
        public string $id,
        public string $email,
        public string $name
    ) {
    }
}
<?php
// src/Application/Handler/CreateUserHandler.php

namespace App\Application\Handler;

use App\Application\Command\CreateUserCommand;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use InvalidArgumentException;

readonly class CreateUserHandler
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {
    }

    public function __invoke(CreateUserCommand $command): void
    {
        $email = new Email($command->email);

        if ($this->userRepository->findByEmail($email) !== null) {
            throw new InvalidArgumentException(
                'User with this email already exists'
            );
        }

        $user = User::create(
            new UserId($command->id),
            $email,
            $command->name
        );

        $this->userRepository->save($user);
    }
}
例 5:Symfony コントローラー
<?php
// src/Adapter/Http/Controller/UserController.php

namespace App\Adapter\Http\Controller;

use App\Adapter\Http\Request\CreateUserRequest;
use App\Application\Command\CreateUserCommand;
use App\Application\Handler\CreateUserHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

#[AsController]
class UserController
{
    public function __construct(
        private CreateUserHandler $createUserHandler
    ) {
    }

    #[Route('/api/users', methods: ['POST'])]
    public function create(CreateUserRequest $request): JsonResponse
    {
        $command = new CreateUserCommand(
            id: Uuid::v4()->toRfc4122(),
            email: $request->email,
            name: $request->name
        );

        ($this->createUserHandler)($command);

        return new JsonResponse(['id' => $command->id], 201);
    }
}
例 6:バリデーション付きリクエスト DTO
<?php
// src/Adapter/Http/Request/CreateUserRequest.php

namespace App\Adapter\Http\Request;

use Symfony\Component\Validator\Constraints as Assert;

class CreateUserRequest
{
    #[Assert\NotBlank]
    #[Assert\Email]
    public string $email;

    #[Assert\NotBlank]
    #[Assert\Length(min: 2, max: 100)]
    public string $name;
}
例 7:Doctrine リポジトリアダプター
<?php
// src/Adapter/Persistence/Doctrine/Repository/DoctrineUserRepository.php

namespace App\Adapter\Persistence\Doctrine\Repository;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use Doctrine\ORM\EntityManagerInterface;

readonly class DoctrineUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {
    }

    public function findById(UserId $id): ?User
    {
        return $this->entityManager
            ->getRepository(User::class)
            ->find($id->value());
    }

    public function findByEmail(Email $email): ?User
    {
        return $this->entityManager
            ->getRepository(User::class)
            ->findOneBy(['email.value' => $email->value()]);
    }

    public function save(User $user): void
    {
        $this->entityManager->persist($user);
        $this->entityManager->flush();
    }

    public function delete(UserId $id): void
    {
        $user = $this->findById($id);
        if ($user !== null) {
            $this->entityManager->remove($user);
            $this->entityManager->flush();
        }
    }
}
例 8:Symfony DI の設定
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'
        exclude:
            - '../src/Domain/Entity/'
            - '../src/Kernel.php'

    # Repository binding - Port to Adapter
    App\Domain\Repository\UserRepositoryInterface:
        class: App\Adapter\Persistence\Doctrine\Repository\DoctrineUserRepository

    # In-memory repository for tests
    App\Domain\Repository\UserRepositoryInterface $inMemoryUserRepository:
        class: App\Tests\Infrastructure\Repository\InMemoryUserRepository
例 9:テスト用の In-Memory リポジトリ
<?php
// tests/Infrastructure/Repository/InMemoryUserRepository.php

namespace App\Tests\Infrastructure\Repository;

use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;

class InMemoryUserRepository implements UserRepositoryInterface
{
    private array $users = [];

    public function findById(UserId $id): ?User
    {
        return $this->users[$id->value()] ?? null;
    }

    public function findByEmail(Email $email): ?User
    {
        foreach ($this->users as $user) {
            if ($user->email()->equals($email)) {
                return $user;
            }
        }
        return null;
    }

    public function save(User $user): void
    {
        $this->users[$user->id()->value()] = $user;
    }

    public function delete(UserId $id): void
    {
        unset($this->users[$id->value()]);
    }
}

ベストプラクティス

  1. 依存関係のルール:依存関係は内側にのみ向けます。ドメインはアプリケーションやインフラストラクチャについて何も知りません。
  2. 不変性:値オブジェクトは、PHP 8.1 以降では readonly を使用して必ず不変にします。可変状態は決して許可しないでください。
  3. リッチドメインモデル:create() のようなファクトリーメソッドを備えたエンティティにビジネスロジックを配置します。貧血モデルは避けてください。
  4. インターフェース分離:リポジトリインターフェースは小さく、目的を絞ったものにします。何でも含む巨大なインターフェースは作成しないでください。
  5. フレームワークからの独立性:ドメイン層とアプリケーション層は、Symfony や Doctrine がなくてもテスト可能でなければなりません。
  6. 構築時のバリデーション:値オブジェクトの構築時にバリデーションを行います。無効な状態を決して許可しないでください。
  7. Symfony 属性:ルーティング(#[Route])、バリデーション(#[Assert\])、DI には PHP 8 の属性を使用します。
  8. テストダブル:高速な単体テストを可能にするため、リポジトリには必ずインメモリー実装を用意します。
  9. ドメインイベント:副作用を切り離すためにドメインイベントをディスパッチします。エンティティから外部サービスを呼び出さないでください。
  10. XML/YAML マッピング:ドメインエンティティのアノテーションではなく、Doctrine のマッピングには XML または YAML を使用します。

制約と警告

アーキテクチャ上の制約
  • 依存関係のルール:依存関係は内側にのみ向けます。ドメインはアプリケーションについて何も知らず、アプリケーションはインフラストラクチャについて何も知りません。これに違反すると、アーキテクチャが損なわれます。
  • 貧血ドメインの禁止:エンティティは単なるデータの入れ物ではなく、振る舞いをカプセル化する必要があります。ビジネスロジックを伴わないゲッターやセッターは避けてください。
  • インターフェース分離:リポジトリインターフェースは小さく、目的を絞ったものにします。何でも含む巨大なインターフェースは作成しないでください。
PHP の実装上の制約
  • 不変性:値オブジェクトは、PHP 8.1 以降では readonly を使用して必ず不変にします。値オブジェクトで可変状態を決して許可しないでください。
  • バリデーション:値オブジェクトの構築時にバリデーションを行います。無効な状態が存在しないようにしてください。
  • Symfony 属性:ルーティング、バリデーション、DI には PHP 8 の属性(#[Route]、#[Assert\Email]、#[Autowire])を使用します。
テスト上の制約
  • フレームワークからの独立性:ドメイン層とアプリケーション層は、Symfony、Doctrine、データベースがなくてもテスト可能でなければなりません。
  • テストダブル:高速な単体テストを可能にするため、リポジトリインターフェースには必ずインメモリー実装を用意します。
警告
  • コントローラーでのリッチドメインモデルの回避:コントローラーは調整のみを行い、ビジネスロジックを含めないでください。
  • 抽象化の漏れに注意:Doctrine のアノテーションなどのインフラストラクチャ上の関心事を、ドメインエンティティに漏らさないでください。代わりに XML/YAML マッピングを使用します。
  • コマンドバスの検討:複雑なアプリケーションでは、非同期処理に Symfony Messenger を使用します。ハンドラーに複雑なオーケストレーションをインラインで記述しないでください。
  • ドメインイベント:コアのビジネスロジックから副作用を切り離すために、ドメインイベントをディスパッチします。エンティティから外部サービスを直接呼び出さないでください。

参考資料

コメント